@stablemodels/qmd-cf
v0.3.0
Published
Hybrid full-text + vector search for Cloudflare Durable Objects. A DO-native reimagination of qmd.
Readme
@stablemodels/qmd-cf
Hybrid full-text + vector search for Cloudflare Durable Objects. A DO-native reimagination of qmd.
FTS5 runs co-located in the DO's SQLite for zero-latency BM25 keyword search. Optionally add Cloudflare Vectorize for semantic search, fused via Reciprocal Rank Fusion.
Install
npm install @stablemodels/qmd-cfPeer dependency: @cloudflare/workers-types (optional). Core platform types (SqlStorage, SqlStorageCursor, SqlStorageValue) are re-exported from the main entry point, so most consumers don't need the peer dep at all.
Usage
FTS-only (zero external dependencies)
import { Qmd } from "@stablemodels/qmd-cf";
export class MyDO extends DurableObject {
qmd: Qmd;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.qmd = new Qmd(ctx.storage.sql);
}
async index(id: string, content: string) {
return this.qmd.index({ id, content });
}
async search(query: string) {
return this.qmd.search(query);
}
}Hybrid FTS + Vector
this.qmd = new Qmd(ctx.storage.sql, {
vectorize: env.VECTORIZE,
embedFn: (texts) =>
env.AI.run("@cf/baai/bge-m3", { text: texts }).then((r) => r.data),
});Requires a Vectorize index and Workers AI binding in your wrangler.toml:
[ai]
binding = "AI"
[[vectorize]]
binding = "VECTORIZE"
index_name = "my-index"API
Indexing
// Index a document
await qmd.index({ id: "doc.md", content: "...", title: "My Doc" });
// Batch index
await qmd.indexBatch(docs);
// Remove a document
await qmd.remove("doc.md");Documents support optional title, docType, namespace, and metadata fields. Content hashing skips re-indexing when content is unchanged. Use maxChunksPerDocument in config to guard against extremely large documents.
Searching
// Hybrid search (FTS + vector when configured, FTS-only otherwise)
const results = await qmd.search("query", { limit: 5 });
// FTS-only search
const ftsResults = qmd.searchFts("query");
// Vector-only search
const vecResults = await qmd.searchVector("query");Filter by docType or namespace:
const results = await qmd.search("query", { docType: "note", namespace: "projects/web" });Other methods
qmd.has("doc.md"); // Check if document exists
qmd.get("doc.md"); // Get document content
qmd.list({ namespace: "projects" }); // List document IDs
qmd.listByNamespace("projects/*"); // List docs by namespace pattern
qmd.stats(); // Index statistics
qmd.rebuild(); // Rebuild FTS indexConfiguration
const qmd = new Qmd(ctx.storage.sql, {
config: {
chunkSize: 3200, // Max chars per chunk (default: 3200)
chunkOverlap: 480, // Overlap between chunks (default: 480)
strongSignalMinScore: 0.85, // BM25 score threshold to skip vector search (default: 0.85)
strongSignalMinGap: 0.15, // Min gap between top-1 and top-2 scores (default: 0.15)
maxChunksPerDocument: 0, // Max chunks per doc, 0 = unlimited (default: 0)
},
});Contexts
Contexts enrich vector embeddings with semantic path descriptions:
qmd.setContext("projects/", "Engineering project documentation");
qmd.setContext("projects/web/", "Frontend web application docs");Testing
The package provides test utilities via the /testing subpath:
import { MockSqlStorage, createMockEmbedFn } from "@stablemodels/qmd-cf/testing";
import { Qmd } from "@stablemodels/qmd-cf";
const sql = new MockSqlStorage();
const qmd = new Qmd(sql);
await qmd.index({ id: "doc-1", content: "Hello world" });
const results = qmd.searchFts("hello");
sql.close();MockSqlStorage is backed by bun:sqlite with real FTS5 support. MockVectorize provides in-memory vector search with brute-force cosine similarity. createMockEmbedFn(dims?) returns a deterministic embedding function for reproducible tests.
Requires Bun as the test runner.
Running the library's own tests
# Unit tests (bun, ~200ms)
bun test tests/*.test.ts
# Workerd integration tests (vitest + @cloudflare/vitest-pool-workers)
vitest run --config vitest.config.ts
# Both
npm test