BXUP Track progress & test yourself — free
Zero-to-Hero Spine · LLMs, RAG & Agents · ~11 min read
ZERO-TO-HERO SPINE · LLMS, RAG & AGENTS

RAG that survives production

The scene

Monday morning, Bhubaneswar. Ritika runs the document bot at a housing finance company. It began as one HR file, like your 3.5 build. Then Admin, IT, and Sales pushed forty more documents into it. Last week an employee asked about maternity leave and got the Sales travel advance rules, quoted with full confidence. Another employee was told a notice period from a policy retired in March. The bot never crashed. It kept answering, wrongly, from the wrong papers. Ritika's repair list is this module.

Why this matters to you
  • Every RAG demo meets the same wall: more documents, more departments, more edits. This module is how you climb it.
  • Wrong-department answers, stale quotes, and slow replies are the top three reasons Indian firms shelve RAG pilots.
  • An interviewer who asks "how does this work with 4,000 chunks" is testing this module, not 3.5.
The idea

Your 3.5 pipeline worked because everything was small. One document, fourteen chunks, one honest floor, one user. Production changes the numbers, not the ideas. Many documents from many teams, edits every week, questions in more than one language. Each of the five stages needs an upgrade to survive that growth.

Chunking is the first upgrade. The wrong way cuts every 300 characters and hopes: rules get split mid-sentence, and no chunk knows where it came from. The right way cuts at section boundaries, so each rule stays whole. It adds chunk overlap — repeating the last sentence of one chunk at the start of the next — so meaning that spans a cut is never lost. And it attaches a metadata dict to every chunk: document name, department, section, version, date.

Fixed-size slicing versus structure-aware chunks with metadata
Fixed-size slicing versus structure-aware chunks with metadata

Storage is the second upgrade. In 3.5 your index was a NumPy array in memory: restart Python and you re-embed everything, and there is no way to update one document. A vector store — a database built to hold vectors along with ids, text, and metadata — fixes both. We use chromadb because it installs with pip, runs offline, and saves to a folder on disk. Its key verb is upsert — a write that adds a new id or replaces an existing one, curing the stale index from WR-036.

Search is the third upgrade, and it has two halves. The first half is metadata filtering — telling the store to search only chunks whose labels match, such as department or language, before similarity runs. One big collection with no labels lets an HR question retrieve a Sales chunk; the same collection with filters cannot make that mistake. The second half covers what embeddings miss: exact strings like form codes and policy numbers. Hybrid retrieval — running a keyword search and a semantic search together and merging the two lists — lets each side rescue the other.

One more polish step exists above retrieval. Reranking — a second, slower scoring pass that re-orders a retrieved shortlist by relevance — lets you fetch ten candidates cheaply and send only the best three to the model. At laptop scale you will see why it exists rather than run a heavy reranker. The last upgrade is discipline about cost and speed: retrieve less, keep chunks short, cache repeated questions, and measure each stage before tuning any of them.

The production RAG pipeline: ingest with upserts, query with filters
The production RAG pipeline: ingest with upserts, query with filters
Watch me do it

We upgrade 3.5's rag.py into rag2.py on the same Ollama setup — nomic-embed-text to embed, llama3.2:1b to generate. Install the store first: pip install chromadb==1.3.* inside your ai-lab environment (one-time download; everything after runs offline). The pin is deliberate: the counts and answers printed in this module were produced on this version, and chromadb's defaults shift between releases. Prepare two source files. Edit your hr-policy.txt so every topic starts with a heading line like ## Casual leave, and make sure one paragraph mentions a reimbursement form by code, such as "submit Form TR-4 within 30 days of travel". Then write it-policy.txt — eight to ten paragraphs of an imaginary IT policy (passwords, laptops, VPN, device requests for travel) under ## headings.

Step 1 — structure-aware chunking with overlap and metadata. Cut at headings, merge small paragraphs inside a section, and carry one sentence across each cut:

import re, requests, chromadb

