cd /news/developer-tools/hybrid-search-in-one-file-with-node-… · home topics developer-tools article
[ARTICLE · art-124738] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Hybrid search in one file with node:sqlite, FTS5 and zero dependencies

A developer built a hybrid search system using Node.js's built-in node:sqlite module and SQLite's FTS5, eliminating the need for native dependencies like better-sqlite3. The system combines lexical search with optional vector search, using reciprocal rank fusion to merge results, and is packaged as the npm library 'hinterland' with zero dependencies.

by read3 min views2 publishedSep 9, 2026

Node 22.5 added node:sqlite to the standard library, and I think people have not registered what that removes.

No better-sqlite3. No node-gyp. No prebuilt binary that is missing for your platform, no rebuild after a Node upgrade, no Docker image that works locally and fails in CI because the native module was compiled against a different ABI.

import { DatabaseSync } from 'node:sqlite';

That is the whole dependency story, and it is enough to build hybrid retrieval.

SQLite's FTS5 comes along for the ride, so lexical search is a virtual table:

CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
  content,
  ...
  tokenize='porter unicode61'
);

Two things in that tokenizer line are doing real work.

porter is stemming. A search for "settling" matches a document containing "settles", because both stem to settl. Without it, exact-word-only matching feels broken in a way users cannot articulate.

unicode61 is Unicode-aware tokenisation with diacritic folding, so "réconciliation" and "reconciliation" find each other. That matters a great deal if any of your content is French and not at all if you never test with it.

Vectors go in an ordinary table as packed binary. No extension, no separate service, no second thing to back up.

At the scale this is designed for, tens of thousands of memories, a linear scan over packed float arrays is fast enough. The point where an index starts paying for itself is further out than people assume.

The vectors are optional. A memory with no embedding is still a row, still searchable lexically, still returned by recall.

Full text search returns BM25 relevance via SQLite's own bm25(). Vector search returns cosine similarity. Those are not on the same scale, they are not on the same scale per query, and their distributions move around.

A weighted sum of normalised scores gives you a magic constant tuned on one dataset, which needs retuning every time either ranker changes.

Reciprocal rank fusion throws the scores away and keeps the positions:

const RRF_K = 60;

for (const { rows, weight = 1, name } of rankings) {
  rows.forEach((row, index) => {
    const entry = combined.get(row.id) ?? { row, score: 0, ranks: {} };
    entry.score += weight * (1 / (k + index + 1));
    entry.ranks[name] = index + 1;
  });
}

A document at position 1 contributes 1/61, position 2 contributes 1/62. A document that both rankers place near the top accumulates from both and rises above anything only one of them liked.

The constant damps the influence of the very top positions, so a single ranker cannot dominate on its own confidence. 60 comes from the original Cormack et al. paper and is insensitive to tuning, so nothing needs retuning when your embedding model changes.

One detail I would not skip: each result keeps the ranks that produced it.

entry.ranks[name] = index + 1;

When a result looks wrong you can see it was 1st lexically and 40th semantically, which tells you whether the problem is the query or the embedding. A fused score alone gives you nothing to act on.

import { Memory } from 'hinterland';

const memory = Memory.open('./memory.db');

await memory.remember('The Benin gateway settles overnight, so refunds lag by a day');
const hits = await memory.recall('why are refunds slow');

That runs on a machine that has never been online. No API key, no server, no embedding model required. Add an embedder and the same calls get semantic recall fused in.

The database is one file, so moving memories between machines is copying a file, and there are commands for doing it selectively.

This is not a vector database. There is no ANN index, no sharding, no distributed anything, and above a few hundred thousand rows you want something built for that.

It is the amount of retrieval machinery that fits in the standard library plus one file, which turned out to be more than I expected.

npm install hinterland

32 tests, zero dependencies, Node 22.5+. hinterland.

── more in #developer-tools 4 stories · sorted by recency
── more on @node.js 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/hybrid-search-in-one…] indexed:0 read:3min 2026-09-09 ·