Every LLM you interact with has a hard stop in time. It was trained on data up to a certain date, and it knows nothing about anything that happened after. More importantly, it knows nothing about your data — your internal documentation, your architecture decision records, your private knowledge base, your team’s runbooks. When you ask a general-purpose model about your own codebase or your company’s systems, it can only guess.
Retrieval-Augmented Generation (RAG) is the practical solution to this. Instead of trying to teach the model everything it needs to know (which would require fine-tuning and retraining), RAG retrieves relevant information from your own documents at query time and hands it to the model along with the question. The model reads the retrieved context and answers based on what is actually in your documents, not what it learned during training.
This post builds a complete, production-aware RAG system from scratch: a working Python implementation using Ollama for local inference and embeddings, ChromaDB as the vector store, and LangChain for orchestration. Everything runs locally — your documents never leave your machine.
What RAG Solves
The core problem is simple: LLMs are static. Training is expensive and slow. You cannot retrain a model every time your documentation changes, and you certainly cannot retrain it to include your private data without significant cost and privacy risk.
The naive alternative — dumping all your documents into the context window — has gotten more practical as context windows have grown, but it still has real problems. Sending 100,000 tokens to a cloud API on every query is expensive. It is also slow, and the model’s attention degrades over very long contexts (documents near the middle of a long window get less reliable treatment than those at the start or end). For private data, sending everything to an external API on every request creates a continuous data exposure surface.
RAG threads the needle. At query time, it retrieves only the handful of document chunks that are semantically similar to the user’s question. Those chunks — typically 3–10 of them, totaling 2,000–8,000 tokens — get inserted into the prompt. The model answers based on that focused context. Retrieval is fast, token costs are contained, and you only send the relevant excerpt rather than the entire corpus.
RAG vs. Fine-Tuning
Fine-tuning is the other common approach when people want a model to “know” something new. The distinction matters:
- Fine-tuning adjusts the model’s weights. It is good for changing behavior, style, and response format. It is poor for factual grounding — fine-tuned knowledge still “leaks” and hallucinates, and you have to retrain every time the underlying facts change.
- RAG does not change the model at all. It changes what the model sees at inference time. RAG is right for dynamic, frequently updated, or private factual information.
In practice, they are complementary. A model fine-tuned for a specific tone or output format plus RAG for grounding on current facts is a common production pattern.
Real Use Cases
- Chatbot over your internal documentation or wiki
- Q&A over a folder of PDFs (policies, contracts, research papers)
- Semantic search over your notes (Obsidian vault, Logseq database)
- Code search: find functions by what they do, not by name
- Support bot trained on your product’s documentation
- Personal knowledge assistant over your Readwise highlights or bookmarks
How RAG Works: The Full Pipeline
RAG has two separate pipelines that run at different times.
INDEXING PIPELINE (run once, or on document updates):
Raw Documents
│
▼
Document Loader ──► Text Chunks ──► Embedder ──► Vector DB
(PDF, MD, TXT) (split text) (text→vec) (store vecs)
QUERY PIPELINE (run on every user question):
User Question
│
▼
Embedder ──► Vector DB ──► Top-K Chunks ──► LLM ──► Answer
(query→vec) (ANN search) (relevant text) (prompt) + Sources
The Indexing Pipeline
Step 1: Load documents. Parse your raw files into a standard structure. This means extracting text from PDFs, reading markdown files, scraping HTML pages, or parsing structured formats like JSON.
Step 2: Split into chunks. This is more nuanced than it sounds. The chunks need to be small enough that a single chunk represents one coherent idea (so retrieval is precise), but large enough that the chunk provides sufficient context for the LLM to work with. Chunk size is one of the most impactful tuning knobs in a RAG system.
Step 3: Embed each chunk. An embedding model converts each text chunk into a dense numerical vector — typically 384 to 1536 floating-point numbers. Semantically similar texts produce geometrically similar vectors. The vector for “how do I restart the web service” will be close to the vector for “systemctl restart nginx” in the embedding space.
Step 4: Store in a vector database. The vectors, along with the original text and metadata (filename, page number, etc.), are persisted in a vector store that supports approximate nearest-neighbor (ANN) search.
The Query Pipeline
Step 1: Embed the query. The user’s question is converted to a vector using the same embedding model used during indexing. This is critical — you must use the same model for both index and query.
Step 2: Similarity search. The query vector is compared against all stored chunk vectors. The database returns the top-K most similar chunks by cosine similarity or dot product.
Step 3: Build the prompt. The retrieved chunks are formatted and injected into the prompt alongside the user’s question. A system prompt instructs the model to answer only based on the provided context.
Step 4: Generate the answer. The LLM reads the prompt (context + question) and produces an answer grounded in the retrieved documents.
Embeddings Explained
Embeddings are the core technical mechanism that makes semantic search work. An embedding model — a neural network trained specifically for this purpose — takes a piece of text and outputs a fixed-length vector of floating-point numbers. Two pieces of text that mean similar things produce vectors that are close together in the high-dimensional vector space; text that means different things produces vectors that are far apart.
“Cosine similarity” measures the angle between two vectors. A cosine similarity of 1.0 means identical direction (semantically identical), 0.0 means orthogonal (completely unrelated), and -1.0 means opposite. In practice, the vectors for semantically related text cluster between 0.7 and 1.0.
The practical implications:
- “What is the maximum file size?” and “file size limit” will have high cosine similarity, even though they share no keywords.
- “The file size limit is 100MB” will also be close to both — it is the answer to the question.
- “The weather in Oslo is cold” will be far from all three.
Embedding Model Options
Local models (privacy-preserving — recommended for sensitive data):
| Model |
Dimensions |
Notes |
nomic-embed-text (Ollama) |
768 |
Excellent quality, easy to pull via Ollama, great default choice |
mxbai-embed-large (Ollama) |
1024 |
Strong MTEB benchmark scores, good for retrieval tasks |
all-MiniLM-L6-v2 (sentence-transformers) |
384 |
Small and fast, good for resource-constrained environments |
bge-large-en-v1.5 (sentence-transformers / Ollama) |
1024 |
Top-tier MTEB performance for English retrieval |
Cloud API models (easy but sends your data externally):
| Model |
Dimensions |
Notes |
text-embedding-3-small (OpenAI) |
1536 |
Very good quality, cheap, the default OpenAI choice |
text-embedding-3-large (OpenAI) |
3072 |
Marginally better, 5× more expensive |
| Cohere embed-v3 |
1024 |
Strong multilingual performance |
For this guide we use nomic-embed-text via Ollama. It runs entirely locally, performs well on retrieval benchmarks, and integrates cleanly with LangChain.
The cardinal rule: the embedding model must be identical between indexing and querying. If you index with nomic-embed-text, every query must also use nomic-embed-text. Switching models requires re-embedding your entire corpus.
Vector Databases
A vector database stores your chunk vectors alongside their original text and metadata. Its defining capability is approximate nearest-neighbor (ANN) search: given a query vector, find the K most similar vectors in the store efficiently — without comparing against every single vector (which would be too slow at scale).
Common ANN algorithms:
- HNSW (Hierarchical Navigable Small World): graph-based, excellent recall and speed balance, the default in most modern vector DBs.
- IVF (Inverted File Index): partitions vectors into clusters, searches only the nearest clusters. Good for very large datasets.
- Flat (exact): exhaustive search, perfect recall, too slow above ~100k vectors.
Choosing a Vector Database
ChromaDB — Start here. Runs in-process as a Python library or as a client-server setup. Stores data to disk automatically. Zero infrastructure required. The easiest path from zero to a working vector store.
Qdrant — When you need more. Docker-based, excellent performance, rich filtering API, payload storage, and good horizontal scaling. Self-hostable. A strong production choice when ChromaDB’s limitations start showing.
Weaviate — Full-featured, Docker-based. Built-in BM25 hybrid search (vector + keyword in one query). Good for large document sets where keyword matching matters.
pgvector — Postgres extension. If you already run Postgres, adding pgvector lets you do vector search without adding a new service. Not as performant as dedicated vector stores at large scale, but eliminates operational complexity for teams already managing Postgres.
FAISS — Facebook’s library, in-memory only, no persistence. Great for rapid prototyping and benchmarking embedding quality. Not suitable as a production store without wrapping it in your own persistence layer.
Milvus — Enterprise-scale, supports billions of vectors, complex to operate. Overkill until you are at a scale where Qdrant is struggling.
One underused feature of vector databases is metadata filtering. When you index a chunk, you store metadata alongside it: the source filename, page number, document type, creation date, author, category, or any other field that makes sense for your documents. At query time, you can constrain the similarity search to only chunks that match a metadata filter:
1
2
3
4
5
6
|
# Only search within documents tagged as "architecture"
results = vectorstore.similarity_search(
query,
k=5,
filter={"category": "architecture"}
)
|
This is essential for multi-tenant systems, access control, and keeping answers within a relevant subset of your corpus.
Hybrid Search
Pure vector similarity has a weakness: it is excellent at semantic matching but can miss exact keyword matches. If a user queries for a specific error code or a precise product name, BM25 keyword search (the algorithm behind traditional full-text search) often outperforms vector similarity. Hybrid search combines both scores (typically with a weighted sum or RRF — Reciprocal Rank Fusion) to get the benefits of both approaches. Weaviate and Qdrant support hybrid search natively.
Building a RAG System from Scratch
Tech Stack
- Python 3.11+
- Ollama — local LLM inference and embeddings (
llama3.2 for generation, nomic-embed-text for embeddings)
- ChromaDB — vector store with on-disk persistence
- LangChain — document loaders, text splitters, and chain orchestration
- pypdf — PDF parsing
Installation
1
2
3
4
5
6
|
# Install Python packages
pip install langchain langchain-community langchain-ollama chromadb pypdf python-dotenv
# Pull the models you need via Ollama
ollama pull llama3.2
ollama pull nomic-embed-text
|
Verify Ollama is running and the models are available:
1
2
|
ollama list
# Should show llama3.2 and nomic-embed-text
|
Step 1: Document Loading
LangChain provides document loaders for nearly every format. Each loader returns a list of Document objects with .page_content (the text) and .metadata (source, page, etc.).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
# loaders.py
from pathlib import Path
from langchain_community.document_loaders import (
PyPDFLoader,
DirectoryLoader,
TextLoader,
WebBaseLoader,
UnstructuredMarkdownLoader,
)
from langchain.schema import Document
from typing import List
def load_pdfs(path: str) -> List[Document]:
"""Load all PDFs from a directory, one Document per page."""
loader = DirectoryLoader(
path,
glob="**/*.pdf",
loader_cls=PyPDFLoader,
show_progress=True,
)
docs = loader.load()
print(f"Loaded {len(docs)} pages from PDFs in {path}")
return docs
def load_markdown(path: str) -> List[Document]:
"""Load all markdown files from a directory."""
loader = DirectoryLoader(
path,
glob="**/*.md",
loader_cls=UnstructuredMarkdownLoader,
show_progress=True,
)
docs = loader.load()
print(f"Loaded {len(docs)} markdown documents from {path}")
return docs
def load_text_files(path: str) -> List[Document]:
"""Load all plain text files from a directory."""
loader = DirectoryLoader(
path,
glob="**/*.txt",
loader_cls=TextLoader,
loader_kwargs={"encoding": "utf-8"},
show_progress=True,
)
docs = loader.load()
print(f"Loaded {len(docs)} text files from {path}")
return docs
def load_web_page(url: str) -> List[Document]:
"""Load a web page and return its text content."""
loader = WebBaseLoader(url)
docs = loader.load()
print(f"Loaded {len(docs)} documents from {url}")
return docs
def load_all_documents(directory: str) -> List[Document]:
"""Load PDFs, markdown, and text files from a directory."""
docs = []
docs.extend(load_pdfs(directory))
docs.extend(load_markdown(directory))
docs.extend(load_text_files(directory))
return docs
|
Step 2: Text Splitting
Splitting is where most RAG systems go wrong. The goal is chunks that are semantically coherent — ideally, each chunk covers one topic, one function, or one idea — while being large enough to contain useful context.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
|
# splitter.py
from langchain.text_splitter import (
RecursiveCharacterTextSplitter,
MarkdownHeaderTextSplitter,
Language,
)
from langchain.schema import Document
from typing import List
def split_documents(
docs: List[Document],
chunk_size: int = 1000,
chunk_overlap: int = 200,
) -> List[Document]:
"""
Split documents into chunks using RecursiveCharacterTextSplitter.
chunk_size: maximum number of characters per chunk
chunk_overlap: number of characters shared between adjacent chunks
The splitter tries to break on natural boundaries in this order:
paragraph breaks (\n\n), line breaks (\n), sentences (. ), words ( ),
and finally individual characters. It falls back to the next separator
only when the current one would produce a chunk that exceeds chunk_size.
"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=len,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_documents(docs)
print(f"Split {len(docs)} documents into {len(chunks)} chunks")
print(f"Average chunk size: {sum(len(c.page_content) for c in chunks) // len(chunks)} chars")
return chunks
def split_markdown_by_headers(markdown_text: str) -> List[Document]:
"""
Split a markdown document by its header hierarchy.
Produces chunks that align with the document's logical structure.
"""
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False,
)
sections = header_splitter.split_text(markdown_text)
# Further split large sections so no chunk exceeds our size limit
char_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=100,
)
return char_splitter.split_documents(sections)
def split_code(code_text: str, language: str = "python") -> List[Document]:
"""
Split source code using LangChain's language-aware splitter.
Breaks on class and function boundaries rather than arbitrary character counts.
"""
lang_map = {
"python": Language.PYTHON,
"javascript": Language.JS,
"typescript": Language.TS,
"go": Language.GO,
"rust": Language.RUST,
"java": Language.JAVA,
}
lang = lang_map.get(language.lower(), Language.PYTHON)
splitter = RecursiveCharacterTextSplitter.from_language(
language=lang,
chunk_size=1500,
chunk_overlap=200,
)
return splitter.create_documents([code_text])
|
Why overlap matters: Imagine a document where a concept starts at the end of one chunk and continues into the next. Without overlap, neither chunk contains the full picture. With 200 characters of overlap, both chunks include the bridging text, so retrieval of either chunk gives the LLM enough context to form a coherent answer.
Concrete example with chunk_size=50, chunk_overlap=10:
Text: "The nginx server listens on port 80. It proxies requests to the backend on port 8080."
Chunk 1: "The nginx server listens on port 80. It proxies"
Chunk 2: "It proxies requests to the backend on port 8080."
The overlap ensures “It proxies” appears in both chunks, so either can be retrieved and still convey the proxy relationship.
Step 3: Embedding and Storing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
# indexer.py
from langchain_ollama import OllamaEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.schema import Document
from typing import List
import os
CHROMA_PERSIST_DIR = "./chroma_db"
EMBEDDING_MODEL = "nomic-embed-text"
COLLECTION_NAME = "my_documents"
def get_embeddings() -> OllamaEmbeddings:
"""Return the embedding model. Must be the same at index and query time."""
return OllamaEmbeddings(model=EMBEDDING_MODEL)
def build_index(chunks: List[Document]) -> Chroma:
"""
Embed all chunks and store them in ChromaDB.
ChromaDB persists to disk automatically at CHROMA_PERSIST_DIR.
"""
embeddings = get_embeddings()
# If the collection already exists, delete and recreate it
# (for a full re-index — see incremental updates section for smarter logic)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
collection_name=COLLECTION_NAME,
persist_directory=CHROMA_PERSIST_DIR,
)
print(f"Indexed {len(chunks)} chunks into ChromaDB at {CHROMA_PERSIST_DIR}")
return vectorstore
def load_index() -> Chroma:
"""Load an existing ChromaDB index from disk."""
if not os.path.exists(CHROMA_PERSIST_DIR):
raise FileNotFoundError(
f"No ChromaDB index found at {CHROMA_PERSIST_DIR}. "
"Run the indexing pipeline first."
)
embeddings = get_embeddings()
vectorstore = Chroma(
collection_name=COLLECTION_NAME,
embedding_function=embeddings,
persist_directory=CHROMA_PERSIST_DIR,
)
count = vectorstore._collection.count()
print(f"Loaded ChromaDB index with {count} chunks")
return vectorstore
def add_documents_to_index(new_chunks: List[Document]) -> None:
"""Add new chunks to an existing index without rebuilding from scratch."""
vectorstore = load_index()
vectorstore.add_documents(new_chunks)
print(f"Added {len(new_chunks)} new chunks to the index")
|
ChromaDB stores the vectors and the original chunk text together. When you persist to disk, the index survives between Python sessions — you only need to re-embed when documents actually change.
Step 4: The Retriever
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
# retriever.py
from langchain_community.vectorstores import Chroma
from langchain.schema import Document
from typing import List, Tuple
from indexer import load_index
def similarity_search(
query: str,
k: int = 5,
score_threshold: float = 0.3,
) -> List[Tuple[Document, float]]:
"""
Return the top-k most similar chunks to the query, with their scores.
score_threshold filters out chunks below a minimum similarity.
"""
vectorstore = load_index()
results = vectorstore.similarity_search_with_relevance_scores(
query,
k=k,
)
# Filter by score threshold
filtered = [(doc, score) for doc, score in results if score >= score_threshold]
if not filtered:
print(f"Warning: no chunks above score threshold {score_threshold}")
return results[:k] # Return top-k anyway
return filtered
def mmr_search(
query: str,
k: int = 5,
fetch_k: int = 20,
lambda_mult: float = 0.5,
) -> List[Document]:
"""
Maximal Marginal Relevance search.
Retrieves fetch_k candidates by similarity, then selects k of them
to maximize both relevance AND diversity (penalizes redundant chunks).
lambda_mult: 0.0 = pure diversity, 1.0 = pure similarity.
0.5 is a balanced default.
"""
vectorstore = load_index()
return vectorstore.max_marginal_relevance_search(
query,
k=k,
fetch_k=fetch_k,
lambda_mult=lambda_mult,
)
def filtered_search(
query: str,
metadata_filter: dict,
k: int = 5,
) -> List[Document]:
"""
Restrict retrieval to chunks matching a metadata filter.
Example:
filtered_search("nginx config", {"source": "runbooks/nginx.md"})
filtered_search("backup policy", {"doc_type": "policy"})
"""
vectorstore = load_index()
return vectorstore.similarity_search(
query,
k=k,
filter=metadata_filter,
)
|
MMR is worth using when your document corpus has a lot of redundant content — for example, a FAQ where the same answer appears multiple times in slightly different wording. Without MMR, the top-5 results might all say the same thing. MMR explicitly penalizes chunks that are too similar to already-selected results, increasing diversity in what gets passed to the LLM.
Step 5: The RAG Chain
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
|
# rag_chain.py
from langchain_ollama import OllamaLLM
from langchain.prompts import ChatPromptTemplate
from langchain.schema import Document
from retriever import similarity_search, mmr_search
from typing import List, Dict, Any
LLM_MODEL = "llama3.2"
SYSTEM_PROMPT = """You are a helpful assistant that answers questions based strictly on the provided context documents.
Rules:
- Answer only using information present in the context below.
- If the context does not contain enough information to answer the question, say so explicitly — do not guess or hallucinate.
- When referencing specific information, mention which document it came from.
- Be concise and precise. Do not add information that is not in the context.
- If the question is ambiguous, answer for the most likely interpretation and note the ambiguity.
Context:
{context}
"""
HUMAN_PROMPT = "Question: {question}"
def format_docs(docs_with_scores: List[tuple]) -> str:
"""
Format retrieved documents into a string for injection into the prompt.
Includes source metadata so the model can reference it.
"""
formatted = []
for i, (doc, score) in enumerate(docs_with_scores, 1):
source = doc.metadata.get("source", "unknown")
page = doc.metadata.get("page", "")
page_str = f", page {page}" if page else ""
formatted.append(
f"[Document {i} | Source: {source}{page_str} | Relevance: {score:.2f}]\n"
f"{doc.page_content}"
)
return "\n\n---\n\n".join(formatted)
def get_llm() -> OllamaLLM:
return OllamaLLM(model=LLM_MODEL, temperature=0.1)
def ask(
question: str,
k: int = 5,
use_mmr: bool = False,
metadata_filter: dict = None,
) -> Dict[str, Any]:
"""
Full RAG pipeline: retrieve relevant chunks, build prompt, generate answer.
Returns a dict with:
- answer: the LLM's response
- sources: list of (document, score) tuples
- context: the formatted context string sent to the LLM
"""
# Retrieve
if use_mmr:
docs = mmr_search(question, k=k)
docs_with_scores = [(doc, 0.0) for doc in docs] # MMR doesn't return scores
else:
docs_with_scores = similarity_search(question, k=k)
if metadata_filter:
from retriever import filtered_search
filtered_docs = filtered_search(question, metadata_filter, k=k)
docs_with_scores = [(doc, 0.0) for doc in filtered_docs]
if not docs_with_scores:
return {
"answer": "I could not find any relevant documents to answer your question.",
"sources": [],
"context": "",
}
# Format context
context = format_docs(docs_with_scores)
# Build prompt
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("human", HUMAN_PROMPT),
])
# Build chain
llm = get_llm()
chain = prompt | llm
# Generate
answer = chain.invoke({
"context": context,
"question": question,
})
return {
"answer": answer,
"sources": docs_with_scores,
"context": context,
}
|
The system prompt is doing important work here. Instructing the model to answer only from the provided context — and to explicitly say when it cannot — is the main defense against hallucination in a RAG system. Models will tend to confabulate if you do not actively tell them not to. The phrase “do not guess or hallucinate” in the system prompt measurably reduces confabulation in practice.
Complete End-to-End Script
Here is rag.py — a single runnable script that indexes a directory of documents and answers questions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
|
#!/usr/bin/env python3
"""
rag.py — Complete RAG pipeline in a single script.
Usage:
# Index a directory of documents
python rag.py index ./my_docs
# Ask a question
python rag.py ask "How do I configure nginx for SSL termination?"
# Ask with MMR retrieval (better diversity)
python rag.py ask "What are the backup procedures?" --mmr
# Ask with metadata filter
python rag.py ask "database schema" --filter '{"doc_type": "technical"}'
"""
import sys
import json
import argparse
from pathlib import Path
from typing import List
from langchain_community.document_loaders import (
PyPDFLoader,
DirectoryLoader,
TextLoader,
UnstructuredMarkdownLoader,
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings, OllamaLLM
from langchain_community.vectorstores import Chroma
from langchain.prompts import ChatPromptTemplate
from langchain.schema import Document
# --- Configuration ---
CHROMA_PERSIST_DIR = "./chroma_db"
COLLECTION_NAME = "my_documents"
EMBEDDING_MODEL = "nomic-embed-text"
LLM_MODEL = "llama3.2"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
SYSTEM_PROMPT = """You are a helpful assistant that answers questions based strictly on the provided context documents.
Rules:
- Answer only using information present in the context below.
- If the context does not contain enough information to answer the question, say so explicitly.
- When referencing specific information, mention which document it came from.
- Be concise and precise. Do not add information that is not in the context.
Context:
{context}
"""
# --- Indexing ---
def load_documents(directory: str) -> List[Document]:
docs = []
path = Path(directory)
if not path.exists():
raise FileNotFoundError(f"Directory not found: {directory}")
loaders = [
("**/*.pdf", PyPDFLoader),
("**/*.md", UnstructuredMarkdownLoader),
("**/*.txt", TextLoader),
]
for glob_pattern, loader_cls in loaders:
if list(path.glob(glob_pattern)):
kwargs = {}
if loader_cls == TextLoader:
kwargs["loader_kwargs"] = {"encoding": "utf-8"}
loader = DirectoryLoader(
directory,
glob=glob_pattern,
loader_cls=loader_cls,
show_progress=True,
**kwargs,
)
loaded = loader.load()
docs.extend(loaded)
print(f" Loaded {len(loaded)} documents matching {glob_pattern}")
return docs
def split_documents(docs: List[Document]) -> List[Document]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_documents(docs)
return chunks
def index_documents(directory: str) -> None:
print(f"\n=== Indexing documents from: {directory} ===\n")
print("Loading documents...")
docs = load_documents(directory)
if not docs:
print("No documents found. Check the directory and supported formats (.pdf, .md, .txt).")
sys.exit(1)
print(f"Total documents loaded: {len(docs)}")
print("\nSplitting into chunks...")
chunks = split_documents(docs)
avg_size = sum(len(c.page_content) for c in chunks) // len(chunks)
print(f"Total chunks: {len(chunks)} | Average chunk size: {avg_size} chars")
print(f"\nEmbedding and storing in ChromaDB (model: {EMBEDDING_MODEL})...")
print("(This may take a few minutes for large document sets.)\n")
embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
collection_name=COLLECTION_NAME,
persist_directory=CHROMA_PERSIST_DIR,
)
count = vectorstore._collection.count()
print(f"\nIndexing complete. {count} chunks stored at {CHROMA_PERSIST_DIR}")
# --- Querying ---
def load_vectorstore() -> Chroma:
embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL)
return Chroma(
collection_name=COLLECTION_NAME,
embedding_function=embeddings,
persist_directory=CHROMA_PERSIST_DIR,
)
def format_docs_for_prompt(docs_with_scores) -> str:
parts = []
for i, (doc, score) in enumerate(docs_with_scores, 1):
source = doc.metadata.get("source", "unknown")
page = doc.metadata.get("page", "")
loc = f"{source}" + (f" (page {page})" if page else "")
parts.append(
f"[Chunk {i} | {loc} | score={score:.3f}]\n{doc.page_content}"
)
return "\n\n---\n\n".join(parts)
def answer_question(
question: str,
k: int = 5,
use_mmr: bool = False,
metadata_filter: dict = None,
) -> None:
print(f"\n=== Question: {question} ===\n")
vectorstore = load_vectorstore()
# Retrieve
if use_mmr:
raw_docs = vectorstore.max_marginal_relevance_search(
question, k=k, fetch_k=k * 4, lambda_mult=0.5
)
docs_with_scores = [(doc, 0.0) for doc in raw_docs]
elif metadata_filter:
raw_docs = vectorstore.similarity_search(
question, k=k, filter=metadata_filter
)
docs_with_scores = [(doc, 0.0) for doc in raw_docs]
else:
docs_with_scores = vectorstore.similarity_search_with_relevance_scores(
question, k=k
)
if not docs_with_scores:
print("No relevant documents found.")
return
# Show retrieved chunks
print(f"Retrieved {len(docs_with_scores)} chunks:\n")
for i, (doc, score) in enumerate(docs_with_scores, 1):
source = doc.metadata.get("source", "unknown")
preview = doc.page_content[:120].replace("\n", " ")
print(f" [{i}] score={score:.3f} | {source}")
print(f" \"{preview}...\"")
print()
# Build prompt and generate
context = format_docs_for_prompt(docs_with_scores)
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("human", "Question: {question}"),
])
llm = OllamaLLM(model=LLM_MODEL, temperature=0.1)
chain = prompt | llm
print("=== Answer ===\n")
answer = chain.invoke({"context": context, "question": question})
print(answer)
print("\n=== Sources ===")
seen = set()
for doc, score in docs_with_scores:
source = doc.metadata.get("source", "unknown")
if source not in seen:
seen.add(source)
print(f" - {source}")
# --- CLI ---
def main():
parser = argparse.ArgumentParser(description="RAG from Scratch")
subparsers = parser.add_subparsers(dest="command")
# Index command
idx = subparsers.add_parser("index", help="Index a directory of documents")
idx.add_argument("directory", help="Path to the document directory")
# Ask command
ask_p = subparsers.add_parser("ask", help="Ask a question")
ask_p.add_argument("question", help="The question to answer")
ask_p.add_argument("--k", type=int, default=5, help="Number of chunks to retrieve")
ask_p.add_argument("--mmr", action="store_true", help="Use MMR for diverse retrieval")
ask_p.add_argument("--filter", type=str, default=None, help="JSON metadata filter")
args = parser.parse_args()
if args.command == "index":
index_documents(args.directory)
elif args.command == "ask":
metadata_filter = json.loads(args.filter) if args.filter else None
answer_question(
args.question,
k=args.k,
use_mmr=args.mmr,
metadata_filter=metadata_filter,
)
else:
parser.print_help()
if __name__ == "__main__":
main()
|
Run it:
1
2
3
4
5
6
7
|
# Index your documents
python rag.py index ./docs
# Ask questions
python rag.py ask "What is our incident response procedure?"
python rag.py ask "How do I deploy to production?" --mmr
python rag.py ask "nginx configuration" --filter '{"source": "runbooks/nginx.md"}'
|
Practical Considerations: Chunking Strategy
Chunking is one of the highest-leverage decisions in a RAG system. Too small, and each chunk lacks sufficient context for the LLM to form a coherent answer. Too large, and a single chunk covers multiple topics, diluting retrieval precision (the chunk might be retrieved for the wrong reason) and increasing token costs.
General Guidelines
| Document type |
Recommended chunk size |
Notes |
| Technical documentation |
512–1024 chars |
Dense, precise — smaller chunks improve precision |
| Narrative / prose |
1000–2000 chars |
Needs paragraph-level context |
| API reference |
256–512 chars |
One endpoint or function per chunk |
| Source code |
Function-level |
Use language-aware splitters |
| Q&A pairs (FAQ) |
One Q&A per chunk |
Natural unit boundary |
| Legal / policy |
512–1000 chars |
Dense language rewards smaller chunks |
Always include overlap. A good starting point is 15–20% of your chunk size. Overlap should increase when:
- The document has many continuous narratives (the same idea spans multiple paragraphs)
- You are seeing poor retrieval quality at chunk boundaries
- Chunks feel incomplete when returned to the LLM
Document-Specific Issues
PDFs: Page headers, footers, and page numbers get extracted as text and end up in your chunks. Pre-process to strip these. Multi-column layouts can garble sentence order when extracted linearly. Check the extracted text before indexing — PyPDFLoader will show you the raw content.
Markdown: Split by header hierarchy first (using MarkdownHeaderTextSplitter), then further split large sections. This ensures each chunk stays within a logical section of the document.
Source code: Never split in the middle of a function or class. Use language-aware splitters that treat function and class definitions as natural boundaries. Include import statements or module-level constants in each chunk when possible — they provide essential context.
HTML / web pages: Strip navigation, sidebars, and footers before chunking. BeautifulSoup or trafilatura can extract just the main content.
Parent-Child Chunking
An advanced pattern that significantly improves answer quality: store two representations of each document section.
- Small chunks (256–512 chars) are used for retrieval — their precision makes similarity search more accurate.
- Large parent chunks (1500–3000 chars) are stored alongside and retrieved after the small chunk is found — their size gives the LLM sufficient context.
When a small chunk matches the query, you look up its parent chunk ID and return the parent text to the LLM. LangChain’s ParentDocumentRetriever implements this pattern.
Improving Retrieval Quality
The default similarity search works well for clear, well-formed questions. It starts breaking down when the user’s query uses different vocabulary than the indexed documents, when questions are vague, or when relevant information is spread across multiple documents.
Query Expansion
Instead of searching with just the original query, generate multiple reformulations and search with all of them. Results are merged (with deduplication).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
from langchain_ollama import OllamaLLM
from langchain.prompts import PromptTemplate
def expand_query(question: str, n: int = 3) -> list[str]:
"""Generate n alternative phrasings of a question for improved recall."""
llm = OllamaLLM(model="llama3.2", temperature=0.7)
prompt = PromptTemplate.from_template(
"Generate {n} different ways to phrase this question for a document search. "
"Output only the alternative questions, one per line, with no numbering or explanation.\n\n"
"Original question: {question}"
)
result = llm.invoke(prompt.format(n=n, question=question))
alternatives = [q.strip() for q in result.strip().split("\n") if q.strip()]
return [question] + alternatives[:n]
|
HyDE: Hypothetical Document Embeddings
HyDE is a clever approach to the vocabulary mismatch problem. Instead of embedding the user’s short question, you ask the LLM to generate a hypothetical answer to the question — a paragraph that looks like it could have been in a document. You then embed that hypothetical answer and use it for similarity search. The hypothetical answer uses the vocabulary of the domain, so it matches indexed document language better than the raw question.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
def hyde_search(question: str, k: int = 5) -> list:
"""
Hypothetical Document Embeddings search.
Generate a hypothetical answer, embed it, search with that embedding.
"""
llm = OllamaLLM(model="llama3.2", temperature=0.5)
prompt = PromptTemplate.from_template(
"Write a detailed technical paragraph that answers this question. "
"Write as if you are a technical document, not as if you are directly answering. "
"If you don't know the answer, make a plausible technical response anyway.\n\n"
"Question: {question}\n\nParagraph:"
)
hypothetical_doc = llm.invoke(prompt.format(question=question))
vectorstore = load_vectorstore()
return vectorstore.similarity_search(hypothetical_doc, k=k)
|
Re-Ranking
Initial vector similarity retrieves candidates quickly but imprecisely. A cross-encoder re-ranker reads the query and each candidate chunk together (not as separate embeddings), producing a much more accurate relevance score. The workflow:
- Retrieve top-20 candidates via vector similarity (fast, approximate)
- Re-rank all 20 with a cross-encoder (slower but more accurate)
- Take the top-5 re-ranked results to send to the LLM
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
from sentence_transformers import CrossEncoder
def rerank_results(query: str, docs: list, top_k: int = 5) -> list:
"""
Re-rank retrieved documents using a cross-encoder.
More accurate than embedding similarity alone.
"""
model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
# Score each (query, document) pair
pairs = [[query, doc.page_content] for doc in docs]
scores = model.predict(pairs)
# Sort by score descending
scored = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, score in scored[:top_k]]
# Usage: retrieve 20 candidates, re-rank to top 5
candidates = vectorstore.similarity_search(question, k=20)
reranked = rerank_results(question, candidates, top_k=5)
|
Install sentence-transformers for this:
1
|
pip install sentence-transformers
|
The cross-encoder/ms-marco-MiniLM-L-6-v2 model is small (22M parameters), fast on CPU, and significantly improves precision over raw vector similarity in most benchmarks.
Building a Chat UI with Streamlit
Once the indexing pipeline works, a Streamlit app gives you a usable interface with file upload, chat history, and source display.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
|
# app.py
import streamlit as st
from pathlib import Path
import tempfile
import shutil
import os
from langchain_community.document_loaders import PyPDFLoader, TextLoader, UnstructuredMarkdownLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings, OllamaLLM
from langchain_community.vectorstores import Chroma
from langchain.prompts import ChatPromptTemplate
# --- Config ---
EMBEDDING_MODEL = "nomic-embed-text"
LLM_MODEL = "llama3.2"
CHROMA_DIR = "./streamlit_chroma_db"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
SYSTEM_PROMPT = """You are a helpful assistant. Answer questions based only on the provided context.
If the context does not contain the answer, say so clearly. Do not fabricate information.
Context:
{context}"""
def index_uploaded_files(uploaded_files) -> Chroma:
"""Process uploaded files and build a vector index."""
docs = []
with tempfile.TemporaryDirectory() as tmpdir:
for f in uploaded_files:
file_path = os.path.join(tmpdir, f.name)
with open(file_path, "wb") as out:
out.write(f.getbuffer())
if f.name.endswith(".pdf"):
loader = PyPDFLoader(file_path)
elif f.name.endswith(".md"):
loader = UnstructuredMarkdownLoader(file_path)
else:
loader = TextLoader(file_path, encoding="utf-8")
docs.extend(loader.load())
splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP
)
chunks = splitter.split_documents(docs)
embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL)
# Clear and rebuild index
if os.path.exists(CHROMA_DIR):
shutil.rmtree(CHROMA_DIR)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=CHROMA_DIR,
)
return vectorstore
def get_answer(question: str, vectorstore: Chroma) -> dict:
"""Run the RAG pipeline for a single question."""
docs_with_scores = vectorstore.similarity_search_with_relevance_scores(question, k=5)
context_parts = []
for i, (doc, score) in enumerate(docs_with_scores, 1):
source = doc.metadata.get("source", "uploaded file")
context_parts.append(f"[{i}] (source: {source})\n{doc.page_content}")
context = "\n\n---\n\n".join(context_parts)
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("human", "{question}"),
])
llm = OllamaLLM(model=LLM_MODEL, temperature=0.1)
chain = prompt | llm
answer = chain.invoke({"context": context, "question": question})
sources = list({doc.metadata.get("source", "unknown") for doc, _ in docs_with_scores})
return {"answer": answer, "sources": sources}
# --- Streamlit UI ---
st.set_page_config(page_title="RAG Chatbot", page_icon="", layout="wide")
st.title("Document Q&A — RAG Chatbot")
with st.sidebar:
st.header("Upload Documents")
uploaded_files = st.file_uploader(
"Upload PDF, Markdown, or Text files",
type=["pdf", "md", "txt"],
accept_multiple_files=True,
)
if uploaded_files and st.button("Index Documents", type="primary"):
with st.spinner(f"Indexing {len(uploaded_files)} file(s)..."):
st.session_state.vectorstore = index_uploaded_files(uploaded_files)
st.session_state.indexed_files = [f.name for f in uploaded_files]
st.success(f"Indexed {len(uploaded_files)} file(s)")
if "indexed_files" in st.session_state:
st.subheader("Indexed files:")
for fname in st.session_state.indexed_files:
st.write(f"- {fname}")
# Chat interface
if "messages" not in st.session_state:
st.session_state.messages = []
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.write(msg["content"])
if msg.get("sources"):
with st.expander("Sources"):
for s in msg["sources"]:
st.write(f"- {s}")
if prompt := st.chat_input("Ask a question about your documents..."):
if "vectorstore" not in st.session_state:
st.error("Please upload and index documents first.")
else:
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.write(prompt)
with st.chat_message("assistant"):
with st.spinner("Thinking..."):
result = get_answer(prompt, st.session_state.vectorstore)
st.write(result["answer"])
if result["sources"]:
with st.expander("Sources"):
for s in result["sources"]:
st.write(f"- {s}")
st.session_state.messages.append({
"role": "assistant",
"content": result["answer"],
"sources": result["sources"],
})
|
Run with:
1
2
|
pip install streamlit
streamlit run app.py
|
Docker Compose for the Full Stack
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
# docker-compose.yml
services:
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# Remove the deploy block if you don't have a GPU
rag-app:
build: .
ports:
- "8501:8501"
environment:
- OLLAMA_BASE_URL=http://ollama:11434
depends_on:
- ollama
volumes:
- ./chroma_db:/app/chroma_db
- ./docs:/app/docs
volumes:
ollama_data:
|
1
2
3
4
5
6
7
8
9
10
|
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8501
CMD ["streamlit", "run", "app.py", "--server.address", "0.0.0.0"]
|
Evaluation: How Good Is Your RAG?
A RAG system can degrade silently. You change the chunk size, swap embedding models, or add new documents, and suddenly answers are worse — but without a measurement framework you will not catch it until users complain. Evaluation is not optional for any serious RAG deployment.
The Four Key Metrics
Faithfulness: Does the answer come from the retrieved context, or is the LLM adding information that was not there? This is the hallucination metric. An answer is faithful if every claim in it can be traced back to the retrieved chunks.
Answer relevance: Does the answer actually address the user’s question? A faithful answer can still be irrelevant (the model answers a different question that happened to be grounded in the context).
Context precision: Of the chunks retrieved, what fraction are actually relevant to the question? High retrieval quantity with low precision wastes the LLM’s context window.
Context recall: Are all the document chunks that are relevant to the question actually being retrieved? Low recall means the LLM is answering with partial information.
RAGAs: Automated Evaluation
RAGAs (Retrieval-Augmented Generation Assessment) is a Python library that automates the four metrics above. It uses an LLM to judge faithfulness and relevance, and can generate test questions automatically from your documents.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
# evaluate.py
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall,
)
from datasets import Dataset
def evaluate_rag_pipeline(qa_pairs: list[dict]) -> dict:
"""
Evaluate a RAG pipeline on a set of question-answer pairs.
qa_pairs: list of dicts with keys:
- question: str
- answer: str (the RAG system's answer)
- contexts: list[str] (the retrieved chunks)
- ground_truth: str (the correct answer, for recall calculation)
"""
dataset = Dataset.from_list(qa_pairs)
results = evaluate(
dataset,
metrics=[
faithfulness,
answer_relevancy,
context_precision,
context_recall,
],
)
return results
# Example usage
sample_qa = [
{
"question": "What is the maximum upload file size?",
"answer": "The maximum upload file size is 50MB as configured in nginx.conf.",
"contexts": [
"The nginx configuration sets client_max_body_size to 50m, limiting uploads to 50MB.",
"Files larger than 50MB will receive a 413 Request Entity Too Large error.",
],
"ground_truth": "The maximum file upload size is 50MB.",
},
]
scores = evaluate_rag_pipeline(sample_qa)
print(scores)
# Output: {'faithfulness': 0.97, 'answer_relevancy': 0.95, ...}
|
The LLM-as-Judge Pattern
For faster iteration without a dedicated evaluation framework, use an LLM to score individual answers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
def judge_answer(question: str, answer: str, context: str) -> dict:
"""Use an LLM to evaluate RAG output quality."""
llm = OllamaLLM(model="llama3.2", temperature=0.0)
prompt = f"""Evaluate this RAG system response on three dimensions.
Score each 1-5 (5 = best). Return JSON only.
Question: {question}
Retrieved Context:
{context}
System Answer: {answer}
Evaluate:
1. faithfulness (1-5): Is the answer fully supported by the context? Penalize any claims not in context.
2. relevance (1-5): Does the answer address the question?
3. completeness (1-5): Does the answer cover all relevant information from the context?
Return only valid JSON: {{"faithfulness": N, "relevance": N, "completeness": N, "reasoning": "brief note"}}"""
result = llm.invoke(prompt)
import json
return json.loads(result.strip())
|
Production Considerations and Next Steps
Keeping the Index Fresh
Documents change. When they do, you have two options: full re-index (simple but slow for large corpora) or incremental updates.
For incremental updates, track which documents have been indexed and when:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
import hashlib
import json
from pathlib import Path
def get_file_hash(file_path: str) -> str:
with open(file_path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
def load_index_manifest(manifest_path: str = "./index_manifest.json") -> dict:
if Path(manifest_path).exists():
with open(manifest_path) as f:
return json.load(f)
return {}
def save_index_manifest(manifest: dict, manifest_path: str = "./index_manifest.json"):
with open(manifest_path, "w") as f:
json.dump(manifest, f, indent=2)
def get_changed_files(directory: str, manifest: dict) -> list[str]:
"""Return files that are new or changed since last index."""
changed = []
for path in Path(directory).rglob("*"):
if path.is_file() and path.suffix in {".pdf", ".md", ".txt"}:
file_hash = get_file_hash(str(path))
if manifest.get(str(path)) != file_hash:
changed.append(str(path))
return changed
|
Access Control
For multi-user systems where different users should see different documents, the cleanest approach is metadata filtering. Tag each chunk with the user or group that owns it:
1
2
3
4
5
6
7
8
9
|
# When indexing, add user/group metadata
chunk.metadata["owner"] = "team-ops"
chunk.metadata["access_level"] = "internal"
# At query time, filter to the current user's documents
results = vectorstore.similarity_search(
query,
filter={"owner": current_user_team}
)
|
For stronger isolation (where metadata filter bypass is a concern), maintain separate ChromaDB collections or separate Qdrant namespaces per user or group.
Advanced Architectures
Agentic RAG: Rather than always retrieving, an agent LLM decides whether retrieval is needed and what query to use. This is more flexible — the agent can reformulate the query, retrieve iteratively, or decide to answer from its own knowledge when retrieval is not helpful. LangChain’s ReAct agent pattern implements this.
Multi-hop RAG: For questions that require combining information from multiple documents (“What does our nginx policy say about SSL, and how does that interact with our load balancer configuration?”), a single retrieval is often insufficient. Multi-hop RAG chains multiple retrievals: retrieve for the first sub-question, extract relevant facts, then retrieve again for the second sub-question informed by the first.
GraphRAG: Microsoft’s approach combines knowledge graph extraction with vector search. Named entities and their relationships are extracted from documents and stored as a graph. Queries traverse the graph to find relevant entities, then retrieve the document chunks that mention them. Significantly better for questions about relationships between entities. Available as the open-source graphrag Python package.
Scaling Beyond ChromaDB
ChromaDB is the right starting point. When you outgrow it:
- Qdrant for up to tens of millions of vectors, self-hosted, strong filtering
- Weaviate when hybrid search (vector + keyword) is important
- pgvector when you want to stay within your existing Postgres infrastructure
- Milvus or Pinecone for billion-vector scale
Observability
Log everything: the query, the retrieved chunks (with scores), the full prompt sent to the LLM, the response, and the end-to-end latency. This data is your feedback loop for improvement.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import time
import logging
logger = logging.getLogger("rag")
def ask_with_logging(question: str) -> dict:
start = time.time()
result = ask(question)
elapsed = time.time() - start
logger.info({
"query": question,
"num_chunks": len(result["sources"]),
"top_score": result["sources"][0][1] if result["sources"] else 0,
"latency_ms": int(elapsed * 1000),
"answer_length": len(result["answer"]),
})
return result
|
LlamaIndex — an alternative to LangChain with different abstractions. Stronger built-in support for advanced indexing strategies (knowledge graphs, document hierarchies, multi-step query engines). Worth evaluating if LangChain’s abstractions feel too leaky.
Haystack — production-focused RAG and NLP pipeline framework from deepset. Strong evaluation tooling, pipeline-as-code approach. Excellent for teams that need maintainable, testable RAG pipelines.
DSPy — a different paradigm entirely. Instead of handcrafting prompts, DSPy optimizes them automatically given a set of training examples and a metric. Promising for teams willing to invest in the paradigm shift.
Where to Go From Here
The system built in this post is a solid foundation. It runs entirely locally, handles multiple document formats, and exposes a clean interface for both CLI and web use. The next improvements that will have the most impact, roughly in order of effort-to-reward ratio:
- Add re-ranking — cross-encoder re-ranking is a near-universal improvement for retrieval precision with relatively little code.
- Implement incremental indexing — essential once documents change frequently; the hash-based manifest approach above works well.
- Set up RAGAs evaluation — build a golden question set from your documents and track your scores as you make changes.
- Try MMR by default — if your corpus has redundant content, MMR should be your default retrieval strategy rather than pure similarity.
- Explore parent-child chunking — if answers frequently feel incomplete, this pattern usually helps.
RAG is a proven technique with a rapidly maturing tooling ecosystem. The gap between a prototype and a production system is real but tractable. The systems that work best in production are those where someone has invested in evaluation — treating “does this answer correctly?” as a measurable engineering metric rather than a vibes check.
Comments