def chunk_file(path, dept):
    text = open(path).read()
    out = []
    for sec in re.split(r"^## ", text, flags=re.M)[1:]:
        title, body = sec.split("\n", 1)
        paras = [p.strip() for p in body.split("\n\n") if p.strip()]
        buf, tail = "", ""
        for p in paras + [""]:                    # "" flushes the leftover
            if buf and (len(buf) >= 300 or p == ""):
                out.append({"text": (tail + " " + buf).strip(),
                            "meta": {"doc": path, "department": dept,
                                     "section": title.strip(), "version": 1}})
                tail = buf.rsplit(". ", 1)[-1]    # overlap: last sentence
                buf = ""
            buf = (buf + "\n" + p).strip()
    return out

hr = chunk_file("hr-policy.txt", "hr")
it = chunk_file("it-policy.txt", "it")
print(len(hr), "hr chunks,", len(it), "it chunks")
print(hr[7]["meta"])

Output: 16 hr chunks, 9 it chunks then {'doc': 'hr-policy.txt', 'department': 'hr', 'section': 'Travel reimbursement', 'version': 1} (your counts will differ). Two things changed from 3.5. A chunk can no longer straddle two sections, and the tail variable is the chunk overlap — the closing sentence of one chunk reappears at the top of the next, so a rule that spans the cut survives in at least one piece. The metadata dict travels with the text from here on; nothing downstream can filter on a label that was never attached.

Step 2 — a real vector store with upsert. Same embed() as 3.5, but the vectors now live in chromadb on disk, under stable ids:

def embed(texts):
    r = requests.post("http://localhost:11434/api/embed",
                      json={"model": "nomic-embed-text", "input": texts},
                      timeout=120)
    r.raise_for_status()
    return r.json()["embeddings"]

client = chromadb.PersistentClient(path="ragdb")
col = client.get_or_create_collection("policies",
                                      metadata={"hnsw:space": "cosine"})

def index_doc(path, dept):
    chunks = chunk_file(path, dept)
    col.upsert(ids=[f"{path}#{i}" for i in range(len(chunks))],
               embeddings=embed([c["text"] for c in chunks]),
               documents=[c["text"] for c in chunks],
               metadatas=[c["meta"] for c in chunks])
    return len(chunks)

print(index_doc("hr-policy.txt", "hr"), index_doc("it-policy.txt", "it"))
print("in store:", col.count())

Output: 16 9 then in store: 25. The id hr-policy.txt#7 is deliberate: run index_doc again and upsert overwrites those same ids instead of duplicating them — run it twice and count() stays 25. And because PersistentClient writes to the ragdb folder, you can close Python, reopen it, and query without re-embedding anything. The 3.5 pattern of "restart means rebuild" is gone.

Step 3 — retrieval with a metadata filter. query() does the cosine search 3.4 taught you, but where narrows it first. Watch the same question with and without the filter:

def retrieve(question, k=3, where=None):
    res = col.query(query_embeddings=embed([question]),
                    n_results=k, where=where)
    hits = []
    for cid, dist, doc, meta in zip(res["ids"][0], res["distances"][0],
                                    res["documents"][0], res["metadatas"][0]):
        sim = 1 - dist                     # cosine distance -> similarity
        print(f"  log: {cid:<18s} sim={sim:.2f} "
              f"[{meta['department']}/{meta['section']}]")
        hits.append((cid, sim, doc))
    return hits

retrieve("How do I claim travel allowance?")
print("--- filtered ---")
retrieve("How do I claim travel allowance?", where={"department": "hr"})

Output:

  log: it-policy.txt#7    sim=0.61 [it/Device requests for travel]
  log: hr-policy.txt#11   sim=0.58 [hr/Travel reimbursement]
  log: it-policy.txt#6    sim=0.49 [it/Device requests for travel]
--- filtered ---
  log: hr-policy.txt#11   sim=0.58 [hr/Travel reimbursement]
  log: hr-policy.txt#12   sim=0.44 [hr/Travel reimbursement]
  log: hr-policy.txt#0    sim=0.31 [hr/Casual leave]

Read the unfiltered log closely: the IT policy's travel-device section beat the HR travel section, because both discuss "travel" and "requests" and embeddings only measure closeness of meaning. Send that top-3 to the model and it will quote laptop rules to someone asking about allowance — with a citation that checks out against the wrong document. That is WR-037 in one log. The filter removes the failure class instead of hoping similarity gets it right; keep ask() from 3.5 unchanged except that it calls this retrieve() and passes where through, floor and refusal line intact.

