AI Knowledge Tree

RAG fundamentals

Build a retrieval-augmented generation pipeline: chunk documents, embed with a bi-encoder, store in a vector index, retrieve top-k, and pass to an LLM.

An LLM's knowledge is frozen at training time. For questions about recent events, private documents, or domain-specific facts, the model will hallucinate or say "I don't know."

Retrieval-Augmented Generation (RAG, Lewis et al., 2020) grounds the model by dynamically retrieving relevant documents at inference time and including them in the context[1]. The model generates its answer conditioned on both the question and the retrieved passages.

The RAG pipeline

Step 1: Chunking

Documents are split into overlapping chunks (e.g., 512 tokens with 64-token overlap). The overlap ensures that context is not lost at chunk boundaries.

Chunking strategies:

  • Fixed-size: simplest; works for homogeneous documents.
  • Sentence-based: split at sentence boundaries; more coherent chunks.
  • Recursive character splitting: split on paragraph, sentence, and word boundaries in order of preference.
  • Semantic chunking: split where the embedding similarity between adjacent sentences drops (topic change detection).

Step 2: Embedding

Each chunk is embedded using a bi-encoder: a transformer that maps a text into a fixed-dimensional vector. The same model is used for both documents and queries.

Popular embedding models: text-embedding-3-large (OpenAI), BAAI/bge-m3 (multilingual), nomic-embed-text (open-source), E5-large.

Similarity is measured with cosine similarity:

sim(q,d)=eq⋅ed∥eq∥∥ed∥.\text{sim}(q, d) = \frac{\mathbf{e}_q \cdot \mathbf{e}_d}{\|\mathbf{e}_q\| \|\mathbf{e}_d\|}.

Step 3: Vector index

Store all chunk embeddings in a vector database for approximate nearest neighbor (ANN) search. The key structures:

  • FAISS (flat): exact brute-force search; O(N⋅d)O(N \cdot d). Accurate but slow at scale.
  • FAISS HNSW: hierarchical navigable small world graph; approximate, fast, high recall.
  • FAISS IVF: inverted file index; clusters vectors, searches only the nearest clusters.

Vector databases (Pinecone, Weaviate, Qdrant, Chroma, pgvector) wrap these indices with persistence, filtering, and metadata support.

Step 4: Retrieval

At query time: embed the query, search for top-kk nearest chunks (typically k=5−10k = 5{-}10), return the chunks and their scores.

Step 5: Generation

Prepend the retrieved chunks to the LLM's prompt:

Context:
[Retrieved passage 1]
[Retrieved passage 2]
...

Question: {user_question}
Answer:

The LLM generates an answer conditioned on the provided context.

Implementation

python · numpy
import numpy as np

# Toy embedding function (replace with a real bi-encoder)
def embed(text, d=16, seed=None):
    """Deterministic random embedding for demo."""
    rng = np.random.default_rng(hash(text) % (2**32))
    return rng.normal(0, 1, d)

def cosine_sim(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)

# Build index
documents = [
    "Backpropagation computes gradients by the chain rule on the computation graph.",
    "Transformers use self-attention to mix information across sequence positions.",
    "Gradient descent updates parameters in the direction of steepest loss decrease.",
    "The LSTM uses a cell state to maintain long-range dependencies.",
    "LoRA injects low-rank adapter matrices alongside frozen pretrained weights.",
    "KV cache stores computed keys and values to avoid recomputation during decoding.",
]

def chunk(doc, chunk_size=50, overlap=10):
    words = doc.split()
    chunks = []
    i = 0
    while i < len(words):
        chunk_words = words[i:i + chunk_size]
        chunks.append(" ".join(chunk_words))
        i += chunk_size - overlap
    return chunks

# Index all chunks
chunk_store = []   # (chunk_text, embedding)
for doc in documents:
    for c in chunk(doc):
        e = embed(c)
        chunk_store.append((c, e))

def retrieve(query, k=3):
    q_emb = embed(query)
    scores = [(cosine_sim(q_emb, e), text) for text, e in chunk_store]
    scores.sort(reverse=True)
    return scores[:k]

# Demo retrieval
query = "How do you update model weights?"
results = retrieve(query, k=3)
print(f"Query: {query}\nTop-3 results:")
for score, text in results:
    print(f"  [{score:.3f}] {text}")

Common failure modes

Retrieval failure: the correct chunk is not retrieved (top-k miss). Caused by:

  • Poor embedding model for the domain.
  • Chunk size too small (loses context) or too large (dilutes relevance signal).
  • Exact-keyword queries vs. semantic mismatch.

