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.
Verified agents 0.33.0wrangler 4.157.0
Start from e-04 on GitHub. Starting here, or behind? Get the code at that tag:
git clone https://github.com/ryanyogan/klippy cd klippy git checkout e-04
1. Keyword search in D1
D1 is SQLite, and SQLite has FTS5, a full-text index. Add packages/db/migrations/0004_chunks_fts.sql:
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.
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):
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:
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:
k = 60 comes from the original paper. It needs no tuning, which is why it's the usual default.
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:
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:
The file check below it now uses action instead of working out the path again.
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:
The Pip note wins: by meaning, and by the word "Pip". With q=carrots, one plain word, the keyword side finds it first too:
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.
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.
Your code matches tag e-05 (changes from e-04).