Chapter 7 of 10
Implement a small RAG system from first principles
Implementation
About 4 minutes · includes examples, an exercise, and references
Chapter at a glance
- •Implement stable evidence and policy contracts before replacing toy algorithms with services.
- •The model adapter is one replaceable stage whose output must pass deterministic validation.
- •Tests prioritize authorization, validity, citations, abstention, and budgets.
Building a small system without a framework reveals the contracts that libraries often hide. The goal is not to reproduce a production embedding model in a few lines. It is to implement the control plane around retrieval: immutable evidence units, query analysis, scoring, context budgets, citations, abstention, validation, and trace evidence. Once these boundaries are clear, production adapters can replace individual algorithms.
Define immutable inputs. An evidence unit has ID, source revision, text, authority, validity interval, and access tags. A request has actor scope, question, time, and answer policy. The pipeline never mutates the corpus during a query. It produces a run record containing versioned configuration and stage outputs. This makes test fixtures cheap and replay deterministic.
The runnable example below uses term overlap rather than a neural retriever. That limitation is intentional: relevance scoring is visible, and the surrounding behavior is the same for BM25 or vector adapters. The retriever filters authorization and validity before scoring. It returns identities and scores rather than anonymous strings. The context builder caps both item and word count and assigns handles.
Generation is represented by a deterministic synthesis function that copies a selected sentence. A real language-model adapter would receive the question, evidence packet, structured output schema, and deadline. Its output must still pass the same validator. Separating the adapter keeps tests independent of network access and allows recorded model fixtures.
The pipeline abstains if no positive-scoring evidence survives. This avoids the “nearest neighbor must be relevant” trap. A real threshold should be calibrated on held-out data and may incorporate reranker or support grades. The answer includes a claim and handle, not a fabricated source URL. Resolution to source metadata happens after validation.
Trace evidence should include request ID, corpus generation, policy version, retriever version, candidates before and after filters, scores, context omissions, model invocation identity, output, validation results, and terminal reason. Hash or protect sensitive values according to policy. A trace is not a chain-of-thought; it is an externally inspectable record of system transitions and artifacts.
Test invariants first. A denied unit never appears in candidates, context, output, or logs. An expired unit cannot support a current answer. Unknown citations fail. Empty evidence abstains. Context budgets are never exceeded. Tie ordering is deterministic. A corpus generation mismatch stops rather than mixing evidence. These properties matter more than whether a demo answer sounds polished.
Then test relevance. Construct queries for exact terms, paraphrases, ambiguous entities, negation, and unsupported facts. The toy overlap scorer will fail paraphrases, producing a clear reason to add dense retrieval. Add a second adapter and RRF without changing the evidence contract. Add a reranker without changing policy. This incremental path turns architecture choices into measured interventions.
Concurrency adds deadlines and cancellation. Run independent retrievers in parallel with bounded output. Reserve budget for context, generation, and validation. If one adapter fails, return a declared degraded result only where policy allows. Idempotent request identities can prevent client retries from producing duplicate billable generations or conflicting artifacts.
Persist only what the product needs. A stateless query service can return the complete run artifact to an evidence store. Conversations require user-visible state and explicit provenance for prior claims. Caches key on corpus generation, policy scope, retrieval configuration, model version, and answer schema. Omitting any of those can yield stale or cross-scope answers.
Frameworks can accelerate connectors, model calls, and orchestration, but they do not remove these responsibilities. Inspect whether a framework preserves stable IDs, exposes raw retrieval evidence, enforces policy before model context, supports typed terminal states, and allows stage replay. Wrap provider objects at your boundary so upgrades do not rewrite the entire system.
The mastery outcome is a system whose retrieval algorithm can be swapped while tests for authority, provenance, citations, abstention, and budgets remain unchanged. That is the difference between a RAG demo and an engineered evidence application.
Key points
- Implement stable evidence and policy contracts before replacing toy algorithms with services.
- The model adapter is one replaceable stage whose output must pass deterministic validation.
- Tests prioritize authorization, validity, citations, abstention, and budgets.
- Frameworks should sit behind product-owned types and evidence records.
A runnable closed-corpus RAG skeleton
Read the expected behavior in the surrounding walkthrough, then copy and run this reference implementation.
import re
def words(text): return set(re.findall(r"[a-z0-9]+", text.lower()))
def answer(question, corpus, allowed, as_of, word_budget=60):
query_terms = words(question)
candidates = []
for unit in corpus:
if unit["source"] not in allowed: continue
if not unit["valid_from"] <= as_of < unit["valid_to"]: continue
score = len(query_terms & words(unit["text"]))
if score: candidates.append((score, unit["id"], unit))
candidates.sort(key=lambda row: (-row[0], row[1]))
packet, used = {}, 0
for _, _, unit in candidates:
size = len(unit["text"].split())
if used + size > word_budget: continue
handle = "E" + str(len(packet) + 1)
packet[handle] = unit; used += size
if len(packet) == 3: break
if not packet:
return {"status":"abstained", "reason":"no_support", "evidence":{}}
handle, unit = next(iter(packet.items()))
claim = unit["text"].split(".")[0] + "."
result = {"status":"answered", "claims":[{"text":claim, "citations":[handle]}],
"evidence":packet}
assert all(c in packet for item in result["claims"] for c in item["citations"])
return result
docs = [{"id":"p1", "source":"manual", "valid_from":1, "valid_to":99,
"text":"Retries are limited to two. Escalate after the second failure."}]
print(answer("What is the retry limit?", docs, {"manual"}, as_of=10))Exercise
Evolve the skeleton without breaking contracts
Implement the example, then replace overlap retrieval with lexical plus dense adapters.
- Add typed run records, omission reasons, deadlines, and corpus generations.
- Add RRF and a model adapter behind interfaces.
- Write invariant, relevance, degraded-mode, and replay tests.
Success criteria
- All original policy and evidence tests pass after algorithm swaps.
- An unsupported question produces a typed abstention.
- The same fixture and configuration reproduce every deterministic stage.
Reflect: Which framework convenience currently hides a policy decision?
References and further reading
- Retrieval-Augmented Generation for Knowledge-Intensive NLP TasksThe original RAG formulation combining parametric and non-parametric memory.
- Dense Passage Retrieval for Open-Domain Question AnsweringThe primary DPR dual-encoder paper.
- Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning MethodsThe primary paper introducing and evaluating Reciprocal Rank Fusion.