Step 4 — why hybrid: one miss from each side. Semantic search and keyword search fail on different questions. Show one miss each:

# A: no word here matches the sick-leave chunk -- meaning does
retrieve("My daughter has a fever, can I stay home?",
         where={"department": "hr"})

# B: an exact code -- meaning is no help
retrieve("Where do I submit Form TR-4?", where={"department": "hr"})
kw = col.get(where_document={"$contains": "TR-4"})
print("keyword pass found:", kw["ids"])

Output for A: the sick-leave chunk leads at sim=0.57 even though the question shares no keyword with it — grep could never find this. Output for B: the semantic log shows generic travel chunks at flat scores near 0.4, with the actual TR-4 paragraph at rank 3 or missing, then keyword pass found: ['hr-policy.txt#11'] — the literal string match lands instantly. At 25 chunks both passes often succeed; at 4,000 chunks the gap becomes daily pain, which is why production systems run hybrid retrieval and merge both lists, then let reranking pick the best few from the merged shortlist.

Step 5 — the update drill. Edit hr-policy.txt: change the notice period from 30 to 45 days and save. The store does not know yet — prove it, fix it, prove the fix:

print(ask("What is the notice period?", where={"department": "hr"}))
# -> "The notice period is 30 days [hr-policy.txt#13]"   (stale!)

n = index_doc("hr-policy.txt", "hr")        # re-chunk + upsert, same ids
stale = [i for i in col.get(where={"doc": "hr-policy.txt"})["ids"]
         if int(i.split("#")[1]) >= n]
if stale:
    col.delete(ids=stale)                   # doc shrank? kill leftover ids

print(ask("What is the notice period?", where={"department": "hr"}))
# -> "The notice period is 45 days [hr-policy.txt#13]"

The first answer is WR-036 happening on schedule: the source changed, the index did not, and the bot stated last month's reality with confidence. index_doc is the one-line cure because upsert replaces by id. The stale check matters when an edit removes sections: fifteen new chunks over sixteen old ids leaves #15 orphaned, still retrievable, quoting deleted policy. Update discipline is three verbs — re-chunk, upsert, delete leftovers — and it must run on every edit, not on a good-intentions schedule.

Now you do it

Work in ai-lab with Ollama running and chromadb installed. Keep every retrieval log you generate — exercises 2 and 3 feed the gate assessment's artifact questions.

Exercise 190 min●○○

Migrate your 3.5 project to a vector store. Take the ragme.py you built over your own document in 3.5 and rebuild it as ragme2.py: add ## headings to your document, switch to chunk_file() with overlap and metadata, index into a PersistentClient collection with index_doc(), and route ask() through the filtered retrieve(). Then add a second document from a different "department" (any other rulebook or notes file you have) and index it into the same collection. Re-run the ten audit questions from 3.5 exercise 2. Check: all ten verdicts match or beat your 3.5 results; closing and reopening Python answers a question without re-embedding; one unfiltered question demonstrably retrieves from the wrong document, and the same question with where does not.

Exercise 290 min●●○

Chunking bake-off. Build two indexes from the same source document into two separate collections: strategy A = 3.5's fixed 300-character merge with no overlap and no sections; strategy B = chunk_file() with sections, overlap, and metadata. Write a 10-question eval set: for each question, note in advance which section holds the answer. Run all ten against both collections at k=3 and record: top similarity, whether the answer-holding section appeared in the top-3 (hit), and its rank. Build the results table in notes-3.6.md, one row per question with columns for both strategies. Check: the table has 10 rows and both strategies' hit counts; a three-sentence conclusion names the winner, cites one question where overlap or section-cutting changed the outcome, and states one case where the strategies tied and why.

Exercise 385 min●●○

Document-update drill. Pick your main document and make three edits: change one number (like a limit or a date), delete one entire section, and add one new section. Before touching the index, write down three questions whose answers must change. Run them against the stale index and save the transcript. Then run the full update: index_doc, plus the leftover-id delete. Re-run the three questions. Check: the before/after transcript in notes-3.6.md shows the old number answered pre-update and the new number post-update; a question about the deleted section gets the refusal line after update, and col.count() matches the new chunk count exactly — no orphan ids when you list ids for that document.

