Noqta
  • Home
  • Services
  • About us
  • Writing
  • Sign in
writing/tutorial/2026/07
● TutorialJul 23, 2026·30 min read

LanceDB with TypeScript: Build Embedded Hybrid Search in Next.js Without a Vector Server

LanceDB is the only truly embedded vector database in the Node.js ecosystem — no Docker, no server, no per-vector billing. This tutorial builds a complete hybrid search engine in TypeScript: schema-driven embeddings, IVF-PQ vector indexes, full-text search, RRF reranking, incremental upserts, and a Next.js App Router API backed by local disk or S3.

AI Bot
AI Bot
Author
·EN · FR · AR

Every vector database tutorial starts the same way: run a Docker container, wait for a health check, configure a connection string, then hope the container is still alive tomorrow. That ceremony makes sense at scale, but it is absurd overhead for the 90% of applications whose entire corpus is a few hundred thousand chunks of documentation, product records, or support tickets.

LanceDB removes the server entirely. It is an embedded retrieval engine — think SQLite, but for vectors — that runs inside your Node.js process, writes to a directory on disk (or straight to S3), and gives you vector search, full-text search, SQL filtering, and versioning without a single daemon. It is built on Lance, a columnar format designed for random access to multimodal data, which is why it can scan and filter far faster than sticking embeddings in Parquet or a relational blob column.

In this tutorial you will build a complete, production-shaped hybrid search service in TypeScript. You will define a schema that generates embeddings automatically, index a real corpus, build both vector and full-text indexes, combine them with reciprocal-rank fusion, handle incremental re-indexing without duplicates, and expose the whole thing through a Next.js App Router endpoint.

Prerequisites

Before starting, make sure you have:

  • Node.js 20 or later installed (LanceDB ships prebuilt native binaries for macOS, Linux, and Windows)
  • TypeScript basics — interfaces, async/await, generics
  • An OpenAI API key for embeddings (we will also cover a fully local alternative)
  • Familiarity with Next.js App Router for the final section
  • A code editor; VS Code is recommended

No Docker. No Postgres. No cloud account required to follow along.

What You'll Build

A searchable knowledge base over a corpus of technical articles that supports:

  1. Semantic search — "how do I keep my API from being hammered" finds an article titled Rate Limiting Strategies
  2. Keyword search — an exact search for IVF_PQ finds the one document that mentions it, even if the embedding model has no idea what it means
  3. Hybrid search — both at once, fused into a single ranked list
  4. Metadata filtering — restrict results by language, category, or publish date, pushed down into the scan
  5. Incremental updates — re-index a changed document without creating a duplicate row

The entire database will be a folder on disk that you can commit, back up with rsync, or ship inside a Docker image.

Step 1: Project Setup

Create the project and install the modern client. The package you want is @lancedb/lancedb; the older vectordb package is deprecated and should not be used for new work.

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 --init

apache-arrow is a peer dependency — LanceDB speaks Arrow natively, which is how it moves columnar batches between Rust and JavaScript with almost no serialization cost.

Update tsconfig.json for modern module resolution:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

Add "type": "module" to package.json, then create the folder structure:

mkdir -p src data
echo "data/" >> .gitignore
echo ".env" >> .gitignore

Put your key in .env:

OPENAI_API_KEY=sk-your-key-here

Step 2: Connect and Understand the Storage Model

Connecting to LanceDB is a single call that takes a path. If the directory does not exist, it is created.

// src/db.ts
import * as lancedb from "@lancedb/lancedb";
 
export async function getDb() {
  // A local directory — this IS your database
  return lancedb.connect("./data/knowledge");
}

That is the whole connection story. There is no port, no auth, no pool.

The same call scales up without a code change. Point it at object storage and LanceDB reads and writes Lance files directly over the network:

// Amazon S3
const db = await lancedb.connect("s3://my-bucket/knowledge");
 
// Cloudflare R2 or any S3-compatible endpoint
const db = await lancedb.connect("s3://my-bucket/knowledge", {
  storageOptions: {
    endpoint: "https://ACCOUNT_ID.r2.cloudflarestorage.com",
    region: "auto",
  },
});

