Skip to main content

@monlite/fts — full-text search

Keyword search over your documents, backed by SQLite FTS5.

npm install @monlite/fts

Plugin (document collections)

import { createDb } from "@monlite/core";
import { fts } from "@monlite/fts";

const db = createDb("app.db", { plugins: [fts({ posts: ["title", "body"] })] });
await db.collection("posts").search("hello world", { where: { status: "published" }, limit: 20 });

The plugin keeps the index current on every write and backfills on open. For a separate ingest process, call collection.catchUp() to pick up its writes.

search() never throws on malformed/untrusted query text — a stray ", bare AND/*, or column filter falls back to a literal-phrase match. With a where, it over-fetches ranked matches before filtering so a selective filter doesn't drop hits that exist further down the ranking; tune the pool with { candidates } (default max(limit * 10, 200), capped at 10,000 to stay under SQLite's bound-variable limit). For exact pre-filtered recall over a large corpus, use the dynamic index below (the where scopes the MATCH itself).

Dynamic index — createSearchIndex(db)

When collections are created at runtime (RAG, per-tenant), use the programmatic index — fields are indexed, filterFields are stored so a where scopes the MATCH:

import { createSearchIndex } from "@monlite/fts";

const idx = createSearchIndex(db);
idx.ensureCollection("docs", { fields: ["title", "body"], filterFields: ["docId"] });
idx.upsert("docs", [{ id: "c1", fields: { title, body }, filters: { docId: "d1" } }]);
idx.search("docs", "hello world", { where: { docId: "d1" } }); // scoped to one case/tenant
idx.delete("docs", { where: { docId: "d1" } });

Pair with @monlite/vector for hybrid (keyword + semantic) search via hybridSearch().