learn-rag-with-phoebe / Builder session 5 of 10
Learn RAG with Phoebe · Builder track · Session 5 of 10

Metadata and hybrid retrieval: when meaning is not enough

Pure semantic search is brilliant at "find me something about payment failures" and hopeless at "find me the SEV1 incidents from March". The first is about meaning; the second is about facts attached to the chunk - a severity, a date, a ticket id. This session gives Recall two tools it was missing: metadata where-filters that narrow retrieval by structured fields and time, and hybrid search that fuses dense semantic scores with sparse keyword/BM25 matching so exact tokens like INC-401 stop slipping through. Recall now works over Corpus B, a stream of dated, severity-tagged Jira incidents.

🟠 Builder track Practitioners · some Python Live playground included Session 5 of 10
0-3 · Recap 3-22 · Metadata + time 22-40 · Hybrid 40-45 · Q&A
Part 0

The two questions similarity cannot answer

Recall retrieves by meaning (b4). But real questions carry constraints that meaning ignores. "Which SEV1s happened in March?" is not asking what a chunk is about - it is asking for chunks whose severity field equals SEV1 and whose date falls in a range. And "what did INC-401 say?" needs an exact token match, not a fuzzy neighbour. Both break pure semantic search. The fixes are metadata filters and hybrid retrieval, and together they turn Recall from a topic finder into a query engine.

Live - presented in session Self-study - full depth after class ★ Try it now Sources covered at the end
★ What you build today A Chroma retriever with a where filter that narrows Corpus B by severity and date, hands-on time combining a semantic query with a metadata filter in the live playground, and a clear mental model of hybrid dense+sparse search and the alpha lever that balances them.
Part 1 · covers metadata where-filters + temporal queries

Filtering by metadata and time 9 min live

Every chunk in Corpus B carries structured metadata alongside its text: a severity (SEV1/SEV2/SEV3), a date (like 2026-03-14), a ticket id (INC-401). A metadata filter says "only consider chunks whose fields satisfy these conditions", and you apply it before or alongside the similarity search. The vector store still ranks by meaning - but only over the chunks that passed the filter.

all chunks INC-401 SEV1 03-14 INC-402 SEV3 03-14 INC-410 SEV2 03-22 INC-421 SEV1 04-18 INC-430 SEV3 05-06 INC-440 SEV1 06-01 where filter severity = SEV1 date $gte 2026-03-01 survivors, ranked by meaning #1 INC-401 SEV1 03-14 #2 INC-421 SEV1 04-18 #3 INC-440 SEV1 06-01 SEV2/SEV3 never entered the ranking The filter runs first (or alongside): similarity only ever ranks chunks that passed the structured conditions.
🔍 Click to zoom - a where-filter narrows the candidate set, then similarity ranks the survivors
LiveWhy "which SEV1s happened in March?" breaks pure semantic search3 min

Embed that question and cosine it against the incidents. The words "SEV1" and "March" barely move the vector - the model latches onto "happened" and "incident" and cheerfully returns SEV3 tickets from May that sound similar. Meaning search has no concept of "severity equals SEV1" or "date within March"; those are facts, not topics.

  • Severity is a category, not a meaning. SEV1 and SEV3 tickets can read almost identically ("the service failed for N minutes"). Only the metadata distinguishes them.
  • Time is a range, not a topic. "In March", "last quarter", "before the outage" are date comparisons. No embedding captures them reliably.
  • The fix is structured. Attach the fields at index time, then filter on them at query time. Meaning search handles "what kind of incident"; the filter handles "which ones count".
Self-studyChroma where-filters, including a date range3 min

Chroma takes a where dictionary on query(). Equality is the common case; the comparison operators let you express ranges - and $and combines conditions:

