Projects/RAG Customer Support Agent/Part 3
Bag-of-Words Vectors and Cosine Retrieve
Build a vocabulary, vectorize chunks and queries as lists of floats, and retrieve top-k by cosine similarity without NumPy.
Retrieval is geometry. Each chunk becomes a vector. The query becomes a vector in the same space. Cosine similarity is the cosine of the angle: 1 means parallel, 0 orthogonal, negative if you use raw counts with centered vectors (you will use non-negative counts, so scores stay in [0, 1]).
You will not call OpenAI embeddings. You will use bag-of-words: a fixed vocabulary from the handbook (plus a few query words at index time? No — freeze vocab from chunks only; unknown query tokens are ignored). That is realistic: out-of-vocab words should not crash you.
Vocabulary
Tokenize: lowercase, [a-z0-9]+, drop tokens of length 1. Optionally drop a tiny stoplist: the a an of to in is. Build token → index from chunk texts only, sorted for stability.
Vector
vec[i] = count of vocab[i] in the document (term frequency). Binary (0/1) also works for tiny docs. TF is fine. Do not implement full BM25 unless you finish early; cosine TF is the lesson.
Cosine
` dot(a,b) / (||a|| ||b||) `
If either norm is 0, similarity is 0. Never divide by zero. Empty query (all stopwords) retrieves nothing useful — refuse later.
Top-k
Score all chunks (N is tiny). Sort by score descending, then by id for ties. Return k=2 or 3. Always return the score to the generator. Hidden scores cause overconfident answers.
Run to execute this in your browser. Nothing is sent to a server.
Step-by-step: read the scores
Run the four queries. You should see:
- Refund question → chunk-01 high, maybe chunk-05 (mug refunds) as rival. That rivalry is the point. Top-1 must be API refunds for the generic "refunds" query if "5-7" and "business" match; "mug refunds" should flip to chunk-05.
- Rate limit → chunk-02.
- Equine insurance → low scores (near 0). Remember the max score; part 4 uses tau.
- If equine accidentally matches "incidents" because of a shared rare token, your stoplist or handbook needs tightening — or tau saves you.
Why not Euclidean distance
Cosine ignores vector length. A long chunk with many tokens would look far in L2 just for being long. Cosine is the default for text. (Production embeddings are still compared with cosine or dot product after normalization.)
k and tau are different knobs
k is how many chunks the generator may see. tau is whether you generate at all (on the best score, usually). You can retrieve k=3 but still refuse if scores[0] < tau. Do not pass low-score chunks in as if they were facts.
Watch out:Printing only top-1 hides near-ties. Always log top-3 scores in traces.
Exercise
Implement retrieve_with_threshold(query, k, tau) that returns [] if the best score is < tau. Print equine vs refunds at tau=0.2 and tau=0.5. Pick a tau that lets refunds through and equine not. Write the number down for part 4.
Check your understanding