Where we left off

Both halves of this course's hybrid pipeline now have a real-library replacement, chromadb for dense (same as naive_rag's Advanced tier) and rank_bm25 for sparse (Lesson 20). This lesson wires both in at once, fused with the exact same RRF function from Lesson 8, unchanged.

The code, piece by piece

chroma_client = chromadb.Client()
collection = chroma_client.create_collection(name="notes")
collection.add(ids=names, documents=texts, embeddings=vectors)

Same pattern as naive_rag Lesson 20: an ephemeral, in-memory collection standing in for this course's own list-based dense store.

dense_results = collection.query(query_embeddings=[query_vector], n_results=len(names))
dense_ranking = dense_results["ids"][0]

n_results=len(names) asks chromadb for every document, ranked, not just a top-k. RRF needs a full ranking from each side to fuse correctly, the same reason Lessons 6-18's hand-rolled dense and sparse functions always returned complete rankings, not pre-truncated ones.

fused = reciprocal_rank_fusion([dense_ranking, sparse_ranking])

Lesson 8's function, character-for-character unchanged. RRF never cared whether a ranking came from a hand-rolled list or a real library, it only ever needed a list of names in order, which is exactly what both chromadb and rank_bm25 hand back.

Checkpoint

  • Both retrievers are now real libraries, chromadb (dense) and rank_bm25 (sparse), the fusion logic connecting them (Lesson 8's RRF) needed zero changes.
  • Requesting a full ranking (n_results=len(names)), not a pre-truncated top-k, from each retriever is what makes fusion possible at all.
  • This is the shape a production hybrid retriever actually takes: two independent, well-tested libraries, one small fusion function gluing their outputs together.

If anything here still feels unclear, ask before moving to Lesson 22.