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

> Source: <https://dev.to/catidegla/hybrid-search-in-one-file-with-nodesqlite-fts5-and-zero-dependencies-j6j>
> Published: 2026-09-09 15:30:08+00:00

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.

``` js
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:

``` js
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.

``` js
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](https://github.com/catidegla/hinterland).