Python · Chroma query with a metadata where-filterimport chromadb client = chromadb.Client() collection = client.get_or_create_collection("recall_corpus_b") # ... incidents added in b3, each with metadata: # {"ticket": "INC-401", "severity": "SEV1", "date": "2026-03-14"} ... # only SEV1 incidents results = collection.query( query_texts=["payment failures at checkout"], n_results=3, where={"severity": "SEV1"}, ) # SEV1 incidents in March 2026 - combine conditions with $and, # and use $gte / $lte for the date range (dates stored as sortable strings) results = collection.query( query_texts=["payment failures at checkout"], n_results=3, where={ "$and": [ {"severity": {"$eq": "SEV1"}}, {"date": {"$gte": "2026-03-01"}}, {"date": {"$lte": "2026-03-31"}}, ] }, )

The full operator set is $eq $ne $gt $gte $lt $lte for comparisons, $in $nin for membership, $and $or for logic, and $contains $not_contains for the document text itself. Store dates as sortable strings (ISO YYYY-MM-DD) or numbers so $gte/$lte compare the way you expect.

Live★ Build-along: semantic query, then narrow with a filter6 min

Here is Recall over Corpus B - eight Jira incidents, each showing its date and severity chips. The metadata filter starts preset to severity=SEV1. Run a semantic query first with the filter cleared, then add it back and watch the result set narrow to just the SEV1s.

Honesty note - what this playground really does The ranking uses a simplified lexical embedding (term frequency + synonyms, offline), the same one from b1, and the filter is a plain field match. Real systems use neural embeddings and a database index for the where clause. But the two-part shape you are practising - similarity to rank, metadata to constrain - is exactly the production mechanic. Cosine ranking is real; the embedder is a toy.

Try "payment outage" with the filter empty, then with severity=SEV1: the SEV2/SEV3 tickets vanish from the results even when they scored well on meaning. That is the point - the filter overrides similarity for facts that similarity cannot see.

Part 2 · covers hybrid dense + sparse/BM25 retrieval

Hybrid retrieval: dense meaning plus sparse keywords 7 min live

Dense (embedding) search finds meaning but blurs exact tokens - it may rank INC-401 and INC-440 as near-equal because they read alike, and it can miss an error code entirely. Sparse search (keyword / BM25) is the opposite: it nails exact strings like "INC-401" or "503" but is blind to paraphrase. Hybrid runs both and fuses their scores, so you get meaning and precision at once.

query dense · embeddings captures meaning / paraphrase sparse · BM25 keywords captures exact tokens: INC-401, 503 fuse by alpha score = a·dense + (1-a)·sparse one ranking alpha = 1.0 pure dense · 0.5 even hybrid · 0.0 pure sparse Dense for "what it means", sparse for "the exact string". Alpha is the dial between them.
🔍 Click to zoom - hybrid fuses dense and sparse scores with a convex weight alpha
LiveWhy hybrid, and what the alpha lever does3 min

The two retrievers fail in opposite directions, which is exactly why fusing them wins:

  • Dense misses exact tokens. Ask for "INC-401" and an embedding may rank several similar-sounding tickets ahead of the one you named, because the id is just a few characters in a sea of meaning. Error codes, SKUs, ticket ids, function names - all weak spots for pure dense.
  • Sparse misses meaning. BM25 scores by keyword overlap, so "money back" never matches a passage titled "Refunds" - the classic failure from b1. It cannot see paraphrase.
  • Alpha fuses them. Hybrid combines the two scores with a convex weight alpha in [0, 1]: score = alpha · dense + (1 - alpha) · sparse. alpha=1.0 is pure dense, alpha=0.0 is pure sparse, alpha=0.5 weighs them evenly. You tune alpha toward sparse when exact tokens matter (incident ids, codes) and toward dense when questions are conversational.
Real world

The ticket id that vanished. A support team (anonymized) let engineers search runbooks by pasting an incident id. Pure semantic search kept surfacing thematically similar incidents and burying the exact one - "INC-401" ranked fourth behind three lookalikes. Turning on hybrid and nudging alpha toward sparse put the exact ticket back at the top without losing the paraphrase queries. One dial, both behaviours.

Self-studyTurning on hybrid in practice2 min

Hybrid is a first-class feature in most managed vector databases. In Pinecone you send both a dense vector and a sparse vector and set an alpha weight; the pattern generalizes across stores:

