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.
What you'll build
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.
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
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
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
You're done when
Go further
retrieve as a tool and search again for multi-hop questions, with a hard cap.Wrapping up
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.
retrieve() + the hybrid-wins example. Repo or gist.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.