This is the property that makes LanceDB different from an in-memory library. Your serverless function can open a table on S3, run a query, and exit — no long-lived process holding an index in RAM. Reads are lazy and range-based, so opening a 40 GB table costs almost nothing until you actually touch rows.

What lives in that directory

Inside ./data/knowledge you will find one subdirectory per table, and inside each: data fragments, a versioned manifest, and index files. Because every write produces a new manifest version, the table has a full history. You can inspect and travel back through it:

const table = await db.openTable("articles");
 
console.log(await table.version());        // e.g. 7
const history = await table.listVersions(); // every commit, with timestamps
 
await table.checkout(3);   // read the table as it existed at version 3
await table.checkoutLatest(); // back to the present

Time travel is free here because Lance never mutates fragments in place. That makes it genuinely practical to debug "why did this search result change last Tuesday" — a question that is usually unanswerable with a conventional vector store.

Step 3: Define a Schema with Automatic Embeddings

Most vector database tutorials make you compute embeddings by hand on every insert and every query, then remember which model produced which column. LanceDB's embedding registry moves that into the schema, so the table itself knows how to vectorize its own data.

// 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: the raw text that gets embedded
  content: embedFunc.sourceField(new Utf8()),
  // vectorField: the embedding column, dimensions inferred from the model
  vector: embedFunc.vectorField(),
  // plain metadata columns
  id: new Utf8(),
  title: new Utf8(),
  category: new Utf8(),
  lang: new Utf8(),
  publishedYear: new Int32(),
});

The import of @lancedb/lancedb/embedding/openai is a side-effect import that registers the OpenAI provider in the global registry. Forgetting it is the single most common setup error — the registry lookup returns undefined and you get a confusing null-reference crash.

Two things are now true that matter a lot in practice:

  • When you insert a row, you supply content and LanceDB fills in vector for you.
  • When you search with a string, LanceDB embeds the query with the same model automatically. There is no way to accidentally query a text-embedding-3-small table with ada-002 vectors.

A fully local alternative

If you would rather not send text to an API, swap the provider for a local one. Install @xenova/transformers and use the built-in transformers provider:

npm install @xenova/transformers
import "@lancedb/lancedb/embedding/transformers";
 
export const embedFunc = getRegistry()
  .get("huggingface")!
  .create({ model: "Xenova/all-MiniLM-L6-v2" }) as lancedb.embedding.EmbeddingFunction;

Everything downstream in this tutorial — indexes, hybrid search, reranking — works identically. The model runs in-process via ONNX, produces 384-dimensional vectors instead of 1536, and never touches the network. For a corpus in the tens of thousands of chunks, quality is more than adequate and your data stays on your machine.

Step 4: Create the Table and Load Data

With a schema in hand, create an empty table and add rows. Use createEmptyTable when the schema drives the structure, and mode: "overwrite" so the script is re-runnable during development.

// 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,
  },
  // ...in a real project, load hundreds or thousands from your CMS
];
 
async function seed() {
  const db = await getDb();
 
  const table = await db.createEmptyTable("articles", articleSchema, {
    mode: "overwrite",
  });
 
  // No manual embedding calls — the schema handles it
  await table.add(articles);
 
  console.log(`Indexed ${await table.countRows()} articles`);
}
 
seed();

Run it:

npx tsx src/seed.ts

LanceDB batches the rows, calls the embedding model once per batch rather than once per row, and writes a single new fragment. For large loads, feed it an async iterator instead of an array so you never hold the whole corpus in memory:

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);
}

Step 5: Build the Indexes

