Lab 03 — Embed & the Full Two-Stage RAG Pipeline
Phase 31 · Lab 03 · Phase README · Warmup
The problem
Cohere sells its platform as three composable APIs — Embed for vectors, Rerank for
precision, Chat with documents for grounded answers — and every serious deployment wires
them into one pipeline. This lab builds that pipeline end to end, plus the two Embed-specific
ideas people consistently get wrong:
input_typeis not decoration. Embed v3 embeds a query differently from a document — same text, different vector — because short keyword-ish queries and long redundant documents come from different distributions, and the model aligns them into one retrieval geometry. Index your corpus assearch_document, embed queries assearch_query; use the wrong one and nothing crashes — retrieval quality just silently sags. The miniature makes the asymmetry mechanical: document vectors are hashed token counts (frequency is document signal), query vectors are hashed content-term presence (a query term votes once) — different encoders, one shared hash space, so cross-type cosine is meaningful.- Compressed embeddings are a storage/recall tradeoff you can measure. int8 (4× smaller, near-identical cosine), binary (32× smaller, coarser), and Matryoshka-style truncation (shorter prefixes of the same vector) all keep the easy retrievals and start losing the hard ones — the lab shows exactly where.
Then the pipeline: retrieve wide (cosine top-k over the index), rerank narrow (Lab 01's cross-encoder idea), generate grounded (Lab 02's citations idea), and prove the two-stage ordering beats retrieve-only on a planted corpus.
What you build
| Piece | What it does | The lesson |
|---|---|---|
embed(texts, input_type) | four genuinely different deterministic encodings over one md5 hash space | search_query ≠ search_document for the same text — provably |
quantize_int8 / quantize_binary / truncate_matryoshka | compressed vectors | int8 ≈ free; binary and aggressive truncation cost recall — measured, not asserted |
VectorIndex | brute-force cosine kNN, deterministic ties | the first stage: wide and cheap |
relevance_score / rerank | compact cross-encoder (coverage + phrase), Cohere response shape | the second stage: the interaction signal cosine cannot see |
grounded_generate | injected generator → supported claims only → citations with exact offsets | the third stage: the answer is auditable, the hallucination is dropped |
TwoStageRag | add → first_stage → answer; carries both stages' doc ids for observability | the whole Cohere stack behind one object |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference implementation + worked example (python solution.py) |
test_lab.py | 27 tests: input_type semantics, index, two-stage promotion, end-to-end citations, compression, determinism |
requirements.txt | pytest only |
Run it
pytest test_lab.py -v # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v # the reference (green)
python solution.py # worked example
Success criteria
-
The same text embeds to different vectors under
search_queryvssearch_document(all fourinput_types pairwise distinct), and an unknowninput_typeraises — the typo that silently degrades real pipelines fails loudly here. -
First-stage cosine retrieval surfaces both relevant docs, scores descending, deterministic
tie-breaks; duplicate ids and bad
kare rejected. -
The planted two-stage win: first-stage top-1 is the term-stuffed
kb_policy; after rerank, top-1 is the verbatim how-tokb_howto. -
End to end,
answer()returns a grounded answer whose citations index the answer text exactly and cite the promoted document; the scripted hallucination never appears and the grounding score reflects it. - int8 and binary quantizations retrieve the same top-1 as float on the easy case; int8 cosine ≈ float cosine; 48-dim truncation keeps the easy top-1, 16-dim loses it.
-
Everything deterministic: same inputs → identical
RagResponse, twice. -
All 27 tests pass under both
labandsolution.
How this maps to the real stack
- Embed v3 / v4:
co.embed(texts=..., model="embed-v4.0" or "embed-english-v3.0", input_type="search_document" | "search_query" | "classification" | "clustering", embedding_types=["float", "int8", "binary", ...]). Theinput_typevalues here are Cohere's own enum, and the real models are trained so query- and document-mode embeddings align across the asymmetry — our two hand-built encoders over a shared hash space are the deterministic stand-in for that trained alignment. Embed v4 adds Matryoshka-style selectable output dimensions;truncate_matryoshkamirrors the usage pattern (short vectors for cheap shortlisting, full vectors for precision). - Compression is a real Cohere feature, not an add-on: Embed v3/v4 natively return int8 and binary embedding types, and Cohere's guidance is exactly this lab's finding — int8 costs almost nothing, binary buys ~32× storage at a recall cost you should measure on your corpus before committing an index format.
- The pipeline shape is the reference architecture: first-stage ANN retrieval (pgvector /
OpenSearch / a managed store) over
search_documentvectors, Cohere Rerank over the top 50–100, Cohere Chat withdocumentsover the top handful, citations returned to the user. This is the exact stack the Phase 05 hybrid retriever feeds and the Phase 11 eval harness scores (recall@k for stage one, faithfulness/citation accuracy for stage three). - Observability lives in the response:
RagResponsecarryingfirst_stage_idsandreranked_idsis the miniature of logging every stage's candidates — the only way to debug "why did the answer cite the wrong doc" is knowing what each stage saw and kept.
Limits of the miniature. The hashing embedder captures token overlap, not semantics — real Embed places paraphrases near each other, ours cannot ("booked" ≠ "book" here, deliberately: no stemming, so the lexical mechanism stays visible). Real Matryoshka truncation works because the model was trained to front-load information; truncating a hash-bucketed vector just drops random buckets, which is why the lab frames truncation as an honest tradeoff demo, not a production recipe. And brute-force kNN is O(N·dim); production uses HNSW/IVF behind the same search contract.
Extensions (your own machine)
- Swap
embedfor real Cohere Embed calls (or a localsentence-transformersbi-encoder with query/passage prefixes, e.g. thee5family's"query: "/"passage: "convention — the same asymmetry idea) behind the same signature and re-run the pipeline. - Implement packed binary embeddings (
int.from_bytes, popcount Hamming similarity) and measure the actual 32× memory ratio. - Add RRF fusion of BM25 + dense as the first stage (Phase 05) and show retrieve→rerank on top of hybrid first-stage beats both.
- Evaluate properly: build 20 labeled queries over your own docs and report recall@5 for first-stage vs two-stage, and citation coverage for the end-to-end answers.
Interview / resume signal
"Built Cohere's full RAG stack as a deterministic miniature: an asymmetric embedder where input_type genuinely changes the encoding (search_query vs search_document), int8/binary/ Matryoshka-compressed vectors with measured recall tradeoffs, cosine first-stage retrieval, a cross-encoder rerank stage that provably corrects the first stage's top-1, and grounded generation that emits Cohere-shaped citations and drops unsupported claims — with per-stage candidate observability in the response."