Project: chat with documents
Time to put every piece together. In this project you will build docqa.py — a command-line application that indexes a folder of documents and answers questions about them with citations. It uses heading-aware chunking, a vector store, hybrid retrieval with reciprocal rank fusion, a relevance threshold, and a grounded prompt. It runs fully offline with an extractive answer mode, and switches to a real LLM with one environment variable.
What you are building
docs/*.md
│ load + heading-aware chunking
▼
chunks (id, source, heading, text)
│ embed │ tokenize
▼ ▼
vector index BM25 keyword index
└───────────┬───────────────┘
│ hybrid search + RRF + threshold
▼
top-k chunks ──► grounded prompt ──► LLM (or offline extractive answer)
│
▼
answer + [n] citations + sources list
Step 1 — Sample documents
from pathlib import Path
DOCS = {
"refunds.md": """# Refunds
## Annual plans
Annual plans can be refunded within 30 days of purchase. Refunds go back to the original card.
## Monthly plans
Monthly plans are not refunded. You can cancel a monthly plan at any time from Settings.
""",
"backups.md": """# Backups
## Schedule
Backups run every night at 2 AM in your local time zone.
## Retention
Deleted files are kept for 90 days. After 90 days they are permanently removed.
## Errors
Error E-504 means the backup server timed out. Wait five minutes and retry.
""",
"security.md": """# Security
## Encryption
All backups are encrypted with AES-256 at rest and TLS in transit.
## Admin accounts
Admins must enable two-factor authentication within 7 days of account creation.
""",
}
folder = Path("docs")
folder.mkdir(exist_ok=True)
for name, text in DOCS.items():
(folder / name).write_text(text, encoding="utf-8")
print(sorted(p.name for p in folder.glob("*.md")))
Step 2 — The application
The whole application is one file. Each function maps to a lesson in this course.
"""docqa.py - chat with a folder of Markdown documents."""
from __future__ import annotations
import hashlib
import math
import os
import re
import sys
from collections import Counter
from pathlib import Path
import numpy as np
FALLBACK = "I couldn't find that in the documentation."
STOPWORDS = {"a", "an", "the", "is", "are", "do", "does", "i", "my", "to", "of", "in",
"for", "and", "what", "how", "can", "it", "on", "be", "you", "your", "after"}
MIN_SIMILARITY = 0.2 # vector hits below this are treated as unrelated
# ---------- loading and chunking (lesson: Loading and chunking) ----------
def load_chunks(folder: Path) -> list[dict]:
chunks = []
for path in sorted(folder.glob("*.md")):
path_headings: list[str] = []
for block in re.split(r"\n(?=#)", path.read_text(encoding="utf-8")):
lines = block.strip().splitlines()
if not lines or not lines[0].startswith("#"):
continue
level = len(lines[0]) - len(lines[0].lstrip("#"))
path_headings[:] = path_headings[: level - 1] + [lines[0].lstrip("#").strip()]
body = " ".join(l.strip() for l in lines[1:] if l.strip())
if body:
heading = " > ".join(path_headings)
chunks.append({"id": f"{path.name}#{len(chunks)}", "source": path.name,
"heading": heading, "text": f"{heading}: {body}"})
return chunks
# ---------- embeddings (lesson: Embeddings) - swap in a real model here ----------
def words(text: str) -> list[str]:
out = []
for w in re.findall(r"[a-z0-9-]+", text.lower()):
if w in STOPWORDS:
continue
for suffix in ("ing", "ed", "s"):
if w.endswith(suffix) and len(w) > len(suffix) + 3:
w = w[: -len(suffix)]
break
out.append(w)
return out
def embed(text: str, dim: int = 512) -> np.ndarray:
vec = np.zeros(dim, dtype=np.float32)
for w in words(text):
vec[int(hashlib.md5(w.encode()).hexdigest(), 16) % dim] += 1
n = np.linalg.norm(vec)
return vec / n if n else vec
# ---------- retrieval (lessons: Vector stores, Retrieval patterns) ----------
class Index:
def __init__(self, chunks: list[dict]):
self.chunks = chunks
self.vectors = np.stack([embed(c["text"]) for c in chunks])
self.tokens = [words(c["text"]) for c in chunks]
self.df = Counter(t for toks in self.tokens for t in set(toks))
self.avg_len = sum(map(len, self.tokens)) / len(self.tokens)
def vector_rank(self, query: str) -> list[int]:
scores = self.vectors @ embed(query)
return [int(i) for i in np.argsort(-scores) if scores[i] >= MIN_SIMILARITY]
def bm25_rank(self, query: str, k1: float = 1.5, b: float = 0.75) -> list[int]:
n, scores = len(self.chunks), []
for i, toks in enumerate(self.tokens):
tf, s = Counter(toks), 0.0
for t in words(query):
if t in tf:
idf = math.log(1 + (n - self.df[t] + 0.5) / (self.df[t] + 0.5))
s += idf * tf[t] * (k1 + 1) / (tf[t] + k1 * (1 - b + b * len(toks) / self.avg_len))
scores.append(s)
return [i for i in sorted(range(n), key=lambda i: -scores[i]) if scores[i] > 0]
def search(self, query: str, k: int = 3) -> list[dict]:
fused: dict[int, float] = {}
for ranking in (self.vector_rank(query), self.bm25_rank(query)):
for rank, i in enumerate(ranking, start=1):
fused[i] = fused.get(i, 0.0) + 1 / (60 + rank)
best = sorted(fused, key=lambda i: -fused[i])[:k]
return [{**self.chunks[i], "score": round(fused[i], 4)} for i in best]
# ---------- generation (lesson: Generation with sources) ----------
def build_messages(question: str, hits: list[dict]) -> list[dict]:
sources = "\n".join(f"[{i}] ({h['source']}) {h['text']}" for i, h in enumerate(hits, 1))
system = ("Answer using ONLY the numbered sources. End each sentence with a citation like [1]. "
f'If the answer is not in the sources, reply exactly: "{FALLBACK}"')
return [{"role": "system", "content": system},
{"role": "user", "content": f"<sources>\n{sources}\n</sources>\n\nQuestion: {question}"}]
def extractive_answer(question: str, hits: list[dict]) -> str:
"""Offline mode: return the source sentence that best matches the question."""
q = set(words(question))
best, best_overlap = None, 0
for n, h in enumerate(hits, start=1):
body = h["text"].split(": ", 1)[-1]
for sentence in re.split(r"(?<=\.)\s+", body):
overlap = len(q & set(words(sentence)))
if overlap > best_overlap:
best, best_overlap = f"{sentence} [{n}]", overlap
return best if best_overlap >= 2 else FALLBACK
def llm_answer(question: str, hits: list[dict]) -> str:
from openai import OpenAI
response = OpenAI().chat.completions.create(
model=os.getenv("MODEL_NAME", "gpt-4.1-mini"),
messages=build_messages(question, hits), temperature=0)
return response.choices[0].message.content
def ask(index: Index, question: str) -> str:
hits = index.search(question)
if not hits:
return FALLBACK
use_llm = os.getenv("DOCQA_USE_LLM") == "1"
answer = llm_answer(question, hits) if use_llm else extractive_answer(question, hits)
if answer == FALLBACK:
return answer
sources = "\n".join(f" [{i}] {h['source']} - {h['heading']}" for i, h in enumerate(hits, 1))
return f"{answer}\nSources:\n{sources}"
if __name__ == "__main__":
index = Index(load_chunks(Path(sys.argv[1] if len(sys.argv) > 1 else "docs")))
print(f"indexed {len(index.chunks)} chunks")
for question in sys.argv[2:] or ["How long are deleted files kept?"]:
print(f"\nQ: {question}\nA: {ask(index, question)}")
Step 3 — Run it
python make_docs.py
python docqa.py docs "How long are deleted files kept?" "What does error E-504 mean?" "Can I get a refund on a monthly plan?" "Who is the CEO?"
Every answer carries a citation and a list of the sources that were retrieved, and the question with no supporting document gets the fallback instead of an invented answer.
Step 4 — Switch to a real LLM
export OPENAI_API_KEY=sk-...
export DOCQA_USE_LLM=1
python docqa.py docs "Can I get a refund on a monthly plan?"
The LLM synthesises a fuller answer from several sources while keeping citations. Wording varies run to run.
Step 5 — Upgrade to production components
| Component in docqa.py | Production upgrade |
|---|---|
Toy embed() | text-embedding-3-small or a sentence-transformers model, batched and cached by hash |
In-memory Index | Chroma or pgvector with HNSW and metadata filters |
| Hand-written BM25 | Database full-text search or rank-bm25 |
| No reranker | Cross-encoder reranker over the top 30 |
| Extractive offline answer | LLM with structured output: answer + list of citations |
| CLI | FastAPI endpoint + streaming chat UI |
| Re-index on start | Incremental indexing job triggered by document changes |
Step 6 — Evaluate it
- Write 20 questions with the expected source chunk and a reference answer, including five unanswerable ones.
- Measure retrieval with recall@3 and MRR (Retrieval patterns lesson).
- Measure answers with citation validity, faithfulness, and refusal accuracy (Generation with sources lesson).
- Re-run the evaluation after every change to chunking, models, or prompts.
- Add an interactive loop with
input()so you can ask several questions in one session. - Add a
--productmetadata filter by tagging chunks with the file name. - Replace the toy embedding with sentence-transformers and compare answers for paraphrased questions such as “Is my data encrypted?”.
- Add conversation history with query rewriting for follow-up questions.
- A complete RAG app is loading, chunking, embedding, indexing, hybrid retrieval, grounded generation, and citations.
- Heading-aware chunks produce self-contained passages and readable source labels.
- Hybrid search with RRF and a relevance threshold gives robust retrieval and honest fallbacks.
- Keep components swappable so the toy parts can be replaced by production services one at a time.
- Evaluate retrieval and generation separately, and re-run evaluations after every change.
Where to go next
You have completed Building RAG Systems. Continue with Generative AI & Agents to give your assistant tools — including retrieval as a tool — and build agents that act, not just answer.