An unindexed LanceDB table still answers queries — it just brute-force scans every vector. That is genuinely fast for small tables (a flat scan over 50,000 vectors is often under a few milliseconds), which is why LanceDB does not force you to index up front. Once you cross roughly a hundred thousand rows, build a real 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. Vector index (IVF-PQ) for approximate nearest neighbour search
  await table.createIndex("vector", {
    config: lancedb.Index.ivfPq({
      distanceType: "cosine",
      numPartitions: 256,
      numSubVectors: 16,
    }),
  });
 
  // 2. Full-text index for keyword / BM25 search
  await table.createIndex("content", {
    config: lancedb.Index.fts(),
  });
 
  // 3. Scalar indexes so metadata filters are pushed down, not scanned
  await table.createIndex("category", { config: lancedb.Index.bitmap() });
  await table.createIndex("publishedYear", { config: lancedb.Index.btree() });
 
  console.log(await table.listIndices());
}
 
buildIndexes();

Choosing index parameters

The two IVF-PQ knobs are the ones that actually matter:

ParameterWhat it controlsPractical guidance
numPartitionsHow many Voronoi cells the vector space is split intoStart near the square root of your row count. 100k rows to about 316; round to 256 or 512.
numSubVectorsHow aggressively each vector is compressedMust divide the dimension evenly. For 1536 dims, 16 or 96 are safe. Higher means smaller and faster, with lower recall.
distanceTypeThe similarity metricUse cosine for text embeddings. Use l2 only if your vectors are not normalized and magnitude carries meaning.

Pick the wrong numSubVectors and index creation fails loudly with a divisibility error, which is a good failure mode. Pick numPartitions too high on a small table and you will get poor recall, because each cell holds too few vectors to search meaningfully.

The scalar index choice is simpler: bitmap for low-cardinality columns (category, language, status — anything with fewer than a few hundred distinct values) and btree for high-cardinality or range-queried columns (dates, years, numeric IDs).

Indexes are asynchronous

createIndex returns before the index is fully queryable on large tables. Wait for it explicitly in scripts:

await table.waitForIndex(["vector_idx"], 120); // name, timeout seconds

Index names follow the pattern columnName_idx. You can confirm them with table.listIndices().

Step 6: Vector Search with Filters

Now the payoff. Because the schema carries the embedding function, searching takes a plain string.

// 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)              // string is embedded automatically
    .limit(limit)
    .select(["id", "title", "category"])
    .toArray();
}

Each result includes a _distance field. With cosine, lower is closer; a rough rule of thumb for text embeddings is that anything above about 0.5 is weakly related and worth cutting.

Filtering

LanceDB filters use SQL syntax, and where the filter runs is a decision you control:

const results = await table
  .search("how do I protect my API from abuse")
  .where("category = 'backend' AND publishedYear >= 2026")
  .limit(5)
  .toArray();

By default this is a post-filter: the engine retrieves the nearest neighbours, then discards those that fail the predicate. It is fast, but if your filter is very selective you can end up with fewer results than you asked for — the classic "I asked for 10 and got 2" problem.

Force a pre-filter to search only within the matching subset:

const results = await table
  .search("how do I protect my API from abuse")
  .where("category = 'backend'")
  .prefilter(true)
  .limit(5)
  .toArray();

Pre-filtering guarantees you get limit results whenever that many rows match, at the cost of a more expensive scan. This is exactly what the scalar indexes from Step 5 are for: with a bitmap index on category, the pre-filter resolves against the index instead of reading every row.

Rule of thumb: if your filter keeps more than roughly 20% of the table, post-filter. Below that, pre-filter with a scalar index.

Tuning recall at query time

Two more parameters trade latency for accuracy without rebuilding anything:

const results = await table
  .search(query)
  .nprobes(40)          // how many partitions to search; default 20
  .refineFactor(10)     // re-rank top (limit * 10) using full vectors
  .limit(5)
  .toArray();

nprobes widens the search across more Voronoi cells. refineFactor fetches extra candidates using the compressed PQ codes, then re-scores them against the uncompressed vectors — which recovers most of the recall that quantization cost you, for a modest latency increase. Raising refineFactor is usually the cheaper win of the two.

Step 7: Full-Text Search

Vector search fails in a very specific and predictable way: exact identifiers. An embedding model has no meaningful representation for IVF_PQ, an SKU like TN-4471-B, or an error code. Those queries need lexical matching.

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();
}

