كل درس عن قواعد البيانات المتجهية يبدأ بالطريقة نفسها: شغّل حاوية Docker، وانتظر فحص السلامة، واضبط سلسلة الاتصال، ثم تمنَّ أن تكون الحاوية ما زالت حيّة غدًا. هذه الطقوس منطقية على نطاق واسع، لكنها عبء سخيف بالنسبة إلى 90% من التطبيقات التي لا يتجاوز مجموع محتواها بضع مئات الآلاف من المقاطع النصية من التوثيق أو سجلات المنتجات أو تذاكر الدعم.
LanceDB تُلغي الخادم تمامًا. إنها محرك استرجاع مُضمَّن — تخيّل SQLite، لكن للمتجهات — يعمل داخل عملية Node.js لديك، ويكتب إلى مجلد على القرص (أو مباشرةً إلى S3)، ويمنحك بحثًا متجهيًا وبحثًا نصيًا كاملًا وتصفية SQL وإصدارات، دون أي خدمة خلفية واحدة. وهي مبنية على Lance، وهو تنسيق عمودي مصمَّم للوصول العشوائي إلى البيانات متعددة الوسائط، ولهذا يمكنه المسح والتصفية أسرع بكثير من وضع التضمينات في Parquet أو في عمود ثنائي داخل قاعدة بيانات علائقية.
في هذا الدرس ستبني خدمة بحث هجين كاملة بمواصفات إنتاجية بلغة TypeScript. ستُعرّف مخططًا يولّد التضمينات تلقائيًا، وتفهرس مجموعة بيانات حقيقية، وتبني فهارس متجهية ونصية معًا، وتدمجها بدمج الرتب التبادلي، وتتعامل مع إعادة الفهرسة التدريجية دون تكرارات، ثم تعرض ذلك كله عبر نقطة نهاية في Next.js App Router.
المتطلبات المسبقة
قبل البدء، تأكّد من توفّر:
- Node.js 20 أو أحدث (تشحن LanceDB ثنائيات أصلية مُجهّزة لأنظمة macOS وLinux وWindows)
- أساسيات TypeScript — الواجهات، وasync/await، والأنواع العامة
- مفتاح OpenAI API للتضمينات (سنغطّي أيضًا بديلًا محليًا بالكامل)
- إلمام بـ Next.js App Router للقسم الأخير
- محرّر أكواد؛ ونوصي بـ VS Code
لا Docker. لا Postgres. ولا حاجة إلى حساب سحابي لمتابعة الدرس.
ما الذي ستبنيه
قاعدة معرفة قابلة للبحث فوق مجموعة من المقالات التقنية تدعم:
- البحث الدلالي — عبارة «كيف أمنع الضغط الزائد على واجهتي البرمجية» تجد مقالًا بعنوان استراتيجيات تحديد المعدّل
- البحث بالكلمات المفتاحية — بحث دقيق عن
IVF_PQيجد المستند الوحيد الذي يذكره، حتى لو لم يكن لدى نموذج التضمين أي فكرة عن معناه - البحث الهجين — كلاهما معًا، مدموجين في قائمة مرتّبة واحدة
- تصفية البيانات الوصفية — تقييد النتائج حسب اللغة أو التصنيف أو تاريخ النشر، مدفوعةً إلى داخل عملية المسح
- التحديثات التدريجية — إعادة فهرسة مستند تغيّر دون إنشاء صف مكرر
ستكون قاعدة البيانات بأكملها مجلدًا على القرص يمكنك حفظه في المستودع، أو نسخه احتياطيًا بـ rsync، أو شحنه داخل صورة Docker.
الخطوة 1: إعداد المشروع
أنشئ المشروع وثبّت العميل الحديث. الحزمة التي تريدها هي @lancedb/lancedb؛ أما الحزمة الأقدم vectordb فهي متوقفة ولا ينبغي استخدامها في أي عمل جديد.
mkdir lancedb-search && cd lancedb-search
npm init -y
npm install @lancedb/lancedb apache-arrow openai
npm install -D typescript tsx @types/node
npx tsc --initapache-arrow تبعية نظيرة — إذ تتحدث LanceDB لغة Arrow أصلًا، وهي الطريقة التي تنقل بها الدفعات العمودية بين Rust وJavaScript بتكلفة تسلسل شبه معدومة.
حدّث tsconfig.json لاعتماد حلّ الوحدات الحديث:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}أضف "type": "module" إلى package.json، ثم أنشئ هيكل المجلدات:
mkdir -p src data
echo "data/" >> .gitignore
echo ".env" >> .gitignoreضع مفتاحك في .env:
OPENAI_API_KEY=sk-your-key-hereالخطوة 2: الاتصال وفهم نموذج التخزين
الاتصال بـ LanceDB هو استدعاء واحد يأخذ مسارًا. وإن لم يكن المجلد موجودًا، يُنشأ تلقائيًا.
// src/db.ts
import * as lancedb from "@lancedb/lancedb";
export async function getDb() {
// مجلد محلي — هذا هو قاعدة بياناتك
return lancedb.connect("./data/knowledge");
}هذه هي قصة الاتصال بكاملها. لا منفذ، ولا مصادقة، ولا تجمّع اتصالات.
والاستدعاء نفسه يتوسّع دون أي تغيير في الشيفرة. وجّهه إلى تخزين الكائنات فتقرأ LanceDB وتكتب ملفات Lance مباشرةً عبر الشبكة:
// Amazon S3
const db = await lancedb.connect("s3://my-bucket/knowledge");
// Cloudflare R2 أو أي نقطة نهاية متوافقة مع S3
const db = await lancedb.connect("s3://my-bucket/knowledge", {
storageOptions: {
endpoint: "https://ACCOUNT_ID.r2.cloudflarestorage.com",
region: "auto",
},
});هذه الخاصية هي ما يجعل LanceDB مختلفة عن مكتبة تعمل في الذاكرة. يمكن لدالتك عديمة الخادم أن تفتح جدولًا على S3، وتنفّذ استعلامًا، ثم تنتهي — دون عملية طويلة الأمد تحتفظ بفهرس في الذاكرة. القراءات كسولة وقائمة على النطاقات، لذا فإن فتح جدول بحجم 40 غيغابايت لا يكلّف شيئًا تقريبًا حتى تلمس الصفوف فعليًا.
ما الذي يوجد داخل ذلك المجلد
داخل ./data/knowledge ستجد مجلدًا فرعيًا لكل جدول، وداخل كل واحد منها: شظايا بيانات، وبيان مُصدَّر، وملفات فهرسة. ولأن كل عملية كتابة تنتج إصدار بيان جديدًا، يمتلك الجدول سجلًا تاريخيًا كاملًا. ويمكنك فحصه والتنقّل عبره:
const table = await db.openTable("articles");
console.log(await table.version()); // مثلًا 7
const history = await table.listVersions(); // كل عملية حفظ مع طوابعها الزمنية
await table.checkout(3); // اقرأ الجدول كما كان في الإصدار 3
await table.checkoutLatest(); // عُد إلى الحاضرالسفر عبر الزمن مجاني هنا لأن Lance لا تعدّل الشظايا في مكانها أبدًا. وهذا يجعل من العملي فعلًا تشخيص سؤال «لماذا تغيّرت نتيجة البحث هذه الثلاثاء الماضي» — وهو سؤال يستحيل الإجابة عنه عادةً مع مخزن متجهات تقليدي.
الخطوة 3: تعريف مخطط بتضمينات تلقائية
معظم دروس قواعد البيانات المتجهية تجعلك تحسب التضمينات يدويًا عند كل إدراج وكل استعلام، ثم تتذكّر أي نموذج أنتج أي عمود. سجلّ التضمينات في LanceDB ينقل ذلك إلى المخطط نفسه، فيصبح الجدول عارفًا كيف يحوّل بياناته إلى متجهات.
// src/schema.ts
import * as lancedb from "@lancedb/lancedb";
import { LanceSchema, getRegistry } from "@lancedb/lancedb/embedding";
import { Utf8, Int32 } from "apache-arrow";
import "@lancedb/lancedb/embedding/openai";
export const embedFunc = getRegistry()
.get("openai")!
.create({ model: "text-embedding-3-small" }) as lancedb.embedding.EmbeddingFunction;
export const articleSchema = LanceSchema({
// sourceField: النص الخام الذي سيُضمَّن
content: embedFunc.sourceField(new Utf8()),
// vectorField: عمود التضمين، وتُستنتج أبعاده من النموذج
vector: embedFunc.vectorField(),
// أعمدة بيانات وصفية عادية
id: new Utf8(),
title: new Utf8(),
category: new Utf8(),
lang: new Utf8(),
publishedYear: new Int32(),
});استيراد @lancedb/lancedb/embedding/openai هو استيراد بأثر جانبي يسجّل مزوّد OpenAI في السجل العام. ونسيانه هو الخطأ الأكثر شيوعًا في الإعداد — إذ يُرجع البحث في السجل undefined فتحصل على انهيار مربك بسبب مرجع فارغ.
وهناك أمران أصبحا صحيحين الآن ولهما أهمية عملية كبيرة:
- عند إدراج صف، تقدّم
contentوتملأ LanceDBvectorنيابةً عنك. - عند البحث بسلسلة نصية، تُضمّن LanceDB الاستعلام بـنفس النموذج تلقائيًا. فلا سبيل إلى أن تستعلم بالخطأ عن جدول
text-embedding-3-smallبمتجهاتada-002.
بديل محلي بالكامل
إن كنت تفضّل عدم إرسال النص إلى واجهة برمجية، استبدل المزوّد بآخر محلي. ثبّت @xenova/transformers واستخدم مزوّد transformers المدمج:
npm install @xenova/transformersimport "@lancedb/lancedb/embedding/transformers";
export const embedFunc = getRegistry()
.get("huggingface")!
.create({ model: "Xenova/all-MiniLM-L6-v2" }) as lancedb.embedding.EmbeddingFunction;كل ما يأتي لاحقًا في هذا الدرس — الفهارس، والبحث الهجين، وإعادة الترتيب — يعمل بالطريقة نفسها تمامًا. يعمل النموذج داخل العملية عبر ONNX، وينتج متجهات بـ 384 بُعدًا بدلًا من 1536، ولا يلمس الشبكة إطلاقًا. وبالنسبة إلى مجموعة من عشرات الآلاف من المقاطع، تكون الجودة أكثر من كافية وتبقى بياناتك على جهازك.
الخطوة 4: إنشاء الجدول وتحميل البيانات
بعد أن أصبح المخطط جاهزًا، أنشئ جدولًا فارغًا وأضف الصفوف. استخدم createEmptyTable حين يقود المخطط البنية، وmode: "overwrite" ليكون السكربت قابلًا لإعادة التشغيل أثناء التطوير.
// src/seed.ts
import "dotenv/config";
import { getDb } from "./db.js";
import { articleSchema } from "./schema.js";
type Article = {
id: string;
title: string;
content: string;
category: string;
lang: string;
publishedYear: number;
};
const articles: Article[] = [
{
id: "rate-limiting",
title: "Rate Limiting Strategies for Public APIs",
content:
"Token bucket and sliding window are the two dominant algorithms for protecting an API from abuse. A token bucket refills at a fixed rate and allows short bursts, while a sliding window counts requests over a rolling interval and is stricter about spikes.",
category: "backend",
lang: "en",
publishedYear: 2026,
},
{
id: "ivf-pq",
title: "Approximate Nearest Neighbour Indexes Explained",
content:
"IVF_PQ partitions the vector space into Voronoi cells and compresses residuals with product quantization. It trades a small amount of recall for a very large reduction in memory footprint and query latency.",
category: "ai",
lang: "en",
publishedYear: 2026,
},
{
id: "edge-caching",
title: "Caching at the Edge with Stale While Revalidate",
content:
"Serving a slightly stale response instantly and refreshing it in the background gives users near-zero latency while keeping content reasonably fresh. The pattern pairs well with content delivery networks.",
category: "frontend",
lang: "en",
publishedYear: 2025,
},
// ...في مشروع حقيقي، حمّل المئات أو الآلاف من نظام إدارة المحتوى لديك
];
async function seed() {
const db = await getDb();
const table = await db.createEmptyTable("articles", articleSchema, {
mode: "overwrite",
});
// لا استدعاءات تضمين يدوية — المخطط يتكفّل بذلك
await table.add(articles);
console.log(`Indexed ${await table.countRows()} articles`);
}
seed();شغّله:
npx tsx src/seed.tsتُجمّع LanceDB الصفوف في دفعات، وتستدعي نموذج التضمين مرة واحدة لكل دفعة بدلًا من مرة لكل صف، وتكتب شظية جديدة واحدة. وللتحميلات الكبيرة، أعطها مُكرِّرًا غير متزامن بدلًا من مصفوفة كي لا تحتفظ بالمجموعة كاملةً في الذاكرة:
async function* chunks(): AsyncGenerator<Article[]> {
for (let page = 0; ; page++) {
const batch = await fetchArticlesFromCms({ page, size: 500 });
if (batch.length === 0) return;
yield batch;
}
}
for await (const batch of chunks()) {
await table.add(batch);
}الخطوة 5: بناء الفهارس
جدول LanceDB غير المفهرس ما زال يجيب عن الاستعلامات — إنه فقط يمسح كل متجه بالقوة الغاشمة. وهذا سريع فعلًا للجداول الصغيرة (فالمسح المسطّح على 50,000 متجه غالبًا ما يستغرق أقل من بضعة أجزاء من الألف من الثانية)، ولهذا لا تُجبرك LanceDB على الفهرسة منذ البداية. وحين تتجاوز نحو مئة ألف صف، ابنِ فهرسًا حقيقيًا.
// src/index-build.ts
import * as lancedb from "@lancedb/lancedb";
import { getDb } from "./db.js";
async function buildIndexes() {
const db = await getDb();
const table = await db.openTable("articles");
// 1. فهرس متجهي (IVF-PQ) للبحث التقريبي عن أقرب الجيران
await table.createIndex("vector", {
config: lancedb.Index.ivfPq({
distanceType: "cosine",
numPartitions: 256,
numSubVectors: 16,
}),
});
// 2. فهرس نصي كامل للبحث بالكلمات المفتاحية / BM25
await table.createIndex("content", {
config: lancedb.Index.fts(),
});
// 3. فهارس عددية كي تُدفع مرشّحات البيانات الوصفية بدل مسحها
await table.createIndex("category", { config: lancedb.Index.bitmap() });
await table.createIndex("publishedYear", { config: lancedb.Index.btree() });
console.log(await table.listIndices());
}
buildIndexes();اختيار معاملات الفهرس
مقبضا IVF-PQ اللذان لهما تأثير فعلي هما:
| المعامل | ما الذي يتحكم به | إرشاد عملي |
|---|---|---|
numPartitions | عدد خلايا فورونوي التي يُقسَّم إليها فضاء المتجهات | ابدأ قرب الجذر التربيعي لعدد صفوفك. 100 ألف صف تعني نحو 316؛ قرّبها إلى 256 أو 512. |
numSubVectors | مدى شدّة ضغط كل متجه | يجب أن يقسم البُعد بالتساوي. لـ 1536 بُعدًا، القيمتان 16 أو 96 آمنتان. الأعلى يعني أصغر وأسرع مع استرجاع أقل. |
distanceType | مقياس التشابه | استخدم cosine لتضمينات النصوص. ولا تستخدم l2 إلا إذا كانت متجهاتك غير مُطبَّعة وكان المقدار يحمل معنى. |
اختيار قيمة خاطئة لـ numSubVectors يجعل إنشاء الفهرس يفشل بوضوح مع خطأ قابلية القسمة، وهذا نمط فشل جيد. أما اختيار numPartitions مرتفعة جدًا على جدول صغير فسيمنحك استرجاعًا ضعيفًا، لأن كل خلية ستحتوي على عدد قليل جدًا من المتجهات بحيث لا يمكن البحث فيها بشكل مُجدٍ.
أما اختيار الفهرس العددي فأبسط: bitmap للأعمدة منخفضة التعدّد (التصنيف، اللغة، الحالة — أي شيء بأقل من بضع مئات من القيم المميزة) وbtree للأعمدة عالية التعدّد أو التي يُستعلم عنها بنطاقات (التواريخ، السنوات، المعرّفات الرقمية).
الفهارس غير متزامنة
يعود createIndex قبل أن يصبح الفهرس قابلًا للاستعلام بالكامل على الجداول الكبيرة. انتظره صراحةً في السكربتات:
await table.waitForIndex(["vector_idx"], 120); // الاسم، والمهلة بالثوانيتتبع أسماء الفهارس النمط columnName_idx. ويمكنك تأكيدها عبر table.listIndices().
الخطوة 6: البحث المتجهي مع المرشّحات
والآن تأتي المكافأة. لأن المخطط يحمل دالة التضمين، يكفي البحث بسلسلة نصية عادية.
// src/search.ts
import { getDb } from "./db.js";
export async function semanticSearch(query: string, limit = 5) {
const db = await getDb();
const table = await db.openTable("articles");
return table
.search(query) // تُضمَّن السلسلة تلقائيًا
.limit(limit)
.select(["id", "title", "category"])
.toArray();
}تتضمن كل نتيجة حقل _distance. ومع cosine، الأقل يعني الأقرب؛ وقاعدة تقريبية لتضمينات النصوص هي أن أي قيمة تتجاوز نحو 0.5 تكون ضعيفة الصلة وتستحق الاستبعاد.
التصفية
تستخدم مرشّحات LanceDB صيغة SQL، وموضع تنفيذ المرشّح قرار تتحكم به أنت:
const results = await table
.search("how do I protect my API from abuse")
.where("category = 'backend' AND publishedYear >= 2026")
.limit(5)
.toArray();افتراضيًا هذه تصفية لاحقة: يسترجع المحرك أقرب الجيران، ثم يتخلص من التي لا تحقق الشرط. وهي سريعة، لكن إن كان مرشّحك انتقائيًا جدًا فقد ينتهي بك الأمر بنتائج أقل مما طلبت — مشكلة «طلبتُ عشرة فحصلتُ على اثنتين» الكلاسيكية.
افرض تصفية مسبقة للبحث داخل المجموعة الفرعية المطابقة فقط:
const results = await table
.search("how do I protect my API from abuse")
.where("category = 'backend'")
.prefilter(true)
.limit(5)
.toArray();تضمن التصفية المسبقة حصولك على limit نتيجة كلما توفّر هذا العدد من الصفوف المطابقة، مقابل مسح أكثر كلفة. وهذا بالضبط الغرض من الفهارس العددية في الخطوة 5: فمع فهرس bitmap على category، تُحلّ التصفية المسبقة مقابل الفهرس بدلًا من قراءة كل صف.
قاعدة عملية: إذا كان مرشّحك يبقي أكثر من نحو 20% من الجدول، استخدم التصفية اللاحقة. وتحت ذلك، استخدم التصفية المسبقة مع فهرس عددي.
ضبط الاسترجاع وقت الاستعلام
معاملان إضافيان يوازنان بين زمن الاستجابة والدقة دون إعادة بناء أي شيء:
const results = await table
.search(query)
.nprobes(40) // عدد الأقسام التي سيُبحث فيها؛ الافتراضي 20
.refineFactor(10) // أعد ترتيب أعلى (limit * 10) باستخدام المتجهات الكاملة
.limit(5)
.toArray();يوسّع nprobes البحث ليشمل خلايا فورونوي أكثر. أما refineFactor فيجلب مرشحين إضافيين باستخدام رموز PQ المضغوطة، ثم يعيد تقييمها مقابل المتجهات غير المضغوطة — وهو ما يستعيد معظم الاسترجاع الذي كلّفك إياه التكميم، مقابل زيادة معتدلة في زمن الاستجابة. ورفع refineFactor هو عادةً المكسب الأرخص من بين الاثنين.
الخطوة 7: البحث النصي الكامل
يفشل البحث المتجهي بطريقة محددة جدًا ويمكن التنبؤ بها: المعرّفات الدقيقة. فليس لدى نموذج التضمين تمثيل ذو معنى لـ IVF_PQ، أو رمز منتج مثل TN-4471-B، أو رمز خطأ. هذه الاستعلامات تحتاج إلى مطابقة معجمية.
export async function keywordSearch(query: string, limit = 5) {
const db = await getDb();
const table = await db.openTable("articles");
return table
.query()
.fullTextSearch(query)
.limit(limit)
.toArray();
}لاحظ table.query() بدلًا من table.search(). فـ query() تبدأ بانيًا بلا مرحلة متجهية، وهو ما تريده للاسترجاع بالكلمات المفتاحية الخالص. وتحمل النتائج حقل _score (صلة BM25) حيث الأعلى أفضل — وهو الاتجاه المعاكس لـ _distance. والخلط بين الاثنين يقلب ترتيبك بصمت، لذا كن متعمّدًا في التعامل معهما.
ويمكنك البحث عبر عدة أعمدة نصية دفعة واحدة عبر فهرسة كل منها وتمرير قائمة الأعمدة:
await table
.query()
.fullTextSearch("rate limiting", { columns: ["title", "content"] })
.limit(10)
.toArray();الخطوة 8: البحث الهجين مع دمج الرتب التبادلي
لا يكفي أي من نمطَي الاسترجاع بمفرده. فالبحث الدلالي يفهم القصد لكنه يفوّت الرموز الدقيقة؛ والبحث بالكلمات المفتاحية يصيب الرموز الدقيقة لكن ليس لديه أي مفهوم للمعنى. البحث الهجين يشغّل الاثنين ويدمج النتائج.
طريقة الدمج التي تستخدمها LanceDB افتراضيًا هي دمج الرتب التبادلي (RRF)، الذي يمنح كل مستند درجة تساوي مجموع 1 / (k + rank) عبر قوائم النتائج التي يظهر فيها. وميزته الكبرى أنه ينظر إلى الرتب فقط، لا إلى الدرجات الخام — فلا يهم أن مسافة الجيب التمام ودرجة BM25 تعيشان على مقياسين مختلفين تمامًا.
// src/hybrid.ts
import * as lancedb from "@lancedb/lancedb";
import { getDb } from "./db.js";
import { embedFunc } from "./schema.js";
export async function hybridSearch(query: string, limit = 5) {
const db = await getDb();
const table = await db.openTable("articles");
const reranker = await lancedb.rerankers.RRFReranker.create();
// ضمّن الاستعلام مرة واحدة وأعد استخدامه للساق المتجهية
const [queryVector] = await embedFunc.computeQueryEmbeddings(query);
return table
.query()
.fullTextSearch(query) // الساق المعجمية
.nearestTo(queryVector) // الساق الدلالية
.rerank(reranker) // ادمج القائمتين المرتّبتين
.select(["id", "title", "category"])
.limit(limit)
.toArray();
}هذه السلسلة هي مسار البحث الهجين بأكمله. فإضافة كل من fullTextSearch وnearestTo إلى باني الاستعلام نفسه تُخبر LanceDB بتشغيل عمليتَي استرجاع وتسليم القائمتين إلى مُعيد الترتيب.
جرّبه على البيانات الأولية لترى لماذا يهم:
- الاستعلام
"IVF_PQ"— تُرجع الساق المتجهية ضجيجًا، وتصيب الساق المعجمية المستند الدقيق، فيرفعه RRF إلى الصدارة. - الاستعلام
"how do I keep my API from being hammered"— لا تجد الساق المعجمية شيئًا (لا رموز مشتركة)، وتجد الساق المتجهية مقال تحديد المعدّل، فيرفعه RRF إلى الصدارة. - الاستعلام
"rate limiting algorithms"— تتفق الساقان، فيُعزَّز المستند الذي يحتل مرتبة جيدة في كليهما فوق المستندات التي تحتل مرتبة جيدة في واحدة فقط.
هذه الحالة الأخيرة هي القيمة الحقيقية للدمج: فالاتفاق بين إشارتين مستقلتين دليل قوي، وRRF يكافئه تلقائيًا.
ترجيح الساقين
إذا كانت مجموعتك تميل إلى جهة — كثيفة المصطلحات، أو كثيفة النثر — فرجّح الدمج:
const reranker = await lancedb.rerankers.RRFReranker.create({
K: 60, // ثابت التنعيم في RRF؛ و60 هو الافتراضي المعياري
returnScore: "all",
});خفض K يزيد حدّة تأثير النتائج المتصدّرة؛ ورفعه يُسطّح المنحنى ويسمح للنتائج الأعمق بالمساهمة. وللحصول على خيار أقوى لكن أبطأ، استبدله بمُعيد ترتيب من نوع cross-encoder يقرأ فعليًا كل زوج استعلام-مستند:
const reranker = await lancedb.rerankers.CohereReranker.create({
model: "rerank-v3.5",
});عادةً ما تضيف نماذج cross-encoder دقة ملموسة في أعلى القائمة، مقابل جولة شبكية إضافية. والشكل الإنتاجي الشائع هو استخدام RRF للنزول من آلاف المرشحين إلى 50، ثم تشغيل cross-encoder على تلك الخمسين.
الخطوة 9: التحديثات التدريجية دون تكرارات
إعادة الفهرسة الساذجة — احذف كل شيء ثم أعد الإضافة — مُهدِرة وتترك نقطة نهاية البحث لديك لبرهة وهي لا تُرجع شيئًا. استخدم mergeInsert لعملية إدراج-أو-تحديث صحيحة مرتكزة على عمود ثابت.
// src/upsert.ts
import { getDb } from "./db.js";
export async function upsertArticles(articles: Article[]) {
const db = await getDb();
const table = await db.openTable("articles");
await table
.mergeInsert("id")
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute(articles);
}تُستبدل الصفوف التي يوجد id الخاص بها مسبقًا، وتُدرَج الصفوف الجديدة، ويبقى كل ما عداها دون مساس. وتُعاد حوسبة تضمينات الصفوف المتغيّرة تلقائيًا لأن المخطط ما زال يتولّى تلك المهمة.
ولإزالة الصفوف التي اختفت من المصدر أيضًا، أضف بند الحذف:
await table
.mergeInsert("id")
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.whenNotMatchedBySourceDelete()
.execute(fullCorpus);أما عمليات الحذف والتحديث الموجّهة فتستخدم شروط SQL:
await table.delete("publishedYear < 2024");
await table.update({ where: "category = 'backend'" }, { category: "engineering" });الضغط ليس اختياريًا
كل عملية كتابة تُنشئ شظية جديدة، وكل عملية حذف تكتب علامة شاهدة بدلًا من إعادة كتابة البيانات. وبعد بضعة آلاف من التحديثات التدريجية سيكون لديك آلاف الشظايا الصغيرة، وسيرتفع زمن استجابة الاستعلام بشكل ملحوظ. شغّل الصيانة وفق جدول:
// src/maintenance.ts
import { getDb } from "./db.js";
export async function compact() {
const db = await getDb();
const table = await db.openTable("articles");
// ادمج الشظايا الصغيرة، وجسّد عمليات الحذف، وحدّث الفهارس
await table.optimize();
// احذف الإصدارات القديمة لاستعادة مساحة القرص (يُبقي 7 أيام من السجل)
await table.optimize({ cleanupOlderThan: new Date(Date.now() - 7 * 864e5) });
console.log(await table.stats());
}كما تُحدّث optimize() الفهرس المتجهي وفهرس FTS تدريجيًا ليغطّيا الصفوف المضافة حديثًا. وتخطّيها يعني أن الصفوف الجديدة ما زالت تُوجَد — إذ تمسحها LanceDB بالقوة الغاشمة في الذيل غير المفهرس — لكن ذلك الذيل يزداد بطئًا كلما نما. ومهمة cron ليلية هي الوتيرة الصحيحة لمعظم أحمال العمل؛ شغّلها أكثر إذا كنت تستوعب البيانات باستمرار.
الخطوة 10: الربط مع Next.js
القطعة الأخيرة هي عرض هذا عبر مسار App Router. والتفصيل المهم هو إعادة استخدام الاتصال: ففتح جدول رخيص لكنه ليس مجانيًا، والدالة عديمة الخادم التي تعيد فتحه مع كل طلب تهدر ذاكرة التخزين المؤقت لمقابض الملفات.
// lib/lancedb.ts
import * as lancedb from "@lancedb/lancedb";
let tablePromise: Promise<lancedb.Table> | null = null;
export function getArticlesTable() {
if (!tablePromise) {
tablePromise = lancedb
.connect(process.env.LANCEDB_URI ?? "./data/knowledge")
.then((db) => db.openTable("articles"));
}
return tablePromise;
}تخزين الوعد بدلًا من الجدول المحلول يعني أن الطلبات المتزامنة أثناء الإقلاع البارد تتشارك محاولة اتصال واحدة بدلًا من التسابق لإنشاء عدة اتصالات.
والآن المسار:
// app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";
import * as lancedb from "@lancedb/lancedb";
import { getArticlesTable } from "@/lib/lancedb";
import { embedFunc } from "@/lib/schema";
// تستخدم LanceDB ارتباطات Node الأصلية — ولا يستطيع بيئة تشغيل الحافة تحميلها
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get("q")?.trim();
const category = req.nextUrl.searchParams.get("category");
const limit = Number(req.nextUrl.searchParams.get("limit") ?? 10);
if (!q) {
return NextResponse.json({ error: "Missing query parameter q" }, { status: 400 });
}
try {
const table = await getArticlesTable();
const reranker = await lancedb.rerankers.RRFReranker.create();
const [queryVector] = await embedFunc.computeQueryEmbeddings(q);
let builder = table
.query()
.fullTextSearch(q)
.nearestTo(queryVector)
.rerank(reranker)
.select(["id", "title", "category", "publishedYear"])
.limit(Math.min(limit, 50));
if (category) {
builder = builder.where(`category = '${category.replace(/'/g, "''")}'`);
}
const rows = await builder.toArray();
return NextResponse.json({
query: q,
count: rows.length,
results: rows,
});
} catch (error) {
console.error("[search] query failed", error);
return NextResponse.json({ error: "Search failed" }, { status: 500 });
}
}ثلاثة تفاصيل تستحق التنويه:
runtime = "nodejs"إلزامي. إذ تشحن LanceDB ارتباطًا أصليًا بلغة Rust. والنشر إلى بيئة تشغيل الحافة يُنتج خطأ في حلّ الوحدات وقت البناء، وليس واضحًا من الرسالة أن بيئة التشغيل هي السبب.- هرّب قيم المرشّحات. فبند
whereهو SQL. وإدراج مدخلات المستخدم الخام فيه ثغرة حقن، تمامًا كما هو الحال في Postgres. ومضاعفة علامات الاقتباس المفردة هي الحد الأدنى؛ والتحقق مقابل قائمة سماح بالتصنيفات المعروفة أفضل. - قيّد الحد الأقصى. فبدون
Math.min، يمكن لمستدعٍ أن يطلب 100,000 صف ويفرض تجسيدًا ضخمًا.
وعند النشر، تذكّر أن مجلد قاعدة البيانات المحلي المُرفق يكون للقراءة فقط على معظم المنصات عديمة الخادم. فإما أن توجّه LANCEDB_URI إلى S3/R2 لأي شيء يكتب، أو تقبل بأن الكتابة تحدث فقط في خطوة البناء وأن الجدول المنشور غير قابل للتغيير حتى النشرة التالية. ونمط «غير قابل للتغيير عند النشر» جيد فعلًا للبحث في التوثيق: أعد بناء الفهرس في CI، واشحنه مع التطبيق، واحصل على قراءات بزمن استجابة صفري بلا أي تبعية خارجية إطلاقًا.
اختبار تنفيذك
تحقّق من كل طبقة على حدة بدلًا من الوثوق بالنتيجة النهائية:
// src/verify.ts
import { getDb } from "./db.js";
async function verify() {
const db = await getDb();
const table = await db.openTable("articles");
console.log("Rows:", await table.countRows());
console.log("Indexes:", await table.listIndices());
console.log("Version:", await table.version());
// يجب أن يأتي الاسترجاع بالرمز الدقيق من ساق FTS
const kw = await table.query().fullTextSearch("IVF_PQ").limit(3).toArray();
console.log("Keyword hit:", kw[0]?.title);
// يجب أن يأتي القصد المُعاد صياغته من الساق المتجهية
const sem = await table.search("stop people hammering my endpoint").limit(3).toArray();
console.log("Semantic hit:", sem[0]?.title, sem[0]?._distance);
// افحص الخطة للتأكد من دفع المرشّحات إلى الأسفل
const plan = await table
.search("caching")
.where("category = 'frontend'")
.prefilter(true)
.explainPlan(true);
console.log(plan);
}
verify();explainPlan(true) هي الأداة التي تجيب عن سؤال «هل يستخدم مرشّحي الفهرس فعلًا». ابحث عن عقدة ScalarIndexQuery في المخرجات. أما إن رأيت بدلًا منها FilterExec عاديًا فوق مسح كامل، فإن فهرسك العددي مفقود أو أُنشئ بعد كتابة الصفوف ولم تُحدّثه optimize() بعد.
ثم استدعِ واجهة البرمجة:
curl "http://localhost:3000/api/search?q=rate+limiting&limit=5"
curl "http://localhost:3000/api/search?q=IVF_PQ"
curl "http://localhost:3000/api/search?q=caching&category=frontend"استكشاف الأخطاء وإصلاحها
Cannot read properties of undefined (reading 'create')
لم يُسجَّل مزوّد التضمين إطلاقًا. أضف الاستيراد ذا الأثر الجانبي — import "@lancedb/lancedb/embedding/openai" — قبل استدعاء getRegistry().get(...).
Module not found: Can't resolve '@lancedb/lancedb-darwin-arm64'
لم يُثبَّت الثنائي الاختياري الخاص بالمنصة. احذف node_modules وملف القفل، ثم أعد التثبيت. وإن كنت تنشر من macOS إلى Linux، ثبّت بـ --os=linux --cpu=x64 أو ابنِ داخل حاوية المنصة الهدف كي يُجلب الثنائي الصحيح.
تُرجع عمليات البحث نتائج أقل من الحد الأقصى
تخلّصت تصفية لاحقة انتقائية من معظم المرشحين. أضف .prefilter(true)، وتأكّد من وجود فهرس عددي على العمود المُصفّى.
الاسترجاع ضعيف بعد بناء فهرس IVF-PQ
إما أن numPartitions مرتفعة جدًا مقارنةً بعدد الصفوف، أو أن التكميم شديد أكثر من اللازم. ارفع nprobes وrefineFactor أولًا — فتجربتهما لا تكلّف شيئًا. وإن لم يكفِ ذلك، أعد البناء بأقسام أقل أو متجهات فرعية أقل.
تتباطأ الاستعلامات مع الوقت
تكاثر الشظايا. شغّل table.optimize(). وإن انخفض زمن الاستجابة بحدّة بعدها، فاجعلها مهمة cron مجدولة.
Commit conflict عند الكتابات المتزامنة
كتبت عمليتان إلى إصدار الجدول نفسه في الوقت ذاته. ستعيد المحاولة آلية التزامن المتفائل في LanceDB، لكن تصميم الكاتب الواحد أبسط بكثير: وجّه الكتابات عبر عملية واحدة أو طابور، ودع كل العمليات الأخرى تقرأ فقط.
اسم الفهرس غير موجود في waitForIndex
تُشتق الأسماء بصيغة columnName_idx. استدعِ table.listIndices() واستخدم الاسم الدقيق الذي تُبلغ عنه.
متى تكون LanceDB الخيار الخاطئ
الصراحة بشأن الحدود تُجنّبك ترحيلًا مؤلمًا لاحقًا:
- كُتّاب متزامنون كثر. فآلية التزامن المتفائل في LanceDB مبنية حول كاتب واحد. وإن كانت اثنتا عشرة خدمة تكتب جميعها إلى جدول واحد، فاستخدم قاعدة بيانات قائمة على خادم.
- حداثة بيانات دون الثانية على نطاق واسع. فالصفوف الجديدة قابلة للبحث فورًا، لكنها تعيش في ذيل غير مفهرس حتى تُشغَّل
optimize(). والاستيعاب عالي السرعة مع ميزانية زمن استجابة صارمة تركيبة غير مناسبة. - مليارات المتجهات مع معدّل استعلامات مرتفع. تتوسّع LanceDB إلى جداول ضخمة جدًا على تخزين الكائنات، لكن عنقودًا موزّعًا مخصصًا سيتفوّق عليها في أحمال العمل عالية التزامن المستدامة.
أما فيما عدا ذلك — البحث في التوثيق، وRAG فوق قاعدة معرفة، وذاكرة الوكلاء، والبحث الدلالي في المنتجات، والبحث في الشيفرة، وتطبيقات سطح المكتب المحلية أولًا — فإن غياب الخادم تبسيط معماري حقيقي لا تنازل.
الخطوات التالية
- قسّم المستندات الطويلة إلى مقاطع قبل التضمين؛ فمقطع بين 400 و800 رمز مع تداخل بسيط هو النقطة المثلى المعتادة، وهو يؤثر في جودة الاسترجاع أكثر من أي معامل فهرس يمكنك ضبطه.
- خزّن تضمينات الصور جنبًا إلى جنب مع النص في الجدول نفسه — فـ Lance تنسيق متعدد الوسائط، ويمكن لعمود
Binaryأن يحمل بايتات الصورة بجوار المتجه مباشرةً. - اقرن هذا بدليلنا حول بناء خادم MCP بلغة TypeScript لعرض البحث كأداة يمكن لوكلاء الذكاء الاصطناعي استدعاؤها.
- قارن المفاضلات مع نهج قائم على خادم في درس البحث الدلالي بـ Qdrant.
- أضف التقييم قبل أي ضبط إضافي — راجع Promptfoo لتقييم نماذج اللغة لقياس ما إذا كانت تغييرات مُعيد الترتيب لديك تساعد فعلًا.
الخاتمة
لقد بنيت محرك بحث هجين كاملًا يعمل بالكامل داخل عملية Node.js لديك: مخطط يُضمّن بياناته بنفسه، وفهرسا IVF-PQ وBM25 فوق الجدول نفسه، ودمج الرتب التبادلي الذي يجمع نمطَي الاسترجاع، ودفع المرشّحات عبر الفهارس العددية، وتحديثات تدريجية خالية من التكرار، ونقطة نهاية Next.js تقدّم ذلك كله.
والأمر الجدير بالاستيعاب ليس واجهة البرمجة — بل التحوّل المعماري. فقاعدة البيانات المتجهية لا يجب أن تكون بنية تحتية. وحين تكون قاعدة البيانات مجلدًا يمكنك إصداره ونسخه وشحنه في حاوية أو إيداعه في S3، يتلاشى قدر هائل من التعقيد التشغيلي ببساطة. وبالنسبة للغالبية العظمى من أحمال الاسترجاع، هذا هو الوضع الافتراضي الصحيح، وينبغي أن يكون اللجوء إلى عنقود قرارًا تتخذه بعدما تقيس سببًا يدعوك إليه.