Retrieval (RAG)
Chunk the project's Markdown, search it by words and by meaning, rerank the candidates, and measure each stage.
What
packages/rag finds the text that answers a question. There is no RAG framework: chunking,
ingest, the three searches, the fusion, the reranking and the evals are written by hand, so
each step can be read, tested and explained.
bun run db:up && bun run db:migrate
bun run rag:ingest # docs/, the design system's MDX, the README, corpus/
infisical run -- bun run rag:embed # vectors for the chunks (about $0.001)
infisical run -- bun run rag:search "how do I restore a backup"
infisical run -- bun run rag:eval # all four searches, side by sideThe searches, from simple to best (--mode):
| Mode | How it finds | Needs the gateway |
|---|---|---|
lexical | Words: Postgres full-text search, the question as an OR of its words | no |
vector | Meaning: the chunk whose embedding is closest to the question's | yes |
hybrid | Both lists, merged by reciprocal rank fusion | yes |
rerank | The hybrid's best 30, read again with the question by a reranking model | yes |
| Piece | File | Job |
|---|---|---|
| Chunker | chunk.ts | Markdown to chunks, cut at headings, each with its heading path |
| Ingest | ingest.ts | Store documents and chunks for a tenant; skip what did not change |
| Embeddings | embed.ts | A vector per chunk and per model; only the missing ones are made |
| Search | search.ts | searchLexical, searchVector, searchHybrid, searchReranked |
| Fusion, rerank | fuse.ts, rerank.ts | Merge two ranked lists; sort candidates with a reranking model |
| Rule lookup | criterion.ts | A WCAG criterion by number, and axe's tags to criteria (guide 16) |
| Evals | eval.ts, evals/vektos-docs.json | recall@k and MRR over the dataset's cases |
The tables are rag_sources, rag_chunks (with a tsvector column the database computes) and
rag_embeddings (one row per chunk and model, in a vector column with no fixed size,
because models differ). Every row has a tenant.
Why
- The chunk carries its place. Its heading (
Graders › Known gaps) says which guide and which part. The full-text search weights it above the body, and the embedding is made from heading plus text. - Cut at headings, not at a length. A fence, a table or a paragraph is never cut. A long list is cut between items. A tiny section joins the one before it.
- Ingest is idempotent on the function too. A document is stored with the hash of its text
and of how it is cut (
CHUNKER_VERSION, sizes). An unchanged document is skipped; a better chunker re-ingests everything. - One vector per chunk and model. Models give vectors of 1,024, 1,536 or 2,560 numbers, and a vector is only compared with its own model's. Keeping them apart made it cheap to compare four models on the same chunks.
- Both searches, then a reranker. Each search is good at something the other misses (see below). The reranker reads the question and each candidate together, which is slower and sharper, so it only sees the 30 candidates the hybrid found.
- Tenants are separate from the first table. Every query filters on
tenant, and a test checks that one tenant never sees another's chunks, text or vectors. - Models are arguments, and tests use a fake.
EmbedderandRerankerare functions. The real ones call the AI Gateway;fake.tshas a deterministic pair, so every test runs without network or money.
Try it
bun test packages/rag # chunker, fusion, rerank, evals, dataset check
bun run check:db # ingest, tenants, vectors and hybrid against a real database
infisical run -- bun run rag:eval
infisical run -- bun run rag:eval --kind keyword --show lexical
infisical run -- bun run rag:search "TS2769" --mode lexicalWhat the evals say
These numbers are the first measurement, on the project's own documents only: 48 cases over 339 chunks from 34 documents. Guide 16 adds two legal texts (772 chunks) and 24 more cases, and the pattern holds.
The cases are 40 questions written as a person would ask, and 8
keyword searches (TS2769, bench:recount) typed as they are. Embeddings are
openai/text-embedding-3-small, the reranker voyage/rerank-2.5-lite.
| Questions (40) | recall@1 | recall@3 | recall@5 | recall@10 | MRR |
|---|---|---|---|---|---|
lexical | 25% | 48% | 57% | 65% | 0.39 |
vector | 48% | 73% | 78% | 85% | 0.62 |
hybrid | 35% | 68% | 80% | 83% | 0.54 |
rerank | 65% | 83% | 90% | 93% | 0.76 |
| Keywords (8) | recall@1 | recall@3 | recall@5 | recall@10 | MRR |
|---|---|---|---|---|---|
lexical | 50% | 88% | 88% | 88% | 0.69 |
vector | 38% | 50% | 50% | 63% | 0.45 |
hybrid | 38% | 50% | 75% | 100% | 0.51 |
rerank | 50% | 88% | 100% | 100% | 0.72 |
Reading it (anecdote 10):
- Vectors win on sentences, words win on identifiers.
- The hybrid is worse than the better single search on both kinds. Its value is candidates (recall@10 of 100% on keywords), not order.
- The reranker is what puts the answer first, and it is best on both kinds.
Other things measured, all on the questions:
| MRR | |
|---|---|
| Embedding model, vector only: OpenAI small / Cohere embed-v5.0-fast / Voyage 4 lite / Qwen3 4B | 0.62 / 0.57 / 0.53 / 0.53 |
| Reranking model, after the hybrid: Voyage 2.5 lite / Cohere v5 pro / Cohere v5 fast | 0.76 / 0.75 / 0.73 |
| Hybrid with the lexical list weighted 1 / 0.5 / 0.25 | 0.54 / 0.55 / 0.57 |
| Rerank from the vector's top 30 / the hybrid's top 30 | 0.75 / 0.76 |
| Hybrid candidates given to the reranker: 10 / 20 / 30 | 0.71 / 0.74 / 0.76 |
The rerankers are within noise of each other, so the cheapest is the default. With 40 questions a gap of two or three points is not a finding.
Cost. Embedding the corpus costs about $0.0013 with OpenAI small, $0.0014 with Voyage or
Qwen and $0.0055 with Cohere (rag:embed prints it, from the gateway's price list). A search
costs a fraction of a cent: one embedding of the question plus a rerank of 30 chunks.
Extend it
- A new question. Add
{ id, question, path, contains, kind }toevals/vektos-docs.json. A test fails if no chunk ofpathcontains the text, which also tells you when a document changed under a question. Write it the way a person would ask, not the way the heading is worded. - Another embedding model.
rag:embed --embedding-model <gateway id>adds a vector per chunk for that model;rag:eval --embedding-model <id>measures it. Add its price toEMBEDDING_USD_PER_TOKENto see the cost. - Another reranker.
--rerank-model <gateway id>. - Another corpus. Pass folders to
collectFiles(base, roots)and a tenant toingest, thenembedMissing. The same functions serve a client's documents. - A document whose sections are units to cite (a law, a standard): put
min-words: 0in its frontmatter, so a short section is not merged into the one before it. Cases of the dataset can then name theheadingthe answer must be under. - A slice of the eval.
rag:eval --kind question|keyword|violationand--scope docs|corpusmeasure one kind of case or one corpus. - A change to the chunker. Raise
CHUNKER_VERSION, or ingest keeps the old chunks. Embeddings are made for the new chunks only (rag:embedafterrag:ingest).
Known gaps
- 48 cases is small, and the 8 keyword searches are a hint. A difference of one or two points is noise. The numbers show the shape (vectors for sentences, words for identifiers, reranking over both), not a ranking to the second decimal.
- The dataset is written by the author of the docs. It tests questions the author can think of, not the ones a stranger asks.
- The corpus overlaps itself. The journal restates what the guides say, so a question can have two good answers and the dataset names one. Several "misses" return the right text from the journal; a few more return the right document but another section of it.
- No index on the vectors. Search compares the question with every vector of the model: 339
of them, instantly. At tens of thousands, an
hnswindex per model is needed, and it needs a fixed vector size. - The reranker's cost is not measured. The gateway charges a small amount per call; the CLI prints what embeddings cost but not what reranking did.
- The numbers on this page are on the docs alone. The corpus of guide 16 changes them a little; the table there is the current one.
- Re-embedding after a document changes is a separate command (
rag:embed), not part ofrag:ingest, because it costs money.
Dashboard
A Next.js app, built with the design system, that shows how contexts compare, every run, and the page an agent wrote, from the Postgres history.
Regulatory corpus
The two legal texts that sit next to the project's docs for retrieval, how they are converted, and how a violation is linked to its rule.