Note table.query() rather than table.search(). query() starts a builder with no vector stage, which is what you want for pure keyword retrieval. Results carry a _score field (BM25 relevance) where higher is better — the opposite direction from _distance. Mixing these two up silently inverts your ranking, so be deliberate about it.

You can search across several text columns at once by indexing each and passing a column list:

await table
  .query()
  .fullTextSearch("rate limiting", { columns: ["title", "content"] })
  .limit(10)
  .toArray();

Step 8: Hybrid Search with Reciprocal Rank Fusion

Neither retrieval mode is sufficient alone. Semantic search understands intent but misses exact tokens; keyword search nails exact tokens but has no concept of meaning. Hybrid search runs both and fuses the results.

The fusion method LanceDB uses by default is Reciprocal Rank Fusion (RRF), which scores each document by the sum of 1 / (k + rank) across the result lists it appears in. Its great virtue is that it only looks at ranks, never at raw scores — so it does not matter that cosine distance and BM25 live on completely different scales.

// 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();
 
  // Embed the query once and reuse it for the vector leg
  const [queryVector] = await embedFunc.computeQueryEmbeddings(query);
 
  return table
    .query()
    .fullTextSearch(query)   // lexical leg
    .nearestTo(queryVector)  // semantic leg
    .rerank(reranker)        // fuse the two ranked lists
    .select(["id", "title", "category"])
    .limit(limit)
    .toArray();
}

That chain is the whole hybrid pipeline. Adding both fullTextSearch and nearestTo to the same query builder tells LanceDB to run two retrievals and hand both lists to the reranker.

Try it against the seed data to see why it matters:

  • Query "IVF_PQ" — the vector leg returns noise, the keyword leg nails the exact document, RRF surfaces it first.
  • Query "how do I keep my API from being hammered" — the keyword leg finds nothing (no shared tokens), the vector leg finds the rate limiting article, RRF surfaces it first.
  • Query "rate limiting algorithms" — both legs agree, and the document that ranks well in both is boosted above documents that rank well in only one.

That last case is the real value of fusion: agreement between two independent signals is strong evidence, and RRF rewards it automatically.

Weighting the two legs

If your corpus leans one way — heavy on jargon, or heavy on prose — bias the fusion:

const reranker = await lancedb.rerankers.RRFReranker.create({
  K: 60,        // the RRF smoothing constant; 60 is the standard default
  returnScore: "all",
});

Lowering K sharpens the influence of top-ranked results; raising it flattens the curve and lets deeper results contribute. For a stronger but slower option, swap in a cross-encoder reranker that actually reads each query-document pair:

const reranker = await lancedb.rerankers.CohereReranker.create({
  model: "rerank-v3.5",
});

Cross-encoders typically add meaningful precision at the top of the list, at the cost of an extra network round trip. A common production shape is RRF to get from thousands of candidates down to 50, then a cross-encoder on those 50.

Step 9: Incremental Updates Without Duplicates

The naive re-index — delete everything and re-add — is wasteful and briefly leaves your search endpoint returning nothing. Use mergeInsert for a proper upsert keyed on a stable column.

// 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);
}

Rows whose id already exists are replaced, new rows are inserted, and everything else is untouched. Embeddings for changed rows are recomputed automatically because the schema still owns that job.

To also remove rows that disappeared from the source, add the delete clause:

await table
  .mergeInsert("id")
  .whenMatchedUpdateAll()
  .whenNotMatchedInsertAll()
  .whenNotMatchedBySourceDelete()
  .execute(fullCorpus);

Targeted deletes and updates use SQL predicates:

await table.delete("publishedYear < 2024");
await table.update({ where: "category = 'backend'" }, { category: "engineering" });

Compaction is not optional

Every write creates a new fragment, and every delete writes a tombstone rather than rewriting data. After a few thousand incremental updates you will have thousands of small fragments, and query latency will creep up noticeably. Run maintenance on a schedule:

// src/maintenance.ts
import { getDb } from "./db.js";
 
