Skip to content
bits
Retrieval without tears · Lesson 5 of 9 · 25 min

Keywords too: hybrid search

Desk: best at a computer, with a terminal

Verified agents 0.33.0wrangler 4.157.0

Start from e-04 on GitHub. Starting here, or behind? Get the code at that tag:

Shell
git clone https://github.com/ryanyogan/klippy
cd klippy
git checkout e-04
Tutor:

Embeddings find paraphrases, but they're fuzzy about exact tokens: a flight number like TP1337, an error code, a file path. Keyword search is the opposite. It nails exact words and misses paraphrases. Use both, and merge the results. Anthropic's Contextual Retrieval post reports that embeddings plus keyword search (BM25) beat embeddings alone.

Tutor:

1. Keyword search in D1

D1 is SQLite, and SQLite has FTS5, a full-text index. Add packages/db/migrations/0004_chunks_fts.sql:

-- Keyword search over chunks with SQLite's FTS5 (D1 supports it). The index points at chunks (content='chunks'), so
-- the text isn't stored twice, and three triggers keep it in step with every insert, delete and update.
CREATE VIRTUAL TABLE chunks_fts USING fts5(text, content = 'chunks', content_rowid = 'id', tokenize = 'porter unicode61');
CREATE TRIGGER chunks_fts_insert AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts (rowid, text) VALUES (new.id, new.text);
END;
CREATE TRIGGER chunks_fts_delete AFTER DELETE ON chunks BEGIN
INSERT INTO chunks_fts (chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
END;
CREATE TRIGGER chunks_fts_update AFTER UPDATE ON chunks BEGIN
INSERT INTO chunks_fts (chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
INSERT INTO chunks_fts (rowid, text) VALUES (new.id, new.text);
END;
-- Index the chunks saved before this migration.
INSERT INTO chunks_fts (chunks_fts) VALUES ('rebuild');
Plain textUTF-8at e-05● lines 3–6: packages/db/migrations/0004_chunks_fts.sql
Tutor:
  • content = 'chunks': the index points at the chunks table, so the text isn't stored twice.

  • porter: stemming, so "carrots" matches "carrot".

  • Three triggers keep it in step with every insert, delete and update. An external-content index can't delete by itself: you insert a row with the special 'delete' command and the old text.

  • 'rebuild' indexes the chunks you saved before this migration.

Apply it: pnpm exec wrangler d1 migrations apply DB --local.

Tutor:

2. Search by words, safely

Create apps/agent/src/retrieval/search.ts. The two imports are for step 4 (you'll write rrf.ts and vectorSearch in a moment):

import { rrf } from './rrf';
import { vectorSearch } from './vectors';
// The chunk ids that share words with the question, best first (bm25), from this owner's chunks only.
export async function keywordSearch(d1: D1Database, owner: string, question: string, limit = 20): Promise<number[]> {
// Each word in quotes, joined by OR: FTS5 treats quoted text as plain words, so the question can't inject syntax.
const words = question.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
if (words.length === 0) return [];
const match = [...new Set(words)].map((word) => `"${word}"`).join(' OR ');
const { results } = await d1
.prepare('SELECT c.id FROM chunks_fts JOIN chunks c ON c.id = chunks_fts.rowid WHERE chunks_fts MATCH ? AND c.owner = ? ORDER BY rank LIMIT ?')
.bind(match, owner, limit)
.all<{ id: number }>();
return results.map((row) => row.id);
}
TypeScriptUTF-8at e-05● lines 6–9: apps/agent/src/retrieval/search.ts
Tutor:

FTS5 has its own query language (AND, NEAR, *, column filters). The question comes from a person, or a model, so it must never be read as syntax. Each word is put in quotes and joined with OR, which FTS5 reads as plain words. ORDER BY rank is bm25: rare words that match count most. c.owner = ? keeps it to this Klippy's chunks.

The meaning side goes in vectors.ts, filtered the same way:

export async function vectorSearch(env: Env, owner: string, question: string, topK = 20): Promise<number[]> {
const [vector] = await embed(env, [question]);
const { matches } = await env.VECTORIZE.query(vector, { topK, filter: { owner } });
return matches.map((match) => Number(match.id.replace('chunk-', '')));
}
TypeScriptUTF-8at e-05● lines 3–3: apps/agent/src/retrieval/vectors.ts
Tutor:

3. Merge by rank, not by score

A bm25 score and a cosine score aren't on the same scale, so you can't add them. Reciprocal rank fusion (RRF) ignores scores and uses positions: each id earns 1 / (k + rank) from every list it's in. Being near the top of both lists beats being first in one. Create src/retrieval/rrf.ts:

// Reciprocal rank fusion: merge ranked lists by position, not by score. Keyword scores (bm25) and cosine scores
// aren't comparable, but "2nd in one list, 1st in the other" is. Each id earns 1 / (k + rank) from every list it's in;
// k = 60 is the value from the original paper, and it keeps one list's top spot from drowning out the rest.
export function rrf(lists: number[][], k = 60): number[] {
const scores = new Map<number, number>();
for (const list of lists) {
list.forEach((id, rank) => scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1)));
}
return [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}
TypeScriptUTF-8at e-05● lines 4–10: apps/agent/src/retrieval/rrf.ts
Tutor:

k = 60 comes from the original paper. It needs no tuning, which is why it's the usual default.

Tutor:

4. Hybrid search, then the rows

Back in search.ts, below keywordSearch: the shape of a hit, then run both searches in parallel, fuse, and fetch the rows:

export type Hit = { chunkId: number; sourceId: number; title: string; url: string | null; kind: string; text: string };
// Hybrid search: keywords (exact names, codes, rare words) and meaning (paraphrases), merged by rank.
export async function hybridSearch(env: Env, owner: string, question: string, limit = 10): Promise<Hit[]> {
const [byWords, byMeaning] = await Promise.all([keywordSearch(env.DB, owner, question), vectorSearch(env, owner, question)]);
const ids = rrf([byWords, byMeaning]).slice(0, limit);
return hydrate(env.DB, owner, ids);
}
// The chunks behind the ids, with their sources, in the same order. A vector whose chunk is gone is dropped, and so is
// anything that isn't this owner's (the owner check is repeated here, not left to the vector filter alone).
export async function hydrate(d1: D1Database, owner: string, ids: number[]): Promise<Hit[]> {
if (ids.length === 0) return [];
const { results } = await d1
.prepare(
`SELECT c.id AS chunkId, s.id AS sourceId, s.title, s.url, s.kind, c.text FROM chunks c JOIN sources s ON s.id = c.source_id
WHERE c.owner = ? AND c.id IN (SELECT value FROM json_each(?))`,
)
.bind(owner, JSON.stringify(ids))
.all<Hit>();
const byId = new Map(results.map((hit) => [hit.chunkId, hit]));
return ids.flatMap((id) => byId.get(id) ?? []);
}
TypeScriptUTF-8at e-05● lines 5–7: apps/agent/src/retrieval/search.ts
Tutor:

hydrate checks the owner again. The vector filter should already have done it, but a wall you can check twice, you check twice. It also drops vectors whose chunk is gone.

To try it, agent.ts gets GET /agents/klippy/<name>/search?q=, rate-limited, since each search costs an embedding. Add import { hybridSearch } from './retrieval/search'; under the other imports, then put the route at the top of onRequest:

async onRequest(request: Request) {
const url = new URL(request.url);
const action = url.pathname.split('/').pop();
// GET /agents/klippy/<name>/search?q=...: what hybrid search finds for a question (to try it, and for e-08).
if (request.method === 'GET' && action === 'search') {
const q = (url.searchParams.get('q') ?? '').trim();
if (!q || q.length > MAX_QUESTION) return Response.json({ error: `Send ?q=, up to ${MAX_QUESTION} characters` }, { status: 400 });
if (!(await underLimit(this.env, `search:${this.name}`))) return Response.json({ error: 'Too many requests. Wait a minute.' }, { status: 429 });
return Response.json({ hits: await hybridSearch(this.env, this.name, q) });
}
if (request.method !== 'POST' || action !== 'file') {
TypeScriptUTF-8at e-05● lines 5–9: apps/agent/src/agent.ts
Tutor:

The file check below it now uses action instead of working out the path again.

Tutor:

5. Run it

Restart wrangler dev and ask the route a question. We trim the answer with jq; without it, drop the | jq … part and read the whole JSON:

recorded output
curl -s -G -H "Authorization: Bearer $T" --data-urlencode "q=What does Pip like to eat?" http://localhost:8801/agents/klippy/me/search | jq -c '.hits[:3][] | {chunkId, title, starts: .text[0:60]}'
{"chunkId":11,"title":"pip.md","starts":"# Pip\n\nPip is my dog. She likes carrots and hates baths."}
{"chunkId":15,"title":"ui-brief.md","starts":"## Screens\n\n| Path | What it shows | Signed out |\n|---|---|-"}
{"chunkId":13,"title":"ui-brief.md","starts":"## Goal\n\nOne person, signed in with GitHub, chats with their"}
Tutor:

The Pip note wins: by meaning, and by the word "Pip". With q=carrots, one plain word, the keyword side finds it first too:

recorded output
curl -s -G -H "Authorization: Bearer $T" --data-urlencode "q=carrots" http://localhost:8801/agents/klippy/me/search | jq -c '.hits[:3][] | {chunkId, title, starts: .text[0:60]}'
{"chunkId":11,"title":"pip.md","starts":"# Pip\n\nPip is my dog. She likes carrots and hates baths."}
{"chunkId":21,"title":"ui-brief.md","starts":"## References (gathered with Firecrawl, 2026-11-03; pages ar"}
{"chunkId":15,"title":"ui-brief.md","starts":"## Screens\n\n| Path | What it shows | Signed out |\n|---|---|-"}
Tutor:

The other hits are filler: this Klippy has only two sources, and search always returns its top few. Deciding whether a passage really answers the question comes later, in lesson 7.

Tutor:

6. Prove it

Copy test/search.spec.ts and the updated test/approval.spec.ts. Five tests: RRF ranks an id found by both lists first, keyword search stems and keeps to one owner, a deleted chunk leaves the index, and the route merges both lists. Ours: 18 files, 83 tests passed.

quick check+20 neurons
Tutor:

Why merge the two result lists by rank instead of adding their scores?

    Pick one option

    Pick one, then Check.