RAG Pipeline
Implement a retrieval-augmented generation pipeline: ingest documents, chunk with overlap, embed, and retrieve top-K similar chunks via dot-product similarity.
Last updated: September 2026
Implement a retrieval-augmented generation pipeline: ingest documents, chunk them with overlap, embed each chunk, then retrieve the top-K most similar chunks for a query using dot-product similarity. The key design decision is separating ingest() from update() - making intentional overwrite explicit rather than silent.
“ingest() is create - fail loudly on duplicate. update() is an intentional overwrite. Never silently overwrite on ingest: data corruption is worse than a raised exception. retrieve() returns empty on an empty index - never raise, never hallucinate.”
Clarifying Questions (Ask These First)
| Question | Why it matters |
|---|---|
| Plain text input only, or PDF and HTML too? | PDF needs parsing (PyMuPDF), HTML needs extraction - plain text is the simplest starting point |
| Real embedding model or stub acceptable? | Stub (ASCII values) is fine for the interview - call out where the real API call goes |
| Is top_k fixed at init or configurable per query? | Per-query is more flexible: pipeline.retrieve(query, top_k=5) is the right default |
| Duplicate doc_id: overwrite silently or raise? | This is the most important design question - ingest() raises, update() overwrites |
Implementation
from dataclasses import dataclass, field
from typing import List
@dataclass
class Document:
doc_id: str
content: str
chunks: List[str] = field(default_factory=list)
class RAGPipeline:
def __init__(self, chunk_size: int = 512, overlap: int = 50):
self.chunk_size = chunk_size
self.overlap = overlap
self.index = {} # doc_id -> list of (chunk, embedding)
def ingest(self, doc: Document):
if doc.doc_id in self.index:
raise ValueError(f"Use update() to replace existing document '{doc.doc_id}'")
self._index_document(doc)
def update(self, doc: Document):
self._index_document(doc)
def _index_document(self, doc: Document):
if not doc.content or not doc.doc_id:
raise ValueError("Document must have non-empty id and content")
chunks = self._chunk(doc.content)
doc.chunks = chunks
self.index[doc.doc_id] = [(chunk, self._embed(chunk)) for chunk in chunks]
def _chunk(self, text: str) -> List[str]:
chunks, start = [], 0
while start < len(text):
end = min(start + self.chunk_size, len(text))
chunks.append(text[start:end])
start += self.chunk_size - self.overlap
return chunks
def _embed(self, text: str) -> List[float]:
# Stub: replace with real embedding API call
return [float(ord(c)) for c in text[:10]]
def retrieve(self, query: str, top_k: int = 3) -> List[str]:
if not self.index:
return []
query_emb = self._embed(query)
scored = []
for chunks_embs in self.index.values():
for chunk, emb in chunks_embs:
score = sum(a * b for a, b in zip(query_emb, emb))
scored.append((score, chunk))
scored.sort(reverse=True)
return [chunk for _, chunk in scored[:top_k]]
# Usage
pipeline = RAGPipeline(chunk_size=512, overlap=50)
pipeline.ingest(Document(doc_id="doc_1", content="RAG stands for Retrieval Augmented Generation..."))
pipeline.ingest(Document(doc_id="doc_2", content="Vector databases store embeddings efficiently..."))
results = pipeline.retrieve("What is RAG?", top_k=2)
print(results)Key Design Decisions
- 1ingest() vs update() - Explicit Intent - ingest() raises ValueError on duplicate doc_id. update() overwrites unconditionally. This makes the caller declare intent - accidental overwrites on ingest are a data correctness bug, so fail loudly. Common pattern in database write paths.
- 2Chunking with Overlap - _chunk() slides a window of chunk_size characters, advancing by (chunk_size - overlap) each step. Overlap ensures a concept split across a boundary appears fully in at least one chunk. Without overlap, relevant context at chunk edges is silently lost.
- 3Dot-Product Similarity (Stub) - retrieve() computes sum(a*b for a,b in zip(query_emb, emb)). For real unit-normalized embeddings this equals cosine similarity. The stub embedding (first 10 ASCII values) is declared upfront - interviewers appreciate knowing exactly where the real API call goes.
- 4_index_document() - Shared Private Method - Both ingest() and update() delegate to _index_document() after their respective pre-conditions. Shared logic lives in one place - if chunking or embedding changes, you change it once. Classic DRY applied correctly.
Frequently asked questions
How would you replace the stub embedding with a real one?
Replace _embed() with a call to an embedding API: OpenAI text-embedding-3-small, Vertex AI text-embedding-004, or a local model via SentenceTransformers. The rest of the pipeline does not change - that is why the stub is isolated in one method. In production, batch embed all chunks in one API call rather than calling per chunk.
What is the problem with dot-product on non-normalized embeddings?
Dot product conflates magnitude with direction. A long document embedding with large magnitude can outscore a highly relevant short chunk just by being bigger. Fix: normalize embeddings to unit length before storing (L2 norm), then dot product equals cosine similarity. Most embedding APIs return normalized vectors already.
How does the overlap prevent lost context?
If a sentence spans characters 500-560 and chunk_size is 512 with overlap 50, the first chunk covers 0-512 and the second covers 462-974. The bridging sentence appears fully in the second chunk. Without overlap it would be split at character 512 and neither chunk would contain the complete sentence.
How would you handle a document that changes frequently?
Call update(doc) - it re-chunks and re-embeds the entire document and replaces the index entry. For large documents this is expensive. Optimization: track a content hash per chunk. On update, only re-embed chunks whose hash changed. Store chunk-level hashes alongside embeddings.
What happens if two threads call ingest() with the same doc_id simultaneously?
Race condition: both see doc_id not in self.index, both proceed to _index_document, last writer wins silently - defeating the purpose of ingest() raising on duplicate. Fix: add a threading.Lock() around the check-then-insert, same pattern as the rate limiter.
How would you scale this to millions of documents?
Replace self.index (in-memory dict) with a vector database: Pinecone, Weaviate, Qdrant, or Vertex AI Vector Search. These support ANN (Approximate Nearest Neighbor) search which scales to billions of vectors with sub-100ms latency. Exact dot-product over millions of vectors is O(N) and too slow.