← All sessions, cheat sheets & labs
Week 2 · Hands-on lab · after Session 4

Lab 2: Build a Hybrid-Search RAG Pipeline

Not "embed the query and stuff in top-k". Build the real thing: chunk, retrieve with dense AND keyword search, fuse, re-rank to a few, assemble to a token budget, and refuse to answer when nothing is relevant.

~2–3 hourstypescriptan embedding model + a small corpus

What you'll build

A retrieval pipeline you would trust in production.

A retrieve(query) that returns a small, ranked, grounded set of chunks plus a hard rule: if nothing scores well enough, say so instead of inventing. Pick any corpus you have, a product's docs, a set of past tickets, a book.

💡 In plain English

A good researcher does not photocopy the whole library and hand it over. They find the two paragraphs that answer the question, and if the library has nothing, they say "we do not have that", instead of making it up.

The shape

Chunk, retrieve two ways, rank, assemble.

flowchart LR
  Q["query"] --> D["dense retrieve"]
  Q --> S["BM25 retrieve"]
  D --> F(("fuse + dedup"))
  S --> F
  F --> R["re-rank, keep top 5"]
  R --> G{"top score
above floor?"} G -- yes --> A["assemble to budget"] G -- no --> N["say no answer"] classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef warn fill:#FEF3C7,stroke:#1F2937,color:#0E1726; class A ok; class N warn;

Starter code

Fuse two retrievers, then rank.

import OpenAI from "openai";
const client = new OpenAI();

// one-time: chunk the corpus into semantic units + metadata
const chunks = chunk(docs, { size: 400, overlap: 0.12 });   // keep source + date on each
const denseIndex = await embedIndex(chunks);   // text-embedding-3-small
const bm25Index  = keywordIndex(chunks);

async function retrieve(query, k = 40, keep = 5, floor = 0.35) {
  const dense = await denseIndex.search(query, k);   // semantic matches
  const sparse = bm25Index.search(query, k);         // exact terms: codes, names
  const fused = dedup([...dense, ...sparse]);         // union, drop duplicates
  const ranked = await rerank(query, fused);        // cross-encoder or LLM scorer
  const top = ranked.slice(0, keep).filter(c => c.score >= floor);
  if (top.length === 0) {
    return { context: [], grounded: false };   // never invent
  }
  const context = assemble(top, { budget: 3000 });   // best chunk LAST (near the question)
  return { context, grounded: true, cites: top.map(c => c.source) };
}

// embedIndex uses the OpenAI embeddings API for each chunk
async function embedIndex(chunks) {
  const res = await client.embeddings.create({
    model: "text-embedding-3-small",
    input: chunks.map(c => c.text),
  });
  return buildVectorIndex(chunks, res.data.map(d => d.embedding));   // TODO: your vector store
}

Do this, in order

The steps.

  1. 1
    Chunk your corpus into semantic unitsA section, a Q&A pair, a function. ~200-500 tokens with a little overlap. Attach source, date, and any permission tag.
  2. 2
    Build both indexesA dense (embedding) index and a keyword/BM25 index over the same chunks.
  3. 3
    Retrieve from both and fuseGrab ~40 from each, take the union, dedup. Recall first, precision later.
  4. 4
    Re-rank and keep the top fewScore each candidate against the query (a cross-encoder, or an LLM judge). Keep the top 3-5 above a score floor.
  5. 5
    Assemble to a token budgetOrder best-last so the strongest chunk sits nearest the question. Trim to your budget. Carry the citations.
  6. 6
    Add the no-context fallbackIf nothing clears the floor, return "no grounded answer" so the model refuses instead of inventing.
  7. 7
    Prove hybrid beats vectors aloneFind one query with an exact token (a code, a name) where pure vector search misses and hybrid nails it.

You're done when

Acceptance criteria.

Go further

Stretch goals.

  • Add a permission filter before retrieval so a user can never retrieve a chunk they are not allowed to see. This is the Week 4 security lesson arriving early.
  • Add query rewriting: expand or clarify the query before retrieval.
  • Make it agentic: let the model call retrieve as a tool and search again for multi-hop questions, with a hard cap.
  • Log a grounding metric (did the answer cite retrieved text?) that feeds your Week 5 eval harness.

Wrapping up

Ship the pipeline + the win.

Hand in your retrieve() and the one query where hybrid beats vectors alone. This becomes your Project 1 (Context & Retrieval Design) and slots into the capstone.

Deliverable
Working retrieve() + the hybrid-wins example. Repo or gist.
feeds Project 1
Stuck?
Start with dense-only + a score floor. Add BM25 and the re-ranker once the skeleton returns results.
rewatch S3
For the instructorHow to run this labclick to open ▾

The two lessons that stick are: re-ranking is the highest-leverage step people skip, and the no-context fallback is what separates a grounded system from a confident liar. Make everyone demonstrate the fallback firing, that is the moment RAG stops being a demo.

The hybrid-wins example is the aha. Ask each student to find a query with an exact token, an error code, a product SKU, a person's name, where vectors return plausible neighbours and keywords return the exact hit. Sharing these across the room makes the case better than any benchmark.