export async function compact() {
  const db = await getDb();
  const table = await db.openTable("articles");
 
  // Merge small fragments, materialize deletions, refresh indexes
  await table.optimize();
 
  // Drop old versions to reclaim disk (keeps 7 days of history)
  await table.optimize({ cleanupOlderThan: new Date(Date.now() - 7 * 864e5) });
 
  console.log(await table.stats());
}

optimize() also incrementally updates the vector and FTS indexes to cover newly added rows. Skipping it means new rows are still found — LanceDB brute-force scans the unindexed tail — but that tail gets slower and slower as it grows. A nightly cron is the right cadence for most workloads; run it more often if you ingest continuously.

Step 10: Wire It Into Next.js

The final piece is exposing this through an App Router route. The important detail is connection reuse: opening a table is cheap but not free, and a serverless function that reopens it on every request wastes the file handle cache.

// 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;
}

Caching the promise rather than the resolved table means concurrent requests during cold start share one connection attempt instead of racing to create several.

Now the 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 uses native Node bindings — the edge runtime cannot load them
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 });
  }
}

Three details worth calling out:

  1. runtime = "nodejs" is mandatory. LanceDB ships a native Rust binding. Deploying to the edge runtime produces a module-resolution error at build time, and it is not obvious from the message that the runtime is the cause.
  2. Escape filter values. The where clause is SQL. Interpolating raw user input is an injection vector, exactly as it would be in Postgres. Doubling single quotes is the minimum; validating against an allowlist of known categories is better.
  3. Clamp the limit. Without Math.min, a caller can request 100,000 rows and force a huge materialization.

For deployment, remember that a bundled local database directory is read-only on most serverless platforms. Either point LANCEDB_URI at S3/R2 for anything that writes, or accept that writes only happen in your build step and the deployed table is immutable until the next deploy. The immutable-at-deploy pattern is genuinely good for documentation search: rebuild the index in CI, ship it with the app, get zero-latency reads with no external dependency at all.

Testing Your Implementation

Verify each layer independently rather than trusting the end-to-end result:

// 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());
 
  // Exact-token retrieval should come from the FTS leg
  const kw = await table.query().fullTextSearch("IVF_PQ").limit(3).toArray();
  console.log("Keyword hit:", kw[0]?.title);
 
  // Paraphrased intent should come from the vector leg
  const sem = await table.search("stop people hammering my endpoint").limit(3).toArray();
  console.log("Semantic hit:", sem[0]?.title, sem[0]?._distance);
 
  // Inspect the plan to confirm filters are pushed down
  const plan = await table
    .search("caching")
    .where("category = 'frontend'")
    .prefilter(true)
    .explainPlan(true);
  console.log(plan);
}
 
verify();

explainPlan(true) is the tool that answers "is my filter actually using the index." Look for a ScalarIndexQuery node in the output. If you instead see a plain FilterExec over a full scan, your scalar index is missing or was created after the rows were written and has not been refreshed by optimize().

Then hit the 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"

Troubleshooting

Cannot read properties of undefined (reading 'create') The embedding provider was never registered. Add the side-effect import — import "@lancedb/lancedb/embedding/openai" — before calling getRegistry().get(...).

Module not found: Can't resolve '@lancedb/lancedb-darwin-arm64' The optional platform binary did not install. Delete node_modules and the lockfile, then reinstall. If you deploy from macOS to Linux, install with --os=linux --cpu=x64 or build inside the target platform's container so the correct binary is fetched.

Searches return fewer results than the limit A selective post-filter discarded most candidates. Add .prefilter(true), and make sure the filtered column has a scalar index.

Recall is poor after building an IVF-PQ index Either numPartitions is too high for the row count, or quantization is too aggressive. Raise nprobes and refineFactor first — they cost nothing to try. If that is not enough, rebuild with fewer partitions or fewer sub-vectors.

Queries slow down over time Fragment sprawl. Run table.optimize(). If latency drops sharply afterwards, schedule it as a cron job.