Context too long: retrieved chunks exceed the LLM's context window. Mitigation: rerank and keep only the top-k′k' most relevant chunks.

Lost in the middle: LLMs perform worse on information in the middle of long contexts than at the beginning and end. Put the most relevant passage first.

Hallucination despite retrieval: the model ignores the retrieved context and generates from parametric memory. Mitigation: explicit prompting ("answer only based on the provided context") and self-consistency checks.

Exercises

Exercise 1. A document holds 2000 tokens. It is chunked with chunk_size = 512 and overlap = 64. How many chunks result? Give the start index of each chunk and explain why the overlap raises the chunk count relative to non-overlapping splitting.

Solution

Step 1. With overlap, the stride (advance per chunk) is chunk_size−overlap=512−64=448\text{chunk\_size} - \text{overlap} = 512 - 64 = 448 tokens.

Step 2. Chunks start at 0,448,896,1344,17920, 448, 896, 1344, 1792. The next start would be 22402240, past the 2000-token document, so there are 5 chunks. The last chunk spans tokens 17921792 to 19991999 (a partial chunk of 208 tokens).

Step 3. Non-overlapping splitting strides by the full 512 tokens, giving starts 0,512,1024,15360, 512, 1024, 1536, which is 4 chunks. Overlap shortens the stride, so more chunks are needed to cover the same text; this is the cost paid to avoid losing context at chunk boundaries.

Exercise 2. Cosine similarity ignores vector magnitude. Take a query eq=(3,4)\mathbf{e}_q = (3, 4) and two document vectors ed1=(6,8)\mathbf{e}_{d_1} = (6, 8) and ed2=(4,3)\mathbf{e}_{d_2} = (4, 3). Compute both cosine similarities by hand. Which document ranks first, and what would change if retrieval used raw dot product instead?

Solution

Step 1. ∥eq∥=9+16=5\|\mathbf{e}_q\| = \sqrt{9 + 16} = 5.

Step 2. Document 1: dot product 3⋅6+4⋅8=503\cdot 6 + 4\cdot 8 = 50, norm 36+64=10\sqrt{36 + 64} = 10, so sim=50/(5⋅10)=1.0\text{sim} = 50 / (5 \cdot 10) = 1.0 (d1d_1 points the same direction as qq).

Step 3. Document 2: dot product 3⋅4+4⋅3=243\cdot 4 + 4\cdot 3 = 24, norm 16+9=5\sqrt{16 + 9} = 5, so sim=24/(5⋅5)=0.96\text{sim} = 24 / (5 \cdot 5) = 0.96.

Step 4. Cosine ranks d1d_1 first (1.0>0.961.0 > 0.96). Raw dot product would score d1=50d_1 = 50 versus d2=24d_2 = 24, so d1d_1 still wins here, but dot product rewards longer vectors: a document twice as long as d1d_1 in the same direction would score 100100 under dot product yet stay at 1.01.0 under cosine. Cosine isolates direction (semantic content) from magnitude (length or term frequency).

Exercise 3 (code). Build the toy retriever from the lesson over a small corpus, then confirm that L2-normalizing every embedding makes the dot product equal to the cosine similarity, so a normalized dot-product index returns the same ranking.

Solution
python · numpy
import numpy as np

rng = np.random.default_rng(0)
d, N = 8, 12
docs = rng.normal(0, 1, (N, d))
query = rng.normal(0, 1, d)

def cosine_sim(a, b):
    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)

cos_scores = np.array([cosine_sim(query, docs[i]) for i in range(N)])

# Normalize, then plain dot product
qn = query / np.linalg.norm(query)
docs_n = docs / np.linalg.norm(docs, axis=1, keepdims=True)
dot_scores = docs_n @ qn

# Same scores and therefore the same top-k ranking
assert np.allclose(cos_scores, dot_scores)
assert np.array_equal(np.argsort(-cos_scores), np.argsort(-dot_scores))
print("top-3 (cosine):", np.argsort(-cos_scores)[:3])
print("top-3 (norm dot):", np.argsort(-dot_scores)[:3])

After normalization the dot product reproduces cosine similarity exactly, which is why vector indices store unit-length embeddings and search by inner product.

The next lesson introduces dense retrieval with trained bi-encoders and approximate nearest neighbor search structures for real-scale deployment.


References

  1. [1]Lewis, P., et al. (2020). "Retrieval-augmented generation for knowledge-intensive NLP tasks". NeurIPS. arXiv:2005.11401. Introduces the retrieve-then-generate architecture this pipeline implements.↩