Python · hybrid query with an alpha weight (Pinecone-style)def scale(dense, sparse, alpha): # alpha in [0, 1]: 1.0 = pure dense, 0.0 = pure sparse if not 0 <= alpha <= 1: raise ValueError("alpha must be between 0 and 1") ds = [v * alpha for v in dense] ss = {"indices": sparse["indices"], "values": [v * (1 - alpha) for v in sparse["values"]]} return ds, ss dense_q, sparse_q = scale(dense_vec, sparse_vec, alpha=0.4) # lean toward keywords results = index.query( top_k=3, vector=dense_q, sparse_vector=sparse_q, include_metadata=True, )

The exact API differs by store, but the idea is universal: supply a dense score and a sparse score, and hand the engine a single alpha that says how much to trust each. Sweep alpha on your own eval set - there is no globally correct value.

Build-along · take it further

Find a query only a filter can answer ★ 10 min · the Corpus B playground

The sharpest way to feel why metadata matters is to find a question that similarity gets wrong and the filter gets right. Do this over the Corpus B playground above.

Run a semantic-only query. Clear the filter and search something like "which incidents were the most severe?". Read the top-3 and note whether SEV3 or SEV2 tickets sneak in despite the question being about severity.

Add the filter. Set the metadata filter to severity=SEV1 and run the same query. Confirm the non-SEV1 tickets drop out entirely, even ones that scored well on meaning.

Find the filter-only win. Craft a query where semantic search alone returns the wrong severity or wrong time period, but the filter fixes it. Write both the query and why meaning failed - "severity is a category, not a topic" is a good starting sentence.

Reflect on hybrid. The playground is dense-only, so try to name a Corpus B query where you would want sparse/BM25 too - a search for an exact ticket id like INC-410 is the obvious one. Note why dense alone would rank it poorly.

★ Recall's status after b5 Recall can now filter by structured facts - severity, date ranges, ticket ids - and you understand how hybrid search fuses dense meaning with sparse keyword precision through the alpha lever. It answers "which SEV1s in March?" instead of guessing. But even a filtered, hybrid top-k can put a mediocre chunk at #1. In b6 we add reranking: retrieve wide, then use a cross-encoder to reorder the candidates so the truly best chunk rises to the top.
Homework

Before session b6 ◐ 40 min total

Source material

Official sources covered

Taught from official vector-database docs. This page covers ~80% of their working content on metadata filtering and hybrid search - the hosted-index setup and billing details stay with the source.

Chroma · Metadata filtering (where)Part 1 · $eq $ne $gt $gte $lt $lte $in $nin $and $or $contains · query(where={...})
Pinecone · Hybrid search (dense + sparse)Part 2 · dense + sparse/BM25 fused by a convex weight alpha in [0,1]
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why does pure semantic search struggle with "which SEV1 incidents happened in March?"

SEV1 and a date range are metadata conditions. You filter on the fields (where + $gte/$lte) and let similarity rank only the survivors.

2 · In hybrid retrieval, what does an alpha of 1.0 mean?

score = alpha·dense + (1-alpha)·sparse. alpha=1.0 is pure dense, 0.0 is pure sparse, 0.5 is an even hybrid.

3 · Sparse/BM25 retrieval is especially good at...

Sparse/BM25 scores by exact keyword overlap - great for ids and codes, blind to paraphrase. Dense is the reverse; hybrid fuses both.

Builder session 5 cheat sheet · pin this

Metadata filterNarrow retrieval by structured fields before/with similarity. Meaning ranks the survivors.
When you need itCategory or time questions: "SEV1s in March". Similarity cannot see facts.
Chroma wherequery(query_texts=[q], n_results=k, where={...}). $eq $ne $gt $gte $lt $lte $in $nin $and $or.
Date rangesStore ISO strings; $gte + $lte inside $and. Sortable strings compare correctly.
Dense vs sparseDense = meaning/paraphrase. Sparse/BM25 = exact tokens (ids, codes). Opposite blind spots.
HybridFuse both scores: score = alpha·dense + (1-alpha)·sparse.
Alpha lever1.0 pure dense · 0.5 even · 0.0 pure sparse. Toward sparse for exact ids.
Tune, do not guessSweep alpha on your own eval set - no globally correct value.