Lexical, dense and hybrid search
- Lesson 8
- 55 min
- Module 3: Build and compare retrieval
Lesson 8 of 18 · RAG: Building AI That Uses Your DataLexical, dense and hybrid search
1. Define the knowledge problem
2. Ingest documents reproducibly
3. Build and compare retrieval
4. Construct grounded answers
5. Evaluate and debug the pipeline
6. Maintain and protect the knowledge base
What you will be able to do
Choose a search method for Larkfield’s assistant with evidence, not with a slogan. You compare keyword, vector and hybrid search on real questions. Then you make sure that the assistant sees only the policy version in force, and only the documents its users may read.
Before you startWhat to know and to have ready
What you need to know
- Embeddings, cosine similarity and the model record in the index, from Embeddings for search.
- BM25 and hit@5, from A keyword-search baseline.
- Versions, effective dates and access labels on every chunk, from Preserve metadata.
What you need on your computer
- The project from Embeddings for search, with
.venvactive:dense.pyandembed.pyexist, andpython -m policy_assistant ingestprintedembeddings intfloat/multilingual-e5-small. - If your project is broken, copy the snapshot
end-of-m02and do the steps of that lesson’s Build it again.
No account and no key. Tested with Python 3.12.13 on macOS (Apple silicon). The Windows commands are not tested.
A catalogue and a librarian
Section titled “A catalogue and a librarian”A good library has two ways to find a book. The catalogue finds an exact call number, such as QA76.73, in a second. The librarian finds a book from a vague description, but she does not remember every call number. You use the catalogue when you have the number, and the librarian when you only have an idea. A clever reader asks both and compares the two lists.
The problem: each method misses something
Section titled “The problem: each method misses something”Tomás has seen the last lesson, and he is sure: “Vector search understands meaning. Keywords are old. Let’s use vectors for everything.” Grace tries one of her questions, a code that agents type as it is: “What is LB-1?” The answer is in the hedge trimmer guide: “put a few drops of blade oil LB-1 along the blade”. Here is vector search, with the project’s code from this lesson:
python -m policy_assistant search "What is LB-1?" --method dense1. 0.7836 [8021b57fe6d3] privacy-policy v3 | Who we share it with | public Who we share it with We share your information with couriers, our payment processor and our help-de…2. 0.7773 [13f00fce8eaa] privacy-policy v3 | Our help-desk assistant | public Our help-desk assistant Our help-desk staff use an assistant that drafts answers from Larkfield's p…3. 0.7738 [6e0e1d1c7e4f] privacy-policy v3 | Contact | public Contact Write to our privacy office at privacy@larkfield.example.4. 0.7616 [f78646d93435] policy-governance v2 | Access labels | public Access labels - public: The document may be shown or quoted to customers. - staff: Only for help-de…5. 0.7609 [a31b42d3e40d] damaged-or-wrong-items v2 | Wrong item | public Wrong item We send a free return label. We ship the right item as soon as the courier scans the ret…dense, top 5, as of 2026-10-09, audience staffThree privacy chunks and nothing about blade oil. The hedge trimmer chunk is not even in the top 20: it comes 34th. “LB-1” means nothing to e5, so the question becomes “What is …?”, a vague question. Vague questions land on chunks that are close to everything, such as these short privacy sections. Keyword search, with the same question, puts the hedge trimmer chunk first, because “LB-1” is a rare word that only that chunk has.
So keywords lose on “sprinkler” and on French, and vectors lose on “LB-1”. Neither is “always better”. The only way to choose is to look at real questions of each kind.
Predict, then compare
Section titled “Predict, then compare”The explorer shows the top 5 of three methods for four of Grace’s questions. Keyword search is BM25, as in Module 1. Vector search is e5, as in the last lesson. Hybrid search combines the two; the next section explains how. A passage with the “Relevant” badge is one that Grace marked as holding the answer.
Explore
Keyword, vector and hybrid search on four real questions
Predict first: For each question, which method has the relevant passage in its top 5? Guess first for the identifier only ('What is LB-1?') and the French question.
- Keyword: first relevant
- rank 1
- Vector: first relevant
- rank 1
- Hybrid: first relevant
- rank 1
Q38: An exact product code in a sentence. All three methods have a relevant passage in the top 5.
Question Which pump kit fits the PW-2200?
Relevant passages: product-care-pw2200 › Parts and part numbers; warranty-claims-procedure › Pressure washer pumps. Date filter: 2026-10-09.
Keyword search (BM25)
- product-care-pw2200Parts and part numbersPart | Part number | Notes Pump kit | PK-2200-07 | fits the PW-2200 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…Relevant
- product-care-pw2400Parts and part numbersPart | Part number | Notes Pump kit | PK-2400-03 | fits the PW-2400 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…
- warranty-claims-procedure staffPressure washer pumps- If the pump of a PW-2200 fails within 3 years, send the pump kit PK-2200-07 instead of a new pressure washer. - For the PW-2400, send the pump kit…Relevant
- product-care-pw2200Winter storageDrain all water from the pump and hoses before temperatures fall below 0 °C. Store it indoors, above 0 °C. Frost damage is not covered by the warrant…
- product-care-pw2200Using it safelyNever run the pressure washer for more than 2 minutes with the trigger released: the pump overheats. Never point the spray at people, animals or elec…
Vector search (e5)
- product-care-pw2200Parts and part numbersPart | Part number | Notes Pump kit | PK-2200-07 | fits the PW-2200 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…Relevant
- warranty-claims-procedure staffPressure washer pumps- If the pump of a PW-2200 fails within 3 years, send the pump kit PK-2200-07 instead of a new pressure washer. - For the PW-2400, send the pump kit…Relevant
- product-care-pw2400Parts and part numbersPart | Part number | Notes Pump kit | PK-2400-03 | fits the PW-2400 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…
- product-care-pw2200Specifications- Maximum pressure: 2,200 PSI. - Water flow: 6.4 litres per minute. - Electric motor: 1,800 W. Plug into an outdoor outlet with a GFCI (ground-fault)…
- product-care-pw2200Troubleshooting- Pressure pulses up and down: clean the inlet filter and check that the water tap is fully open. - Motor hums but does not start: release the trigge…
Hybrid search (RRF)
- product-care-pw2200Parts and part numbersPart | Part number | Notes Pump kit | PK-2200-07 | fits the PW-2200 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…Relevant
- product-care-pw2400Parts and part numbersPart | Part number | Notes Pump kit | PK-2400-03 | fits the PW-2400 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…
- warranty-claims-procedure staffPressure washer pumps- If the pump of a PW-2200 fails within 3 years, send the pump kit PK-2200-07 instead of a new pressure washer. - For the PW-2400, send the pump kit…Relevant
- product-care-pw2200Winter storageDrain all water from the pump and hoses before temperatures fall below 0 °C. Store it indoors, above 0 °C. Frost damage is not covered by the warrant…
- product-care-pw2200Using it safelyNever run the pressure washer for more than 2 minutes with the trigger released: the pump overheats. Never point the spray at people, animals or elec…
Text description of the explorer
The explorer has one control: the question. Under it, a readout gives the rank of the first relevant passage for each method. Three columns show each method’s top 5, with the relevant passages marked. The four questions and their results:
Product code (Q38, “Which pump kit fits the PW-2200?”): the relevant passage is first for keyword, vector and hybrid search. A code inside a full sentence is easy for all three.
Paraphrase (Q34, “How long is the warranty on a garden sprinkler?”): keyword search has the warranty table at rank 16, out of the top 5. Its first result is the garden furniture warranty. Vector search has it at rank 2 and hybrid search at rank 3.
French (Q63, “Puis-je retourner une carte-cadeau?”): keyword search finds no relevant passage in its top 20, only French chunks about returns. Vector search has the gift card passage “Lost cards and returns” at rank 2, and hybrid at rank 5.
Identifier only (Q64, “What is LB-1?”): keyword search has the hedge trimmer passage at rank 1. Vector search does not have it in its top 20; its top 5 are three privacy sections, the access labels and “Wrong item”. Hybrid has it at rank 6, just outside the top 5.
Look at the identifier-only question again. Keyword search has the answer first, and vector search does not have it at all. Hybrid search has it at rank 6, out of the top 5. Combining the two lists did not keep the best of both. The section after next shows why.
Where keywords win, and where vectors win
Section titled “Where keywords win, and where vectors win”One question per kind is still a small sample. Here is hit@5 on Grace’s whole question set, by kind of question:
| Questions | How many | Keyword (BM25) | Vector (e5) | Hybrid |
|---|---|---|---|---|
| Tables (the answer is in a table row) | 8 | 0.625 | 1.000 | 0.875 |
| French | 8 | 0.625 | 1.000 | 0.750 |
| Exact codes | 11 | 1.000 | 0.909 | 0.909 |
| Versions (dates in the question) | 8 | 0.875 | 0.750 | 0.625 |
| All | 59 | 0.831 | 0.915 | 0.864 |
Lexical search, search by the exact words, wins where the words are rare and exact: product codes, part numbers, staff codes such as RMA-, and dates. Four questions in the set are only a code, for example “What is LB-1?” and “What is CV-310 for?”. BM25 ranks the right chunk first for all four. Vector search misses LB-1 and ranks the other three 3rd or 4th. Sometimes a code sits inside a full sentence, as in “Which pump kit fits the PW-2200?”. Then both methods find it, because the rest of the sentence carries the meaning.
Dense search, search by meaning, wins where the words differ. That is the case for paraphrases (“sprinkler” and “sprinklers”), for table rows with other words than the question, and for other languages. With BM25, the French questions reach 0.625; with e5, all of them find a relevant passage.
This answers Tomás. Vector search is better on average here, 0.915 against 0.831. But “on average” hides the questions where it fails completely, and those are the codes that agents copy from a box or a receipt. With 59 questions, a difference of one or two questions (0.017 to 0.034) proves nothing. A difference of 5 questions, as here, is a real signal on this set, and it may still not hold on another collection.
Hybrid search: let the two lists vote
Section titled “Hybrid search: let the two lists vote”If each method finds what the other misses, why not use both? Hybrid search runs keyword search and vector search, and combines the two ranked lists into one.
The scores cannot be added. BM25 scores go from 0 to about 30. The cosine similarities of e5 sit between 0.7 and 0.9. A sum would follow BM25 almost alone. So the usual way uses only the ranks. Each list gives each chunk a vote of 1 ÷ (60 + rank). Then the code adds the votes. This is reciprocal rank fusion (RRF): “reciprocal” because the vote is 1 divided by the rank. The constant 60 comes from the paper that proposed RRF, by Cormack, Clarke and Büttcher in 2009. It keeps the first place of one list from deciding everything.
A tiny example, with made-up lists:
| Chunk | Keyword rank | Vector rank | RRF score |
|---|---|---|---|
| A | 1 | 3 | 1/61 + 1/63 = 0.0323 |
| D | not found | 1 | 1/61 = 0.0164 |
| B | not found | 2 | 1/62 = 0.0161 |
| C | 2 | not found | 1/62 = 0.0161 |
A is in both lists, so it gets two votes and wins, although neither list had it alone at the top. That is the idea of hybrid search: a chunk that both methods like is probably relevant.
Now the real question “What is LB-1?”. BM25 ranks the hedge trimmer chunk first: 1/61 = 0.0164. Vector search does not have it in its top 20, so it gets no second vote. Four other chunks are in both lists, low in each. Their two small votes add up to more than one big vote:
Text description of the diagram
Four rows. Each row starts with one chunk and its rank in the two lists, and ends with its fused score and its rank in the hybrid list. The warranty table is 7th for BM25 and 11th for vector search: 1/67 + 1/71 = 0.0290, hybrid rank 1. The large-item delivery section is 4th for BM25 and 20th for vector search: 1/64 + 1/80 = 0.0281, hybrid rank 2. Two more chunks with two votes take ranks 3 and 4. The privacy section “Who we share it with” is 1st for vector search and not in the BM25 top 20: 1/61 = 0.0164, hybrid rank 5. The hedge trimmer passage with LB-1 is 1st for BM25 and not in the vector top 20: also 1/61 = 0.0164. The chunk ID breaks the tie, so it comes 6th and falls out of the top 5.
The honest result: hybrid is not better here
Section titled “The honest result: hybrid is not better here”Over the whole set, hybrid search scores below vector search alone: hit@5 0.864 against 0.915. The reason is the one in the picture. When one list is weak for a question, its random low ranks still vote, and they can pull a good result down. On the French questions, BM25’s list is weak, and hybrid drops from 1.000 to 0.750.
Hybrid does help in one place. On the four identifier-only questions, it moves the right chunk up. TG-24, HR-50 and CV-310 go from rank 3 or 4 to rank 1. LB-1 goes from rank 34 to rank 6. If Larkfield’s agents typed many bare codes, that could matter. With the hosted embed-small vectors, hybrid is slightly better than vectors alone (0.898 against 0.881). The same method helps with one embedding model and hurts with another.
So the lesson is not “hybrid is bad”. It is: measure on your own questions. Blog posts and benchmarks often say that hybrid search wins, and on their data it may. On Larkfield’s 59 questions, with e5, it does not. There is no method that is best everywhere. The course keeps hybrid search for one job: it collects the 20 candidates that the next lesson reranks. That combination, called rerank, is the course default, because it has the best ranking.
Filters before the top k
Section titled “Filters before the top k”Two questions must be settled before any ranking: which version of a document applies, and who may see it. The chunks carry the answers, from Preserve metadata: an effective date range, and an access label.
- The date filter keeps only the versions in force on a date:
effective_from <= date <= effective_to. Which date? Larkfield’spolicy-governancedocument says: for returns, the delivery date. Returns policy version 3 (30 days) is in force until 31 October 2026, and version 4 (45 days) from 1 November. - The access filter follows the audience. The help-desk assistant is for staff, so it may see every chunk. A public assistant, for customers, may only see chunks labelled
public. A chunk with no label counts asstaff: when the label is missing, the safe choice is to hide it.
The filters run before the top k is chosen, inside the search. If they ran after it, 3 of the 5 places could go to staff chunks, and the filter would then remove them. A public user would get only 2 results. Worse, a later step might forget to filter at all.
Text description of the diagram
Seven stages, from left to right. 1: the 185 chunks in the index. 2: the filters, first: only the version in force on the question’s date, and only the access labels that the audience may see. A dashed arrow leads away from this stage: other versions, and staff-only chunks for a public assistant, are removed here. 3: the candidates: the top 20 from BM25 and the top 20 from vector search, both over the filtered chunks. 4: hybrid search fuses the two lists with RRF and keeps 20. 5: reranking, the topic of the next lesson: a cross-encoder reads the question with each of the 20 chunks. 6: the top k, here 5 chunks: the selected evidence. 7: the evidence goes to the model, in Module 4.
Choose the top k
Section titled “Choose the top k”The top k is how many chunks the search returns. Larkfield uses k = 5. A larger k finds more relevant passages, but it also sends more text to the model, which costs tokens and can bury the answer. Here is recall@k for vector search: the share of all relevant passages that are in the top k.
| k | 1 | 3 | 5 | 10 |
|---|---|---|---|---|
| recall@k, vector search (e5) | 0.531 | 0.785 | 0.862 | 0.895 |
From 3 to 5, recall gains 0.077. From 5 to 10, it gains only 0.033, for twice the text. That is why k = 5 is a fair choice here. Module 4 sets a budget in tokens for the context, and Retrieval measures explains recall@k in full.
No vector database yet
Section titled “No vector database yet”Tomás has one more idea: “Real RAG systems use a vector database.” A vector index is a data structure that finds close vectors without comparing every one. It pays off with millions of chunks. Larkfield has 185. Comparing the question with all 185 vectors in NumPy takes about 0.09 ms on the test computer. Embedding the question takes 5.2 ms, so the search itself is not the slow part.
A vector database would add a service to install, update and back up, for no gain. Measure first. When the collection grows to many thousands of chunks, try an index such as sqlite-vec and measure again.
Build it: three methods and the filters
Section titled “Build it: three methods and the filters”The project gets three new files, hybrid.py, filters.py and search.py, and a new command-line file. Work in ~/projects/policy-assistant, with .venv active.
-
Make the file
policy_assistant/hybrid.py:policy_assistant/hybrid.py """Hybrid search: combine keyword and vector rankings with reciprocal rank fusion (RRF).The scores of BM25 and cosine similarity are on different scales and cannot be added. RRF uses onlythe ranks: an item gets 1 / (K + rank) from every list it appears in, and the sums are sorted.K = 60 (the value of the original paper, Cormack, Clarke and Buettcher, 2009) keeps one list's topitem from dominating. An item that both lists rank well comes first.Worked example (K = 60): chunk A is 1st by keywords and 3rd by vectors: 1/61 + 1/63 = 0.0323.Chunk B is 2nd by vectors only: 1/62 = 0.0161. A comes first."""from .lexical import HitK = 60def rrf(rankings: list[list[Hit]], k: int = 5, constant: int = K) -> list[Hit]:scores: dict[str, float] = {}for ranking in rankings:for hit in ranking:scores[hit.id] = scores.get(hit.id, 0.0) + 1.0 / (constant + hit.rank)order = sorted(scores.items(), key=lambda item: (-item[1], item[0]))return [Hit(i, round(s, 6), rank) for rank, (i, s) in enumerate(order[:k], 1)] -
Make the file
policy_assistant/filters.py:policy_assistant/filters.py """Metadata filters: which chunks may be retrieved for this question at all.Applied BEFORE the top k is chosen (and so before anything reaches the model):- as_of: only versions in force on that date (effective_from <= as_of, and as_of <= effective_towhen the document has an end date). Which date? The policy governance document says: for returns,the delivery date; otherwise the day of the question.- audience: "staff" (the help-desk assistant) may see every chunk; "public" (a customer-facingassistant) only chunks labelled public. A chunk without an access label counts as staff: when thelabel is missing, the safe choice is to hide it."""from dataclasses import dataclassAUDIENCES = ("staff", "public")@dataclass(frozen=True)class Filters:as_of: str | None = None # YYYY-MM-DD, or None for no date filteraudience: str = "staff"def __post_init__(self):if self.audience not in AUDIENCES:raise ValueError(f"audience must be one of {AUDIENCES}, not {self.audience!r}")def allows(self, chunk) -> bool:if self.audience == "public" and (getattr(chunk, "access", "") or "staff") != "public":return Falseif self.as_of:if chunk.effective_from and chunk.effective_from > self.as_of:return Falseif chunk.effective_to and self.as_of > chunk.effective_to:return Falsereturn TrueThe dates are strings in the form
YYYY-MM-DD, so comparing them as text gives the right order. -
Make the file
policy_assistant/search.py. It is the one entry point for every method:policy_assistant/search.py """One entry point for every search method, with the metadata filters applied first.bm25 keyword search (lexical.py)dense vector search (dense.py) with the embedding model that built the indexhybrid bm25 + dense, combined with reciprocal rank fusion (hybrid.py)rerank hybrid's top CANDIDATES, reread by the cross-encoder (rerank.py)"""from dataclasses import dataclassfrom .dense import search as dense_searchfrom .filters import Filtersfrom .hybrid import rrffrom .lexical import Hitfrom .metadata import Chunkfrom .store import IndexMismatch, StoreMETHODS = ("bm25", "dense", "hybrid", "rerank")CANDIDATES = 20 # how many results each first-stage method passes on to fusion and reranking@dataclass(frozen=True)class Result:chunk: Chunkscore: floatrank: intclass Retriever:def __init__(self, store: Store, embedder=None, reranker=None):self.store, self.embedder, self.reranker = store, embedder, rerankerself._chunks = {c.chunk_id: c for c in store.chunks()}def _allowed(self, filters: Filters):return lambda chunk_id: filters.allows(self._chunks[chunk_id])def _check_model(self) -> None:if self.embedder is None:raise ValueError("Dense search needs an embedding model.")meta = self.store.meta()if (meta.get("embedding_model"), meta.get("embedding_revision")) != (self.embedder.name, self.embedder.revision):raise IndexMismatch(f"The index was built with {meta.get('embedding_model') or 'no embedding model'} "f"({meta.get('embedding_revision', '')}), and the question would be embedded with "f"{self.embedder.name} ({self.embedder.revision}). Vectors of two models are not comparable.")def bm25(self, query: str, k: int, filters: Filters) -> list[Hit]:return self.store.keywords.search(query, k, self._allowed(filters))def dense(self, query: str, k: int, filters: Filters) -> list[Hit]:self._check_model()ids, matrix = self.store.vectors()return dense_search(self.embedder.embed_queries([query])[0], matrix, ids, k, self._allowed(filters))def hybrid(self, query: str, k: int, filters: Filters) -> list[Hit]:return rrf([self.bm25(query, CANDIDATES, filters), self.dense(query, CANDIDATES, filters)], k)def rerank(self, query: str, k: int, filters: Filters) -> list[Hit]:if self.reranker is None:raise ValueError("Reranking needs a cross-encoder (rerank.Reranker).")candidates = [self._chunks[h.id] for h in self.hybrid(query, CANDIDATES, filters)]return self.reranker.rerank(query, candidates, k)def search(self, query: str, method: str = "hybrid", k: int = 5, filters: Filters = Filters()) -> list[Result]:if method not in METHODS:raise ValueError(f"Unknown method {method!r}: use one of {', '.join(METHODS)}.")hits = getattr(self, method)(query, k, filters)return [Result(self._chunks[h.id], h.score, h.rank) for h in hits]Part Meaning _allowed(filters)Turns the filters into the allowedfunction thatdense.searchand the keyword index take. Each method filters before it cuts the list to k._check_model()Compares the model in the index’s metawith the model of the question. A different name or revision stops the search.CANDIDATES = 20Hybrid search takes the top 20 of each method, not the top 5. Then a chunk ranked 6th by one method can still get its vote. rerankThe method of the next lesson. Its file comes in the next step. -
Copy the new command-line file and the reranker from the snapshot. The command-line file changes in many small places, and the next lesson explains the reranker:
Terminal window cp ../nextia-learning-labs/C09/snapshots/end-of-m03/policy_assistant/__main__.py policy_assistant/cp ../nextia-learning-labs/C09/snapshots/end-of-m03/policy_assistant/rerank.py policy_assistant/Terminal window Copy-Item ..\nextia-learning-labs\C09\snapshots\end-of-m03\policy_assistant\__main__.py policy_assistant\Copy-Item ..\nextia-learning-labs\C09\snapshots\end-of-m03\policy_assistant\rerank.py policy_assistant\searchandevalnow take--method(bm25,dense,hybridorrerank),--as-ofand--audience. Without--as-of, the course’s date, 2026-10-09, is used, so that your results match this lesson. Use--as-of todayfor real use, and--as-of nonefor no date filter.evaluses each question’s own date. -
Copy the two new test files:
Terminal window cp ../nextia-learning-labs/C09/snapshots/end-of-m03/tests/test_retrieval_parts.py tests/cp ../nextia-learning-labs/C09/snapshots/end-of-m03/tests/test_search.py tests/Terminal window Copy-Item ..\nextia-learning-labs\C09\snapshots\end-of-m03\tests\test_retrieval_parts.py tests\Copy-Item ..\nextia-learning-labs\C09\snapshots\end-of-m03\tests\test_search.py tests\test_retrieval_parts.pychecks the tiny examples: the cosine of (3, 4) and (4, 3) is 0.96, and the RRF table above.test_search.pychecks the real counterexamples on the collection: “What is LB-1?”, the French gift card question and the sprinkler question. -
Run the tests:
Terminal window python -m pytestThe last line is
44 passed. The first run takes a few seconds longer, because the tests load e5.
Guided practice: compare the three methods
Section titled “Guided practice: compare the three methods”-
Search for LB-1 with each method. Change only
--method:Terminal window python -m policy_assistant search "What is LB-1?" --method bm25python -m policy_assistant search "What is LB-1?" --method densepython -m policy_assistant search "What is LB-1?" --method hybridCheck: with
bm25, the first result isproduct-care-hedge-trimmer-ht550 v1 | After each use. Withdenseandhybrid, it is not in the top 5. Thehybridlist starts withwarranty-policy v5 | Warranty periods by product c…at0.0290. -
Measure each method on the whole question set:
Terminal window python -m policy_assistant eval --method bm25python -m policy_assistant eval --method densepython -m policy_assistant eval --method hybridThe first lines of the outputs:
bm25, top 5, audience staff: hit@5 0.831 (59 questions with relevant passages)dense, top 5, audience staff: hit@5 0.915 (59 questions with relevant passages)hybrid, top 5, audience staff: hit@5 0.864 (59 questions with relevant passages)BM25 is now 0.831, not the 0.797 of Module 2, because
evaluses each question’s date. The wrong version of a document no longer takes a place in the top 5. The last line of each output lists the missed questions. Fordense, it ismissed: Q13, Q26, Q28, Q64, Q56. -
See the date filter choose a version. The question has no date, so you give the delivery date:
Terminal window python -m policy_assistant search "Can a customer return an unused item?" --method bm25 --as-of 2026-11-05The first result is version 4, with 45 days:
1. 11.6078 [963dae4229eb] returns-policy v4 | Return window | publicReturn window You can return an unused item within 45 days of delivery for a full refund of the ite…Run it again with
--as-of none. Now version 3 and version 4 both come back, with the same score, 11.6078. Keyword search cannot tell them apart: only the date can. -
See the access filter. Ask for the staff discount as a public assistant:
Terminal window python -m policy_assistant search "What is the staff discount?" --method bm25 --audience publicNo result comes from
staff-discount-and-perks. The first result ispromotions-and-discount-codes v2 | Using a discount code | public. Without--audience public, the first two results are the staff document’sRulesandStaff discount, labelledstaff. -
Try to search the e5 index with another model:
Terminal window python -m policy_assistant search "What is LB-1?" --method dense --embedder sentence-transformers/all-MiniLM-L6-v2The search stops before it compares any vector:
The index was built with intfloat/multilingual-e5-small (614241f622f53c4eeff9890bdc4f31cfecc418b3), and the question would be embedded with sentence-transformers/all-MiniLM-L6-v2 (main). Vectors of two models are not comparable.This command downloads
all-MiniLM-L6-v2(about 90 MB) the first time, before the check. You can skip it:test_search.pychecks the same refusal.
Your turn: three more questions
Section titled “Your turn: three more questions”Predict first, then run search with --method bm25 and --method dense for each question. For each, write which method has a relevant passage in its top 5, and why.
- “What is CV-310 for?” (relevant:
product-care-bbq-gr310, sectionParts) - “A customer has not bought anything from us for two years. What happened to their Rewards points?” (relevant:
loyalty-program, sectionExpiry) - “What is the HR-50?” (relevant:
supplier-aquaflow-hose-reels, sectionThe AquaFlow range)
Show the answer
- CV-310: BM25 ranks
product-care-bbq-gr310 | Partsfirst, and dense search 3rd. Both have it in the top 5, but the code-only question suits keywords better. Dense search putsprivacy-policy | Who we share it withfirst, as for LB-1: a short, general chunk that sits close to many vague questions. - Two years: BM25 ranks
loyalty-program | Expiry8th, so it misses. Dense search ranks it 2nd. The question says “has not bought anything for two years”. The policy says “Points expire after 18 months without any purchase”. It is the same idea in other words. - HR-50: BM25 ranks the AquaFlow range first, and dense search 3rd, again after the privacy chunk.
So the pattern of the explorer holds: codes favour keywords, and paraphrases favour vectors. On the code questions, dense search usually still finds the chunk in the top 5; on LB-1 it does not. That is why one failure in a set of 59 still matters. An agent who types “LB-1” gets nothing useful.
Common mistake
A public assistant without the access filter
Symptom: Omar wants to try the assistant on Larkfield’s website, for customers. Tomás runs the same search with no access filter: “It is the same documents, the answers will be fine.” The question “What is the staff discount?” returns two chunks of the staff procedure first. One gives the 20% discount, and one the rules (“Never share it with customers”). Over all 67 questions, BM25 without the access filter puts 59 staff-only chunks in the top 5 lists; hybrid puts 53, and rerank 70.
Cause: the access label is on every chunk, but nothing used it. Search finds the best text for the question, and for many questions the best text is a staff procedure.
Fix: pass the audience into the search, so that the filter runs before the top k: --audience public. With the filter, the count is 0 for every method. Test it in code too: test_search.py checks that a public search never returns a staff chunk. Permissions and isolation follows the labels through the whole assistant, including the answers and their citations.
For a real AI service: decide the audience of every search from who is asking, in code, not from the question. A filter after the search is not enough, and neither is a rule in the prompt. In both cases, the text has already reached the model.
Knowledge check
Section titled “Knowledge check”Check your understanding
Explain to Tomás, with one real example, why 'vector search is always better than keywords' is wrong.
Show the answer
On “What is LB-1?”, keyword search (BM25) ranks the hedge trimmer passage with “blade oil LB-1” first. Vector search with e5 does not have it in its top 20; its top 5 are privacy sections. “LB-1” is a rare, exact code that the embedding model gives no useful meaning, so the question becomes vague. BM25 finds it because a rare word that only one chunk has gets a high score. Vector search is better on average here, with hit@5 0.915 against 0.831, because it wins on paraphrases, tables and French. But “on average” is not “always”.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: with RRF and the constant 60, chunk X is 2nd in the keyword list and 2nd in the vector list. Chunk Y is 1st in the keyword list only. Which one comes first, and with what scores?
Show the answer
X gets 1/62 + 1/62 = 0.0323. Y gets 1/61 = 0.0164. X comes first, with about twice the score of Y. A chunk that both lists rank well beats a chunk that one list ranks best. That is the idea of hybrid search, and also its risk. If Y is the right answer and the vector list is weak, the fusion pulls Y down, as it did with LB-1.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Why must the date and access filters run before the top 5 is chosen, and not after?
Show the answer
If the filters run after the top 5, the places held by wrong versions or forbidden chunks are removed. The user then gets fewer than 5 results, and a relevant chunk ranked 6th never comes back. If a later step forgets to filter, the forbidden text even reaches the model. Filtering first means that every one of the 5 places goes to a chunk that is in force and allowed.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- Lexical search (BM25) wins on rare, exact words: codes, part numbers, dates. Dense search wins on paraphrases, tables and other languages. On Larkfield’s set: BM25 0.831, vector search 0.915.
- Hybrid search with reciprocal rank fusion adds 1 ÷ (60 + rank) from each list. Here it scores 0.864, below vector search alone: a weak list can pull a good result down, as with “What is LB-1?”. Measure on your own questions.
- The date filter keeps the version in force, and the access filter keeps what the audience may see. Both run before the top k (here 5) is chosen.
- With 185 chunks, comparing every vector takes 0.09 ms. No vector database is needed yet.
Next, reread the candidates with a second, slower model, and let a language model rewrite the question. Both help, and both have a price.