Commit conflict on concurrent writes Two processes wrote to the same table version simultaneously. LanceDB's optimistic concurrency will retry, but a single-writer design is far simpler: funnel writes through one process or a queue, and let every other process read.

Index name not found in waitForIndex Names are derived as columnName_idx. Call table.listIndices() and use the exact name it reports.

When LanceDB Is the Wrong Choice

Being honest about the boundaries saves you a painful migration later:

  • Many concurrent writers. LanceDB's optimistic concurrency is built around a single writer. If a dozen services all write to one table, use a server-based database.
  • Sub-second freshness at scale. New rows are searchable immediately, but they live in an unindexed tail until optimize() runs. High-velocity ingest plus a strict latency budget is a poor fit.
  • Billions of vectors with heavy QPS. LanceDB scales to very large tables on object storage, but a dedicated distributed cluster will beat it on sustained high-concurrency workloads.

For everything else — documentation search, RAG over a knowledge base, agent memory, semantic product search, code search, local-first desktop apps — the absence of a server is a genuine architectural simplification rather than a compromise.

Next Steps

  • Chunk longer documents before embedding; a 400 to 800 token chunk with a small overlap is the usual sweet spot, and it matters more for retrieval quality than any index parameter you can tune.
  • Store image embeddings alongside text in the same table — Lance is a multimodal format, and a Binary column can hold the image bytes right next to the vector.
  • Pair this with our guide on building an MCP server in TypeScript to expose the search as a tool your AI agents can call.
  • Compare the trade-offs against a server-based approach in the Qdrant semantic search tutorial.
  • Add evaluation before you tune anything further — see Promptfoo for LLM evals to measure whether your reranker changes actually help.

Conclusion

You built a complete hybrid search engine that runs entirely inside your Node.js process: a schema that embeds its own data, IVF-PQ and BM25 indexes over the same table, reciprocal-rank fusion combining both retrieval modes, filter pushdown through scalar indexes, duplicate-free incremental upserts, and a Next.js endpoint serving it all.

The thing worth internalizing is not the API surface — it is the architectural shift. A vector database does not have to be infrastructure. When the database is a folder that you can version, copy, ship in a container, or park on S3, an enormous amount of operational complexity simply disappears. For the large majority of retrieval workloads, that is the right default, and reaching for a cluster should be a decision you make when you have measured a reason to.

● Tags
#lancedb#vector-database#typescript#nextjs#hybrid-search#rag#embeddings#intermediate#30 min read
● Share
● A question?

Talk to a Noqta agent about this article.

AI Bot
AI Bot
Author · noqta
Follow ↗

● Read next

AI Chatbot Integration Guide: Build Intelligent Conversational Interfaces
● Tutorial

AI Chatbot Integration Guide: Build Intelligent Conversational Interfaces

Jan 25, 2026
Introduction to MCP: A Beginner's Quickstart Guide
● Tutorial

Introduction to MCP: A Beginner's Quickstart Guide

Jan 25, 2026
Building a RAG Chatbot with Supabase pgvector and Next.js
● Tutorial

Building a RAG Chatbot with Supabase pgvector and Next.js

Jan 28, 2026
Noqta
Terms and Conditions · Privacy Policy
Free Arabic tools
  • Arabic keyboard
  • Arabic alphabet
  • Arabic diacritics
  • Reverse Arabic text
  • Arabic text decorator
  • All Arabic resources
Services
  • AI Automation
  • AI Agents
  • CX Automation
  • Vibe Coding
  • Project Management
  • Quality Assurance
  • Web Development
  • API Integration
  • Business Applications
  • Maintenance
  • Low-Code/No-Code
Links
  • About Us
  • How It Works?
  • News
  • Tutorials
  • Blog
  • Contact
  • FAQ
  • Resources
Regions
  • Saudi Arabia
  • UAE
  • Qatar
  • Bahrain
  • Oman
  • Libya
  • Tunisia
  • Algeria
  • Morocco
Company
  • Noqta, Tunisia, Tunis, phone +216 40 385 594
© Noqta. All rights reserved.