Tous les tutoriels sur les bases de données vectorielles commencent de la même façon : lancez un conteneur Docker, attendez le health check, configurez une chaîne de connexion, puis espérez que le conteneur sera encore vivant demain. Ce cérémonial a du sens à grande échelle, mais il constitue une charge absurde pour les 90 % d'applications dont l'ensemble du corpus tient en quelques centaines de milliers de fragments de documentation, de fiches produits ou de tickets de support.
LanceDB supprime purement et simplement le serveur. C'est un moteur de récupération embarqué — imaginez SQLite, mais pour les vecteurs — qui s'exécute à l'intérieur de votre processus Node.js, écrit dans un répertoire sur disque (ou directement sur S3), et vous offre recherche vectorielle, recherche plein texte, filtrage SQL et versionnement sans le moindre démon. Il repose sur Lance, un format colonnaire conçu pour l'accès aléatoire à des données multimodales, ce qui lui permet de scanner et de filtrer bien plus vite que si vous entassiez vos embeddings dans du Parquet ou dans une colonne binaire relationnelle.
Dans ce tutoriel, vous allez construire un service de recherche hybride complet, de qualité production, en TypeScript. Vous définirez un schéma qui génère automatiquement les embeddings, indexerez un vrai corpus, construirez des index vectoriels et plein texte, les combinerez par fusion de rangs réciproques, gérerez la réindexation incrémentale sans doublons, et exposerez le tout via un point de terminaison Next.js App Router.
Prérequis
Avant de commencer, assurez-vous de disposer de :
- Node.js 20 ou plus récent (LanceDB livre des binaires natifs précompilés pour macOS, Linux et Windows)
- Les bases de TypeScript — interfaces, async/await, génériques
- Une clé API OpenAI pour les embeddings (nous couvrirons aussi une alternative entièrement locale)
- Une familiarité avec Next.js App Router pour la dernière section
- Un éditeur de code ; VS Code est recommandé
Pas de Docker. Pas de Postgres. Aucun compte cloud nécessaire pour suivre.
Ce que vous allez construire
Une base de connaissances interrogeable sur un corpus d'articles techniques, qui prend en charge :
- La recherche sémantique — « comment empêcher que mon API se fasse marteler » trouve un article intitulé Stratégies de limitation de débit
- La recherche par mots-clés — une recherche exacte de
IVF_PQtrouve l'unique document qui le mentionne, même si le modèle d'embedding n'a aucune idée de ce que cela signifie - La recherche hybride — les deux à la fois, fusionnées en une seule liste classée
- Le filtrage par métadonnées — restreindre les résultats par langue, catégorie ou date de publication, poussé au niveau du scan
- Les mises à jour incrémentales — réindexer un document modifié sans créer de ligne en double
L'ensemble de la base de données sera un dossier sur disque que vous pouvez versionner, sauvegarder avec rsync ou embarquer dans une image Docker.
Étape 1 : configuration du projet
Créez le projet et installez le client moderne. Le paquet à utiliser est @lancedb/lancedb ; l'ancien paquet vectordb est déprécié et ne doit pas être utilisé pour de nouveaux développements.
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 est une dépendance pair — LanceDB parle nativement Arrow, ce qui lui permet de déplacer des lots colonnaires entre Rust et JavaScript avec un coût de sérialisation quasi nul.
Mettez à jour tsconfig.json pour une résolution de modules moderne :
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}Ajoutez "type": "module" à package.json, puis créez l'arborescence :
mkdir -p src data
echo "data/" >> .gitignore
echo ".env" >> .gitignorePlacez votre clé dans .env :
OPENAI_API_KEY=sk-your-key-hereÉtape 2 : se connecter et comprendre le modèle de stockage
Se connecter à LanceDB tient en un seul appel qui prend un chemin. Si le répertoire n'existe pas, il est créé.
// src/db.ts
import * as lancedb from "@lancedb/lancedb";
export async function getDb() {
// Un répertoire local — c'EST votre base de données
return lancedb.connect("./data/knowledge");
}Voilà toute l'histoire de la connexion. Pas de port, pas d'authentification, pas de pool.
Le même appel monte en charge sans changer une ligne de code. Pointez-le vers du stockage objet et LanceDB lit et écrit des fichiers Lance directement sur le réseau :
// Amazon S3
const db = await lancedb.connect("s3://my-bucket/knowledge");
// Cloudflare R2 ou tout point de terminaison compatible S3
const db = await lancedb.connect("s3://my-bucket/knowledge", {
storageOptions: {
endpoint: "https://ACCOUNT_ID.r2.cloudflarestorage.com",
region: "auto",
},
});C'est cette propriété qui distingue LanceDB d'une bibliothèque en mémoire. Votre fonction serverless peut ouvrir une table sur S3, exécuter une requête et se terminer — sans processus longue durée conservant un index en RAM. Les lectures sont paresseuses et par plages, si bien qu'ouvrir une table de 40 Go ne coûte presque rien tant que vous ne touchez pas réellement aux lignes.
Ce qui vit dans ce répertoire
Dans ./data/knowledge, vous trouverez un sous-répertoire par table, et à l'intérieur de chacun : des fragments de données, un manifeste versionné et des fichiers d'index. Comme chaque écriture produit une nouvelle version de manifeste, la table possède un historique complet. Vous pouvez l'inspecter et le parcourir :
const table = await db.openTable("articles");
console.log(await table.version()); // par ex. 7
const history = await table.listVersions(); // chaque commit, horodaté
await table.checkout(3); // lire la table telle qu'elle était en version 3
await table.checkoutLatest(); // retour au présentLe voyage dans le temps est gratuit ici parce que Lance ne modifie jamais un fragment sur place. Cela rend réellement praticable le diagnostic de « pourquoi ce résultat de recherche a-t-il changé mardi dernier » — une question à laquelle un magasin vectoriel classique ne peut généralement pas répondre.
Étape 3 : définir un schéma avec embeddings automatiques
La plupart des tutoriels vous font calculer les embeddings à la main à chaque insertion et à chaque requête, puis vous obligent à retenir quel modèle a produit quelle colonne. Le registre d'embeddings de LanceDB déplace cela dans le schéma, si bien que la table sait elle-même vectoriser ses propres données.
// 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 : le texte brut qui sera embeddé
content: embedFunc.sourceField(new Utf8()),
// vectorField : la colonne d'embedding, dimensions déduites du modèle
vector: embedFunc.vectorField(),
// colonnes de métadonnées classiques
id: new Utf8(),
title: new Utf8(),
category: new Utf8(),
lang: new Utf8(),
publishedYear: new Int32(),
});L'import de @lancedb/lancedb/embedding/openai est un import à effet de bord qui enregistre le fournisseur OpenAI dans le registre global. L'oublier est de loin l'erreur de configuration la plus fréquente — la recherche dans le registre renvoie undefined et vous obtenez un plantage déroutant sur référence nulle.
Deux choses sont désormais vraies, et elles comptent beaucoup en pratique :
- Quand vous insérez une ligne, vous fournissez
contentet LanceDB remplitvectorà votre place. - Quand vous cherchez avec une chaîne, LanceDB embedde la requête avec le même modèle, automatiquement. Il devient impossible d'interroger par accident une table
text-embedding-3-smallavec des vecteursada-002.
Une alternative entièrement locale
Si vous préférez ne pas envoyer votre texte à une API, remplacez le fournisseur par un fournisseur local. Installez @xenova/transformers et utilisez le fournisseur transformers intégré :
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;Tout ce qui suit dans ce tutoriel — index, recherche hybride, reranking — fonctionne à l'identique. Le modèle s'exécute dans le processus via ONNX, produit des vecteurs de 384 dimensions au lieu de 1536, et ne touche jamais au réseau. Pour un corpus de quelques dizaines de milliers de fragments, la qualité est largement suffisante et vos données restent sur votre machine.
Étape 4 : créer la table et charger les données
Avec un schéma en main, créez une table vide et ajoutez des lignes. Utilisez createEmptyTable quand le schéma pilote la structure, et mode: "overwrite" pour que le script soit rejouable pendant le développement.
// 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,
},
// ...dans un vrai projet, chargez des centaines ou des milliers d'entrées depuis votre CMS
];
async function seed() {
const db = await getDb();
const table = await db.createEmptyTable("articles", articleSchema, {
mode: "overwrite",
});
// Aucun appel d'embedding manuel — le schéma s'en charge
await table.add(articles);
console.log(`Indexed ${await table.countRows()} articles`);
}
seed();Exécutez-le :
npx tsx src/seed.tsLanceDB regroupe les lignes par lots, appelle le modèle d'embedding une fois par lot plutôt qu'une fois par ligne, et écrit un unique nouveau fragment. Pour les gros chargements, passez-lui un itérateur asynchrone plutôt qu'un tableau, afin de ne jamais garder tout le corpus en mémoire :
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);
}Étape 5 : construire les index
Une table LanceDB non indexée répond quand même aux requêtes — elle scanne simplement tous les vecteurs en force brute. C'est réellement rapide pour de petites tables (un scan plat sur 50 000 vecteurs prend souvent moins de quelques millisecondes), ce qui explique pourquoi LanceDB ne vous impose pas d'indexer d'emblée. Au-delà d'environ cent mille lignes, construisez un vrai index.
// 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. Index vectoriel (IVF-PQ) pour la recherche approximative des plus proches voisins
await table.createIndex("vector", {
config: lancedb.Index.ivfPq({
distanceType: "cosine",
numPartitions: 256,
numSubVectors: 16,
}),
});
// 2. Index plein texte pour la recherche par mots-clés / BM25
await table.createIndex("content", {
config: lancedb.Index.fts(),
});
// 3. Index scalaires pour que les filtres de métadonnées soient poussés, pas scannés
await table.createIndex("category", { config: lancedb.Index.bitmap() });
await table.createIndex("publishedYear", { config: lancedb.Index.btree() });
console.log(await table.listIndices());
}
buildIndexes();Choisir les paramètres d'index
Les deux réglages IVF-PQ qui comptent vraiment :
| Paramètre | Ce qu'il contrôle | Conseil pratique |
|---|---|---|
numPartitions | Le nombre de cellules de Voronoï découpant l'espace vectoriel | Partez de la racine carrée de votre nombre de lignes. 100 000 lignes donnent environ 316 ; arrondissez à 256 ou 512. |
numSubVectors | L'agressivité de la compression de chaque vecteur | Doit diviser la dimension exactement. Pour 1536 dimensions, 16 ou 96 sont sûrs. Plus élevé signifie plus petit et plus rapide, avec moins de rappel. |
distanceType | La métrique de similarité | Utilisez cosine pour les embeddings de texte. N'utilisez l2 que si vos vecteurs ne sont pas normalisés et que la magnitude porte du sens. |
Un mauvais numSubVectors fait échouer bruyamment la création de l'index avec une erreur de divisibilité, ce qui est un bon mode de défaillance. Un numPartitions trop élevé sur une petite table vous donnera un rappel médiocre, car chaque cellule contiendra trop peu de vecteurs pour être fouillée utilement.
Le choix de l'index scalaire est plus simple : bitmap pour les colonnes à faible cardinalité (catégorie, langue, statut — tout ce qui compte moins de quelques centaines de valeurs distinctes) et btree pour les colonnes à forte cardinalité ou interrogées par plages (dates, années, identifiants numériques).
Les index sont asynchrones
createIndex rend la main avant que l'index soit pleinement interrogeable sur les grandes tables. Attendez-le explicitement dans vos scripts :
await table.waitForIndex(["vector_idx"], 120); // nom, délai en secondesLes noms d'index suivent le motif columnName_idx. Vous pouvez les confirmer avec table.listIndices().
Étape 6 : recherche vectorielle avec filtres
Voici la récompense. Comme le schéma porte la fonction d'embedding, la recherche prend une simple chaîne de caractères.
// 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) // la chaîne est embeddée automatiquement
.limit(limit)
.select(["id", "title", "category"])
.toArray();
}Chaque résultat inclut un champ _distance. Avec cosine, plus c'est bas, plus c'est proche ; une règle empirique pour les embeddings de texte est que tout ce qui dépasse environ 0,5 est faiblement lié et mérite d'être écarté.
Filtrage
Les filtres LanceDB utilisent la syntaxe SQL, et l'endroit où le filtre s'exécute est une décision que vous contrôlez :
const results = await table
.search("how do I protect my API from abuse")
.where("category = 'backend' AND publishedYear >= 2026")
.limit(5)
.toArray();Par défaut, c'est un post-filtre : le moteur récupère les plus proches voisins, puis écarte ceux qui ne satisfont pas le prédicat. C'est rapide, mais si votre filtre est très sélectif vous pouvez vous retrouver avec moins de résultats que demandé — le classique « j'en ai demandé 10 et j'en ai eu 2 ».
Forcez un pré-filtre pour ne chercher qu'à l'intérieur du sous-ensemble correspondant :
const results = await table
.search("how do I protect my API from abuse")
.where("category = 'backend'")
.prefilter(true)
.limit(5)
.toArray();Le pré-filtrage garantit d'obtenir limit résultats dès lors qu'autant de lignes correspondent, au prix d'un scan plus coûteux. C'est exactement à cela que servent les index scalaires de l'étape 5 : avec un index bitmap sur category, le pré-filtre se résout contre l'index au lieu de lire chaque ligne.
Règle empirique : si votre filtre conserve plus d'environ 20 % de la table, post-filtrez. En dessous, pré-filtrez avec un index scalaire.
Ajuster le rappel au moment de la requête
Deux paramètres supplémentaires arbitrent entre latence et précision sans rien reconstruire :
const results = await table
.search(query)
.nprobes(40) // nombre de partitions à explorer ; 20 par défaut
.refineFactor(10) // reclasse le top (limit * 10) avec les vecteurs complets
.limit(5)
.toArray();nprobes élargit la recherche à davantage de cellules de Voronoï. refineFactor récupère des candidats supplémentaires à partir des codes PQ compressés, puis les re-note contre les vecteurs non compressés — ce qui récupère l'essentiel du rappel perdu à la quantification, pour une hausse de latence modeste. Augmenter refineFactor est généralement le gain le moins cher des deux.
Étape 7 : recherche plein texte
La recherche vectorielle échoue d'une manière très précise et prévisible : les identifiants exacts. Un modèle d'embedding n'a aucune représentation utile de IVF_PQ, d'une référence produit comme TN-4471-B ou d'un code d'erreur. Ces requêtes ont besoin d'un appariement lexical.
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();
}Notez table.query() et non table.search(). query() démarre un constructeur sans étape vectorielle, ce qui est exactement ce que vous voulez pour de la récupération purement lexicale. Les résultats portent un champ _score (pertinence BM25) où plus c'est haut, mieux c'est — l'inverse de _distance. Confondre les deux inverse silencieusement votre classement : soyez délibéré.
Vous pouvez chercher dans plusieurs colonnes textuelles à la fois en indexant chacune et en passant une liste de colonnes :
await table
.query()
.fullTextSearch("rate limiting", { columns: ["title", "content"] })
.limit(10)
.toArray();Étape 8 : recherche hybride avec fusion de rangs réciproques
Aucun des deux modes de récupération ne suffit seul. La recherche sémantique comprend l'intention mais rate les tokens exacts ; la recherche par mots-clés capture les tokens exacts mais n'a aucune notion de sens. La recherche hybride exécute les deux et fusionne les résultats.
La méthode de fusion utilisée par défaut dans LanceDB est la fusion de rangs réciproques (RRF), qui note chaque document par la somme de 1 / (k + rang) sur les listes de résultats où il apparaît. Son grand mérite est de ne regarder que les rangs, jamais les scores bruts — peu importe donc que la distance cosinus et le score BM25 vivent sur des échelles totalement différentes.
// 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();
// Embedder la requête une fois et la réutiliser pour la branche vectorielle
const [queryVector] = await embedFunc.computeQueryEmbeddings(query);
return table
.query()
.fullTextSearch(query) // branche lexicale
.nearestTo(queryVector) // branche sémantique
.rerank(reranker) // fusionner les deux listes classées
.select(["id", "title", "category"])
.limit(limit)
.toArray();
}Cette chaîne constitue tout le pipeline hybride. Ajouter à la fois fullTextSearch et nearestTo au même constructeur de requête indique à LanceDB de lancer deux récupérations et de confier les deux listes au reranker.
Essayez-le sur les données d'exemple pour comprendre pourquoi c'est important :
- Requête
"IVF_PQ"— la branche vectorielle renvoie du bruit, la branche lexicale trouve exactement le bon document, RRF le fait remonter en tête. - Requête
"how do I keep my API from being hammered"— la branche lexicale ne trouve rien (aucun token commun), la branche vectorielle trouve l'article sur la limitation de débit, RRF le fait remonter en tête. - Requête
"rate limiting algorithms"— les deux branches concordent, et le document bien classé dans les deux est propulsé au-dessus de ceux qui ne sont bien classés que dans une seule.
Ce dernier cas est la vraie valeur de la fusion : l'accord entre deux signaux indépendants est une preuve solide, et RRF le récompense automatiquement.
Pondérer les deux branches
Si votre corpus penche d'un côté — très jargonneux, ou très rédactionnel — biaisez la fusion :
const reranker = await lancedb.rerankers.RRFReranker.create({
K: 60, // la constante de lissage RRF ; 60 est la valeur standard
returnScore: "all",
});Baisser K accentue l'influence des premiers résultats ; l'augmenter aplatit la courbe et laisse contribuer les résultats plus profonds. Pour une option plus puissante mais plus lente, remplacez-le par un reranker cross-encoder qui lit réellement chaque paire requête-document :
const reranker = await lancedb.rerankers.CohereReranker.create({
model: "rerank-v3.5",
});Les cross-encoders ajoutent généralement une précision notable en tête de liste, au prix d'un aller-retour réseau supplémentaire. Une architecture de production courante consiste à utiliser RRF pour passer de milliers de candidats à 50, puis un cross-encoder sur ces 50.
Étape 9 : mises à jour incrémentales sans doublons
La réindexation naïve — tout supprimer puis tout réajouter — est coûteuse et laisse brièvement votre point de terminaison de recherche sans aucun résultat. Utilisez mergeInsert pour un véritable upsert indexé sur une colonne stable.
// 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);
}Les lignes dont l'id existe déjà sont remplacées, les nouvelles sont insérées, et le reste n'est pas touché. Les embeddings des lignes modifiées sont recalculés automatiquement, puisque le schéma reste responsable de cette tâche.
Pour supprimer également les lignes disparues de la source, ajoutez la clause de suppression :
await table
.mergeInsert("id")
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.whenNotMatchedBySourceDelete()
.execute(fullCorpus);Les suppressions et mises à jour ciblées utilisent des prédicats SQL :
await table.delete("publishedYear < 2024");
await table.update({ where: "category = 'backend'" }, { category: "engineering" });Le compactage n'est pas optionnel
Chaque écriture crée un nouveau fragment, et chaque suppression écrit un marqueur au lieu de réécrire les données. Après quelques milliers de mises à jour incrémentales, vous aurez des milliers de petits fragments, et la latence des requêtes grimpera sensiblement. Planifiez la maintenance :
// src/maintenance.ts
import { getDb } from "./db.js";
export async function compact() {
const db = await getDb();
const table = await db.openTable("articles");
// Fusionner les petits fragments, matérialiser les suppressions, rafraîchir les index
await table.optimize();
// Purger les anciennes versions pour récupérer de l'espace (garde 7 jours d'historique)
await table.optimize({ cleanupOlderThan: new Date(Date.now() - 7 * 864e5) });
console.log(await table.stats());
}optimize() met aussi à jour de façon incrémentale les index vectoriel et FTS pour couvrir les lignes récemment ajoutées. L'ignorer ne rend pas les nouvelles lignes introuvables — LanceDB scanne la queue non indexée en force brute — mais cette queue devient de plus en plus lente à mesure qu'elle grossit. Un cron nocturne est la bonne cadence pour la plupart des charges ; augmentez la fréquence si vous ingérez en continu.
Étape 10 : brancher le tout dans Next.js
La dernière pièce consiste à exposer cela via une route App Router. Le détail important est la réutilisation de la connexion : ouvrir une table est peu coûteux mais pas gratuit, et une fonction serverless qui la rouvre à chaque requête gaspille le cache de descripteurs de fichiers.
// 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;
}Mettre en cache la promesse plutôt que la table résolue garantit que les requêtes concurrentes pendant un démarrage à froid partagent une seule tentative de connexion au lieu de courir pour en créer plusieurs.
Maintenant, la route :
// 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 utilise des bindings Node natifs — le runtime edge ne peut pas les charger
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 });
}
}Trois détails méritent d'être soulignés :
runtime = "nodejs"est obligatoire. LanceDB embarque un binding Rust natif. Un déploiement vers le runtime edge produit une erreur de résolution de module au build, et le message ne laisse pas deviner que le runtime en est la cause.- Échappez les valeurs de filtre. La clause
whereest du SQL. Y interpoler l'entrée utilisateur brute est un vecteur d'injection, exactement comme sous Postgres. Doubler les apostrophes est le minimum ; valider contre une liste blanche de catégories connues est mieux. - Bornez la limite. Sans
Math.min, un appelant peut demander 100 000 lignes et forcer une matérialisation énorme.
Pour le déploiement, rappelez-vous qu'un répertoire de base de données local embarqué est en lecture seule sur la plupart des plateformes serverless. Soit vous pointez LANCEDB_URI vers S3/R2 pour tout ce qui écrit, soit vous acceptez que les écritures n'aient lieu qu'à l'étape de build et que la table déployée soit immuable jusqu'au déploiement suivant. Ce motif « immuable au déploiement » est en réalité excellent pour la recherche documentaire : reconstruisez l'index en CI, livrez-le avec l'application, et obtenez des lectures à latence nulle sans la moindre dépendance externe.
Tester votre implémentation
Vérifiez chaque couche indépendamment plutôt que de faire confiance au résultat de bout en bout :
// 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());
// La récupération par token exact doit venir de la branche FTS
const kw = await table.query().fullTextSearch("IVF_PQ").limit(3).toArray();
console.log("Keyword hit:", kw[0]?.title);
// L'intention paraphrasée doit venir de la branche vectorielle
const sem = await table.search("stop people hammering my endpoint").limit(3).toArray();
console.log("Semantic hit:", sem[0]?.title, sem[0]?._distance);
// Inspecter le plan pour confirmer que les filtres sont poussés
const plan = await table
.search("caching")
.where("category = 'frontend'")
.prefilter(true)
.explainPlan(true);
console.log(plan);
}
verify();explainPlan(true) est l'outil qui répond à « mon filtre utilise-t-il réellement l'index ». Cherchez un nœud ScalarIndexQuery dans la sortie. Si vous voyez à la place un simple FilterExec au-dessus d'un scan complet, votre index scalaire est absent, ou il a été créé après l'écriture des lignes et n'a pas été rafraîchi par optimize().
Puis interrogez l'API :
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"Dépannage
Cannot read properties of undefined (reading 'create')
Le fournisseur d'embedding n'a jamais été enregistré. Ajoutez l'import à effet de bord — import "@lancedb/lancedb/embedding/openai" — avant d'appeler getRegistry().get(...).
Module not found: Can't resolve '@lancedb/lancedb-darwin-arm64'
Le binaire optionnel de la plateforme ne s'est pas installé. Supprimez node_modules et le fichier de verrouillage, puis réinstallez. Si vous déployez depuis macOS vers Linux, installez avec --os=linux --cpu=x64 ou construisez dans un conteneur de la plateforme cible pour récupérer le bon binaire.
Les recherches renvoient moins de résultats que la limite
Un post-filtre sélectif a écarté la majorité des candidats. Ajoutez .prefilter(true) et assurez-vous que la colonne filtrée possède un index scalaire.
Le rappel est mauvais après la construction d'un index IVF-PQ
Soit numPartitions est trop élevé pour le nombre de lignes, soit la quantification est trop agressive. Augmentez d'abord nprobes et refineFactor — les essayer ne coûte rien. Si cela ne suffit pas, reconstruisez avec moins de partitions ou moins de sous-vecteurs.
Les requêtes ralentissent avec le temps
Prolifération de fragments. Lancez table.optimize(). Si la latence chute nettement après, planifiez-le en cron.
Commit conflict sur des écritures concurrentes
Deux processus ont écrit simultanément sur la même version de table. La concurrence optimiste de LanceDB réessaiera, mais une conception à écrivain unique est bien plus simple : canalisez les écritures via un seul processus ou une file, et laissez tous les autres processus lire.
Nom d'index introuvable dans waitForIndex
Les noms sont dérivés en columnName_idx. Appelez table.listIndices() et utilisez le nom exact qu'il rapporte.
Quand LanceDB est un mauvais choix
Être honnête sur les limites vous évite une migration douloureuse plus tard :
- De nombreux écrivains concurrents. La concurrence optimiste de LanceDB est pensée pour un écrivain unique. Si une douzaine de services écrivent tous dans une même table, utilisez une base de données à serveur.
- Une fraîcheur inférieure à la seconde à grande échelle. Les nouvelles lignes sont immédiatement interrogeables, mais elles vivent dans une queue non indexée jusqu'au prochain
optimize(). Une ingestion à haute vélocité couplée à un budget de latence strict s'accorde mal. - Des milliards de vecteurs avec un fort débit de requêtes. LanceDB monte à de très grandes tables sur stockage objet, mais un cluster distribué dédié le battra sur des charges soutenues à forte concurrence.
Pour tout le reste — recherche documentaire, RAG sur une base de connaissances, mémoire d'agents, recherche produit sémantique, recherche de code, applications de bureau local-first — l'absence de serveur est une véritable simplification architecturale, pas un compromis.
Prochaines étapes
- Découpez les documents longs avant de les embedder ; un fragment de 400 à 800 tokens avec un léger recouvrement est l'optimum habituel, et cela compte davantage pour la qualité de récupération que n'importe quel paramètre d'index.
- Stockez des embeddings d'images à côté du texte dans la même table — Lance est un format multimodal, et une colonne
Binarypeut contenir les octets de l'image juste à côté du vecteur. - Associez ceci à notre guide sur la création d'un serveur MCP en TypeScript pour exposer la recherche comme un outil appelable par vos agents IA.
- Comparez les compromis avec une approche à serveur dans le tutoriel de recherche sémantique Qdrant.
- Ajoutez de l'évaluation avant d'aller plus loin dans le réglage — voyez Promptfoo pour les évals de LLM pour mesurer si vos changements de reranker aident réellement.
Conclusion
Vous avez construit un moteur de recherche hybride complet qui s'exécute entièrement dans votre processus Node.js : un schéma qui embedde ses propres données, des index IVF-PQ et BM25 sur la même table, une fusion de rangs réciproques combinant les deux modes de récupération, un pushdown de filtres via les index scalaires, des upserts incrémentaux sans doublons, et un point de terminaison Next.js qui sert le tout.
Ce qu'il faut retenir n'est pas la surface d'API — c'est le glissement architectural. Une base de données vectorielle n'a pas besoin d'être de l'infrastructure. Quand la base est un dossier que vous pouvez versionner, copier, embarquer dans un conteneur ou déposer sur S3, une quantité considérable de complexité opérationnelle disparaît tout simplement. Pour la grande majorité des charges de récupération, c'est le bon réglage par défaut, et recourir à un cluster devrait être une décision que vous prenez après avoir mesuré une raison de le faire.