Exercise 485 min●●●

Latency budget and the cheap wins. Wrap each stage of ask() with time.perf_counter(): query embedding, col.query, and generation. Run your ten eval questions at k=3 and again at k=10 (send all retrieved chunks to the model), recording per-stage times. Then add the cheapest cache that works: a dict from the exact question string to its finished answer, checked before any stage runs. Re-ask three repeated questions. Check: notes-3.6.md has a timing table (stage × k=3/k=10) showing which stage dominates and how k changes generation time; cache hits return in under a millisecond; a two-sentence recommendation states what you would cut first to halve response time, backed by your numbers, plus one sentence on what this cache misses (rephrased questions) and one idea to catch them.

When it breaks

Production RAG fails in ways a toy pipeline never shows you. Three of them now have War Room entries:

  • WR-037 — the right answer from the wrong department. One unlabelled collection let an HR question retrieve a Sales chunk with a valid-looking citation; metadata at ingestion plus where filters at query time is the exit, and metadata you never attached cannot be filtered on later.
  • WR-038 — every question finds "something". The collection grew from 14 to 4,000 chunks, scores compressed into a narrow band, and the old floor stopped refusing anything; re-tune thresholds per corpus on a maintained eval set, and use reranking to sharpen a blurry shortlist.
  • WR-039 — answers took twenty seconds. k crept up, chunks grew, and the prompt ballooned until generation dominated every request; measure per stage, retrieve less, and cache repeats before buying anything.
Recap in 5 lines

Production RAG keeps 3.5's five stages and hardens each one against growth. Chunks cut at sections, with chunk overlap and a metadata dict, keep every rule whole and traceable to its source. A vector store such as chromadb holds ids, text, and labels on disk, and upsert keeps the index in step with edited files. Metadata filtering keeps each question inside the right department, while hybrid retrieval and reranking catch what one search method misses. As collections grow, retrieve less, cache repeats, re-tune the score floor, and measure every stage first.

Where this is used for real
  • Insurance and housing finance helpdesks in India stamp product, language, and date labels on every clause. A Hindi query about one scheme can never quote another scheme's English text.
  • Logistics and quick-commerce SOP bots replace a depot's chunks the same day a rule changes. A driver following last month's SOP is a safety risk.
  • GCC knowledge assistants serve HR, IT, and finance from one store with department filters. Source attribution means every answer names a document a lead can open and check.
Check yourself

Answer these before opening the gate assessment. Each question names its kind.

  1. type=output-prediction — Your collection holds 25 chunks: 16 with department: "hr", 9 with department: "it". Predict what retrieve("How many casual leaves do I get?", k=3, where={"department": "finance"}) prints, and what ask() with that filter returns, given the floor logic from 3.5 is intact.
  2. type=debug — After an edit shrank hr-policy.txt from 16 chunks to 12, a teammate re-ran index_doc and reports the bot still quotes a deleted transport-allowance rule, citing hr-policy.txt#14. Explain exactly why that chunk still exists and give the two-line fix.
  3. type=interpretation — An unfiltered retrieval log for "एक साल में कितनी छुट्टियाँ मिलती हैं?" shows three English chunks from sales-sop.txt at sim 0.41, 0.40, 0.38. Name the two separate problems this single log reveals, and say which one metadata alone can fix.
  4. type=scenario-judgement — A teammate proposes dropping the whole collection and re-embedding all documents every night at 2 am, calling upserts "extra bookkeeping". Give your judgement, two concrete costs of the nightly rebuild at 4,000 chunks, and the one situation where you would accept their proposal.
  5. type=trade-off — One shared collection with department metadata filters versus one separate collection per department: give one concrete gain and one concrete cost of each design for a company with 12 departments and one shared "all-staff" handbook.
Explain it to a friend

Explain to a friend who manages the front office at a coaching institute and has never coded: the notes bot answered a fees question using last year's fee sheet, and answered a hostel question using the day-scholar rulebook. In at most 4 sentences of plain words, explain why both mistakes happened and what "labelling every paragraph" and "replacing old paragraphs when a document changes" fix. "The bot searches only paragraphs wearing the right label" is allowed; "metadata filtering on a vector store" is not. If your friend can say why the bot needs labels and replacements, you own this module.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in