البحث الدلالي هو العمود الفقري لتطبيقات الذكاء الاصطناعي الحديثة — فمسارات RAG ومحركات التوصية وإزالة التكرار وميزات "ابحث عن المشابه" كلها تعتمد عليه. معظم الدروس تلجأ مباشرة إلى خدمة سحابية مُدارة، لكن ذلك يعني تسعيرًا لكل متجه، وخروج بياناتك من منطقتك، واعتمادًا صارمًا لا يمكنك نقله. Qdrant يغيّر هذه المعادلة: إنها قاعدة بيانات متجهية مفتوحة المصدر، مكتوبة بلغة Rust، يمكنك تشغيلها على حاسوبك المحمول، أو على خادمك الخاص في تونس أو السعودية، أو في Qdrant Cloud — باستخدام نفس الواجهة البرمجية تمامًا.
في هذا الدرس ستبني خدمة بحث دلالي كاملة من الصفر. ستُشغّل Qdrant عبر Docker، وتولّد التضمينات باستخدام OpenAI، وتخزّنها مع حمولات بيانات وصفية غنية، وتعرض نقطة نهاية بحث ذات أنواع محددة في مشروع Next.js App Router. وبنهاية الدرس ستفهم حلقة الاسترجاع كاملةً وتمتلك كل جزء منها.
المتطلبات المسبقة
قبل البدء، تأكد من توفر ما يلي:
- Node.js 20+ مثبّت (
node --version) - Docker مثبّت وقيد التشغيل (
docker --version) - معرفة أساسية بـ TypeScript وNext.js App Router
- مفتاح OpenAI API (أو أي مزود تضمينات — النمط متطابق)
- محرر أكواد مثل VS Code
لست بحاجة إلى حساب Qdrant. سنُشغّل كل شيء محليًا، ونفس الكود يُنشر على خادم دون أي تغيير.
ما الذي ستبنيه
واجهة بحث دلالي لكتالوج من المقالات. يرسل المستخدم استعلامًا بلغة طبيعية مثل "كيف أجعل قاعدة بياناتي أسرع" فيستعيد المستندات الأكثر صلة دلاليًا — حتى عندما لا تظهر أيٌّ من تلك الكلمات نفسها في النص. تبدو البنية كالتالي:
- الإدخال (Ingest) — تحويل كل مستند إلى متجه تضمين وتخزينه في Qdrant مع حمولة بيانات وصفية.
- الاستعلام (Query) — تحويل سؤال المستخدم إلى متجه وسؤال Qdrant عن أقرب الجيران.
- التصفية (Filter) — تضييق النتائج حسب البيانات الوصفية المنظمة (الفئة، اللغة، حالة النشر) دون فقدان الترتيب الدلالي.
- العرض (Serve) — كشف كل ذلك عبر معالج مسار نظيف في Next.js.
لماذا Qdrant؟
المتجهات مجرد قوائم من الأرقام — يربط نموذج التضمين النص بفضاء عالي الأبعاد حيث تقع المعاني المتشابهة قريبةً من بعضها. تخزّن قاعدة البيانات المتجهية ملايين من هذه المتجهات وتجيب عن سؤال "أي المتجهات المخزّنة أقرب إلى هذا؟" في أجزاء من الثانية باستخدام فهرس HNSW. وإليك سبب تميّز Qdrant في عام 2026:
- قابلة للاستضافة الذاتية ومفتوحة المصدر (رخصة Apache 2.0). بياناتك وفهرسك يعيشان حيثما تقرر — ميزة حقيقية في ظل قواعد إقامة البيانات في منطقة الشرق الأوسط وشمال أفريقيا مثل إطار INPDP التونسي أو نظام حماية البيانات الشخصية السعودي PDPL.
- مكتوبة بلغة Rust لبصمة ذاكرة منخفضة وزمن استجابة متوقع.
- تصفية حمولة غنية تجمع بين الشروط المنظمة والبحث الدلالي في طلب واحد.
- واجهة Query API موحدة تغطي البحث الكثيف والمتفرق والدمج الهجين والتوصيات عبر دالة واحدة.
- بلا فوترة لكل متجه عند الاستضافة الذاتية — تدفع ثمن الخادم لا ثمن الصفوف.
الخطوة 1: تشغيل Qdrant عبر Docker
أسرع طريقة للحصول على نسخة Qdrant هي صورة Docker الرسمية. أنشئ ملف docker-compose.yml في جذر مشروعك:
services:
qdrant:
image: qdrant/qdrant:v1.18.0
restart: always
ports:
- "6333:6333" # REST + واجهة الويب
- "6334:6334" # gRPC
volumes:
- ./qdrant_storage:/qdrant/storage
environment:
QDRANT__SERVICE__API_KEY: "local-dev-key"شغّله:
docker compose up -dأصبح Qdrant قيد التشغيل الآن. هناك أمران يستحقان المعرفة:
- البيانات المحفوظة تعيش في
./qdrant_storage، لذا أضف هذا المجلد إلى.gitignore. - افتح
http://localhost:6333/dashboardفي متصفحك — يأتي Qdrant مع واجهة ويب مدمجة يمكنك من خلالها فحص المجموعات وتشغيل الاستعلامات وتصوّر متجهاتك. هذه أداة لا تُقدّر بثمن أثناء تصحيح الأخطاء.
في الإنتاج، اضبط QDRANT__SERVICE__API_KEY قويًا من مدير أسرار وضع الخدمة خلف TLS. لا تكشف المنفذ 6333 أبدًا للإنترنت العام دون مصادقة.
الخطوة 2: إعداد مشروع Next.js
إذا لم يكن لديك مشروع بعد، أنشئ واحدًا:
npx create-next-app@latest qdrant-search --typescript --app --no-tailwind
cd qdrant-searchثبّت عميل Qdrant وحزمة OpenAI SDK:
npm install @qdrant/js-client-rest openaiأنشئ ملف .env.local. لا تضع هذه القيم في الكود مباشرة أبدًا:
QDRANT_URL="http://localhost:6333"
QDRANT_API_KEY="local-dev-key"
OPENAI_API_KEY="sk-your-key-here"الخطوة 3: إنشاء عميل Qdrant ومساعد التضمينات
اجمع كلا العميلين في وحدة واحدة لتبقى بقية التطبيق نظيفة. أنشئ lib/qdrant.ts:
import { QdrantClient } from "@qdrant/js-client-rest";
import OpenAI from "openai";
// عميل Qdrant مشترك واحد للتطبيق بأكمله
export const qdrant = new QdrantClient({
url: process.env.QDRANT_URL!,
apiKey: process.env.QDRANT_API_KEY,
});
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export const COLLECTION = "articles";
export const VECTOR_SIZE = 1536; // حجم مخرجات text-embedding-3-small
// تحويل أي نص إلى متجه تضمين كثيف
export async function embed(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
return response.data[0].embedding;
}ينتج نموذج التضمين text-embedding-3-small متجهات بـ 1536 بُعدًا. القاعدة الأهم في البحث المتجهي: يجب أن يتطابق حجم المتجه ومقياس المسافة اللذان تختارهما عند إنشاء المجموعة مع نموذج التضمين تمامًا، ويجب أن تستخدم النموذج نفسه للإدخال والاستعلام معًا. خلط النماذج يؤدي إلى نتائج بلا معنى.
الخطوة 4: إنشاء المجموعة
المجموعة في Qdrant تشبه الجدول — تحتفظ بالنقاط (متجهات مع حمولات) وتحدد كيفية فهرستها. أنشئ scripts/init-collection.ts:
import { qdrant, COLLECTION, VECTOR_SIZE } from "../lib/qdrant";
async function init() {
// إعادة إنشاء نظيفة إذا كانت موجودة (للتطوير فقط)
const exists = await qdrant.collectionExists(COLLECTION);
if (exists.exists) {
await qdrant.deleteCollection(COLLECTION);
}
await qdrant.createCollection(COLLECTION, {
vectors: {
size: VECTOR_SIZE,
distance: "Cosine", // الأفضل لتضمينات النص المُطبّعة
},
});
// فهرسة حقول الحمولة التي نخطط للتصفية بناءً عليها.
// بدون هذا تعمل التصفية لكنها أبطأ بكثير على نطاق واسع.
await qdrant.createPayloadIndex(COLLECTION, {
field_name: "category",
field_schema: "keyword",
});
await qdrant.createPayloadIndex(COLLECTION, {
field_name: "published",
field_schema: "bool",
});
console.log(`المجموعة "${COLLECTION}" جاهزة.`);
}
init().catch((err) => {
console.error("فشل تهيئة المجموعة:", err);
process.exit(1);
});خيار distance حاسم. بالنسبة لتضمينات النص، يكون Cosine صحيحًا دائمًا تقريبًا لأنه يقيس الزاوية بين المتجهات بدلًا من مقدارها. يدعم Qdrant أيضًا Dot وEuclid وManhattan لحالات استخدام أخرى.
شغّله عبر tsx:
npx tsx scripts/init-collection.tsلاحظ استدعاءات createPayloadIndex. في Qdrant يمكنك التصفية على أي حقل حمولة دون فهرس، لكن إنشاء فهرس على الحقول التي تستعلم عنها بكثرة يُبقي الأداء ثابتًا مع نمو مجموعة بياناتك من آلاف إلى ملايين النقاط.
الخطوة 5: إدخال المستندات
الآن حمّل بعض البيانات. يصبح كل مستند نقطة: معرّف ومتجه وحمولة من البيانات الوصفية يمكنك لاحقًا تصفيتها وإرجاعها. أنشئ scripts/ingest.ts:
import { qdrant, embed, COLLECTION } from "../lib/qdrant";
const documents = [
{
id: 1,
title: "تسريع Postgres بالفهرسة الصحيحة",
body: "فهارس B-tree وGIN تقلّل بشكل كبير زمن استجابة الاستعلام على الجداول الكبيرة.",
category: "database",
published: true,
},
{
id: 2,
title: "استراتيجيات التخزين المؤقت باستخدام Redis",
body: "أنماط cache-aside وwrite-through تخفّض الحمل على مخزنك الأساسي.",
category: "database",
published: true,
},
{
id: 3,
title: "تصميم أنظمة ألوان متاحة للجميع",
body: "نسب التباين ورموز الألوان تجعل الواجهات قابلة للاستخدام للجميع.",
category: "design",
published: true,
},
{
id: 4,
title: "مسودة داخلية عن التقسيم الأفقي (Sharding)",
body: "التقسيم الأفقي يوزّع حمل الكتابة عبر عدة عقد.",
category: "database",
published: false,
},
];
async function ingest() {
// تضمين كل المستندات. ندمج العنوان + المتن لسياق أغنى.
const points = await Promise.all(
documents.map(async (doc) => ({
id: doc.id,
vector: await embed(`${doc.title}. ${doc.body}`),
payload: {
title: doc.title,
body: doc.body,
category: doc.category,
published: doc.published,
},
}))
);
// upsert يُدرج أو يستبدل النقاط حسب المعرّف. wait:true يحجب
// حتى تُفهرس العملية بالكامل — مفيد في السكربتات.
await qdrant.upsert(COLLECTION, {
wait: true,
points,
});
console.log(`تم إدخال ${points.length} مستندًا.`);
}
ingest().catch((err) => {
console.error("فشل الإدخال:", err);
process.exit(1);
});شغّله:
npx tsx scripts/ingest.tsبعض الملاحظات الإنتاجية:
- اجمع عمليات upsert في دفعات. تضمين مستند واحد لكل طلب جيد للعرض التوضيحي، لكن في أحمال العمل الحقيقية أرسل المستندات إلى واجهة التضمينات على دفعات وأدرجها في كتل من بضع مئات من النقاط.
- معرّفات النقاط يجب أن تكون أعدادًا صحيحة غير سالبة أو UUID. استخدام معرّف ثابت (مثل المفتاح الأساسي لقاعدة بياناتك) يعني أن إعادة إدخال مستند تكتب فوقه ببساطة.
- النص الذي تضمّنه يجب أن يعكس ما يبحث عنه المستخدمون. دمج العنوان والمتن، كما نفعل هنا، يتفوق عادةً على تضمين المتن وحده.
الخطوة 6: تشغيل استعلام دلالي باستخدام Query API
هنا يحدث السحر. الطريقة الحديثة لاسترجاع النقاط في Qdrant هي واجهة Query API الموحدة — client.query() — التي تتعامل مع البحث الكثيف والبحث الهجين والتوصيات عبر دالة واحدة متسقة. أنشئ lib/search.ts:
import { qdrant, embed, COLLECTION } from "./qdrant";
export interface SearchResult {
id: string | number;
score: number;
title: string;
body: string;
category: string;
}
export async function semanticSearch(
query: string,
options: { limit?: number; category?: string } = {}
): Promise<SearchResult[]> {
const { limit = 5, category } = options;
// 1. تحويل استعلام المستخدم إلى متجه بنفس النموذج المستخدم في الإدخال
const queryVector = await embed(query);
// 2. بناء فلتر منظم اختياري.
// نقيّد دائمًا على المستندات المنشورة، واختياريًا على فئة واحدة.
const filter = {
must: [
{ key: "published", match: { value: true } },
...(category ? [{ key: "category", match: { value: category } }] : []),
],
};
// 3. سؤال Qdrant عن أقرب الجيران
const response = await qdrant.query(COLLECTION, {
query: queryVector,
limit,
filter,
with_payload: true,
});
// 4. تحويل الاستجابة ذات الأنواع إلى شكلنا الخاص
return response.points.map((point) => ({
id: point.id,
score: point.score,
title: point.payload?.title as string,
body: point.payload?.body as string,
category: point.payload?.category as string,
}));
}ثلاثة تفاصيل تجعل هذا متينًا:
- يُطبّق الفلتر قبل التسجيل، فالشروط المنظمة لا تشوّه الترتيب الدلالي أبدًا. مصفوفة
mustتعني أن كل شرط يجب أن يتطابق (وهو AND منطقي). يدعم Qdrant أيضًاshould(OR) وmust_not(NOT). with_payload: trueيُرجع البيانات الوصفية المخزّنة مع كل تطابق، فلا تحتاج إلى رحلة ثانية إلى قاعدة بياناتك الأساسية لعرض النتائج.- يحمل كل نتيجة درجة
scoreبين 0 و1 لتشابه جيب التمام. يمكنك ضبط عتبة (مثلًا، استبعاد أي شيء تحت 0.3) لتجنب إظهار التطابقات الضعيفة.
الخطوة 7: كشف معالج مسار في Next.js
اربط دالة البحث بنقطة نهاية App Router. أنشئ app/api/search/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { semanticSearch } from "@/lib/search";
// التضمينات + Qdrant تحتاج بيئة Node.js وليس Edge
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
const category = searchParams.get("category") ?? undefined;
if (!query || query.trim().length === 0) {
return NextResponse.json(
{ error: "معامل الاستعلام مفقود: q" },
{ status: 400 }
);
}
try {
const results = await semanticSearch(query, { category, limit: 5 });
return NextResponse.json({ query, count: results.length, results });
} catch (error) {
// لا تبتلع الأخطاء بصمت أبدًا — سجّلها وأرجع 500 نظيفة
console.error("فشل البحث:", error);
return NextResponse.json(
{ error: "فشل البحث. يرجى المحاولة مرة أخرى." },
{ status: 500 }
);
}
}شغّل خادم التطوير وجرّبه:
npm run devcurl "http://localhost:3000/api/search?q=how%20do%20I%20make%20my%20database%20faster"ينبغي أن تستعيد مقالي فهرسة Postgres والتخزين المؤقت بـ Redis في أعلى الترتيب — رغم أن الاستعلام لا يحتوي أيًّا من تلك الكلمات. ومسودة التقسيم الأفقي (published: false) مستبعدة بشكل صحيح بواسطة الفلتر. هذا هو البحث الدلالي يعمل من البداية إلى النهاية.
جرّب استعلامًا مُصفّى أيضًا:
curl "http://localhost:3000/api/search?q=color%20contrast&category=design"الخطوة 8: إضافة عتبة درجة وترقيم صفحات
في التطبيقات الحقيقية نادرًا ما تريد أقرب الجيران الخام. تحسينان صغيران يُحدثان فرقًا كبيرًا. تدعم واجهة Query API في Qdrant score_threshold وoffset مباشرة:
const response = await qdrant.query(COLLECTION, {
query: queryVector,
limit,
offset: 0, // تخطّي N نتيجة لترقيم الصفحات
score_threshold: 0.3, // استبعاد التطابقات الضعيفة تمامًا
filter,
with_payload: true,
});يضمن score_threshold أنه عندما لا يكون للاستعلام إجابة جيدة، تُرجع قائمة فارغة بدلًا من ضوضاء غير ذات صلة — وهو بالضبط ما تريده لصندوق بحث أو مُسترجِع RAG يغذّي نموذجًا لغويًا كبيرًا.
اختبار التنفيذ
تحقق من كل طبقة على حدة:
- Qdrant سليم — زر
http://localhost:6333/dashboardوأكّد أن مجموعةarticlesتُظهر 4 نقاط. - التضمينات تعمل — أضف
console.log(queryVector.length)مؤقتًا وأكّد أنه يطبع1536. - التصفية تعمل — ابحث بـ
category=databaseوأكّد عدم ظهور أي مقالات تصميم. - الاستبعاد يعمل — أكّد أن مسودة التقسيم الأفقي غير المنشورة لا تظهر أبدًا في النتائج.
- الصلة منطقية — يجب أن تُرجع الاستعلامات المرتبطة دلاليًا المستندات الصحيحة بدرجات أعلى من عتبتك.
استكشاف الأخطاء وإصلاحها
Connection refused على المنفذ 6333 — Qdrant غير قيد التشغيل. تحقق من docker compose ps وdocker compose logs qdrant.
Wrong input: Vector dimension error — حجم تضمينك لا يطابق المجموعة. أنشأت المجموعة بنموذج واستعلمت بآخر، أو غيّرت النماذج في المنتصف. أعد إنشاء المجموعة بقيمة VECTOR_SIZE الصحيحة.
نتائج فارغة لاستعلامات بديهية — أكّد أن الإدخال جرى فعلًا (wait: true يساعد) وأن فلترك ليس صارمًا جدًا. أزل شرط published مؤقتًا لعزل المشكلة.
أخطاء Unauthorized — قيمة QDRANT_API_KEY في .env.local لا تطابق QDRANT__SERVICE__API_KEY في docker-compose.yml.
استعلامات مُصفّاة بطيئة على نطاق واسع — نسيت إنشاء فهرس حمولة على الحقل المُصفّى. راجع الخطوة 4.
الخطوات التالية
أنت الآن تمتلك حزمة بحث دلالي كاملة ذاتية الاستضافة. من هنا يمكنك توسيعها في عدة اتجاهات:
- البحث الهجين — ادمج المتجهات الكثيفة مع المتجهات المتفرقة (الكلمات المفتاحية) باستخدام المتجهات المسمّاة والدمج في Qdrant ضمن نفس استدعاء Query API، لتحصل على أفضل ما في المطابقة الدلالية والمعجمية.
- مسار RAG — مرّر المستندات المسترجعة كسياق إلى نموذج لغوي كبير لبناء نظام إجابة عن الأسئلة. اقرن هذا بدليلنا حول بناء تطبيق RAG باستخدام Next.js وAI SDK.
- التوصيات — استخدم وضع
recommendفي Query API للعثور على نقاط مشابهة لتلك التي أعجبت المستخدم بالفعل. - تضمينات محلية — استبدل OpenAI بنموذج تضمين ذاتي الاستضافة لإبقاء المسار بأكمله داخل بنيتك التحتية الخاصة، مع إزالة استدعاءات الواجهات الخارجية تمامًا.
الخلاصة
يمنحك Qdrant قوة البحث الدلالي بمستوى الإنتاج دون التخلي عن التحكم في بياناتك أو فاتورتك. في هذا الدرس شغّلت Qdrant عبر Docker، وأنشأت مجموعة بمقياس المسافة الصحيح وفهارس الحمولة، وأدخلت مستندات بتضمينات OpenAI، وبنيت نقطة نهاية بحث ذات أنواع محددة في Next.js باستخدام واجهة Query API الموحدة الحديثة — مكتملةً بتصفية البيانات الوصفية وعتبات الدرجات وترقيم الصفحات.
النموذج الذهني الأهم الذي تحمله معك: نموذج التضمين يحوّل المعنى إلى هندسة، وقاعدة البيانات المتجهية تجد ما هو قريب. كل شيء آخر — RAG والتوصيات وإزالة التكرار والتجميع — هو تنويعة على هذه الفكرة الواحدة. ولأن Qdrant مفتوح المصدر وقابل للاستضافة الذاتية، يمكنك بناء كل ذلك على بنية تحتية تملكها بالكامل، وهو أمر أهم من أي وقت مضى للفرق التي تعمل في ظل متطلبات إقامة البيانات الإقليمية.