Docs Copilot
Buildable now. The repo, the spec and the deployment steps are live β the written walkthrough for this one is still being drafted.
A metadata-aware RAG documentation assistant with section-level citations, freshness tracking on the underlying docs, and an explicit βI donβt have enough informationβ threshold instead of a confident-sounding guess. This is RAG again β but now judged on trustworthiness, not just retrieval accuracy, which is the harder and more realistic bar.
Section-level citations and a confidence threshold are exactly the trust-building details that separate a demo RAG bot from one a team would actually adopt internally.
| Field | Value |
|---|---|
| Path | 2 β AI-Augmented Engineering |
| Position | 9 of 12 |
| Difficulty | π‘ Optional Docker |
| Estimated time | 4 hours |
| AWS cost | None |
Agent Pickup Instructions
# 1. Enter project
cd projects/p2-09-docs-copilot
# 2. Create virtual environment
python -m venv .venv && source .venv/bin/activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. Copy env file and add your API key
cp .env.example .env
# 5. Index your documentation folder
python src/main.py --index docs/
# 6. Ask a question
python src/main.py --ask "How do I configure the database connection?"
# 7. Force rebuild if docs changed
python src/main.py --rebuild
# 8. Run tests
pytest tests/ -v
Done when:
-
--indexprocesses Markdown and PDF files, attaches metadata to every chunk -
--askreturns an answer with citations in[source > section]format - Freshness check detects a changed file and automatically re-indexes it
- A low-confidence question returns "I don't know" instead of a hallucinated answer
- All 4 tests pass
What this project is
A RAG-based documentation assistant that differs from a basic RAG system in three key ways: metadata-aware chunking (section headings preserved in every chunk), freshness tracking (file modification times checked on each run to detect stale index entries), and a confidence threshold below which the system returns "I don't know" rather than generating a potentially incorrect answer. Citations appear in [source > section_heading] format in every answer, linking the response to the exact document section it came from.
What the learner achieves
"I built a metadata-aware RAG system over team docs with section-level citations and a freshness check that re-indexes changed files automatically β and an 'I don't know' threshold to prevent hallucination when the docs don't contain the answer."
Folder structure
p2-09-docs-copilot/
βββ src/
β βββ main.py # CLI: --index <dir>, --ask "...", --rebuild
β βββ llm.py # Provider-agnostic LLM wrapper
β βββ chunker.py # markdown_chunk_by_section, pdf_chunk_with_heading_detection, attach_metadata
β βββ indexer.py # build_with_metadata, load, search_with_metadata, check_freshness_and_reindex
β βββ copilot.py # build_answer_with_citations, handle_below_threshold
βββ tests/
β βββ fixtures/
β β βββ docs/
β β β βββ setup_guide.md # Multi-section markdown doc
β β β βββ api_reference.md # Another markdown doc
β β βββ sample.pdf # Small test PDF (optional)
β βββ test_chunker.py
β βββ test_indexer.py
β βββ test_copilot.py
βββ .index/ # Auto-created
β βββ index.faiss
β βββ metadata.json
β βββ file_mtimes.json
βββ .env.example
βββ requirements.txt
βββ README.md
.env.example
# LLM provider: anthropic | openai | ollama
LLM_PROVIDER=anthropic
# Model to use
LLM_MODEL=
# API key for Anthropic
ANTHROPIC_API_KEY=
# API key for OpenAI
OPENAI_API_KEY=
# Ollama base URL
OLLAMA_BASE_URL=http://localhost:11434
# Embedding model for RAG
EMBED_MODEL=all-MiniLM-L6-v2
# Index storage directory
INDEX_DIR=.index
# Number of chunks to retrieve per query
TOP_K=5
# Confidence threshold below which "I don't know" is returned (0.0 to 1.0)
# FAISS inner product score β tune based on your embedding model
CONFIDENCE_THRESHOLD=0.5
# Maximum characters per chunk (for markdown sections and PDF pages)
MAX_CHUNK_CHARS=2000
requirements.txt
anthropic==0.30.0
openai==1.35.0
sentence-transformers==3.0.1
faiss-cpu==1.8.0
pdfplumber==0.11.1
numpy==1.26.4
python-dotenv==1.0.1
rich==13.7.1
pytest==8.2.2
pytest-mock==3.14.0
src/ β what to implement
src/llm.py
get_completion(prompt: str, system: str = "") -> str
- Standard interface: reads
LLM_PROVIDERandLLM_MODELfrom env - Routes to Anthropic, OpenAI, or Ollama
- Edge cases: raise
RuntimeErrorif API key is missing
src/chunker.py
ChunkMetadata (dataclass)
@dataclass
class ChunkMetadata:
doc_title: str # Filename without extension
section_heading: str # Nearest heading above this chunk
last_modified: float # os.path.getmtime() value
source_file: str # Absolute file path
chunk_index: int # Position within document
char_count: int # Length of chunk text
markdown_chunk_by_section(filepath: str) -> list[tuple[str, ChunkMetadata]]
- Inputs: path to
.mdfile - Output: list of
(text, metadata)tuples β one per section - Behavior:
- Read file content
- Split on heading lines:
^#{1,3}(H1, H2, H3) - Each chunk = heading line + content until next heading
section_heading= the heading text (stripped of#symbols)doc_title= filename without extensionlast_modified=os.path.getmtime(filepath)- If content exceeds
MAX_CHUNK_CHARS, split at paragraph boundaries (blank lines)
- Edge cases: file with no headings β one chunk with
section_heading = doc_title
pdf_chunk_with_heading_detection(filepath: str) -> list[tuple[str, ChunkMetadata]]
- Inputs: path to
.pdffile - Output: list of
(text, metadata)tuples β one per detected section - Behavior:
- Use
pdfplumber.open(filepath)to extract text per page - Detect headings heuristically: lines that are ALL CAPS, or lines shorter than 60 chars followed by a blank line
- Group text under each detected heading
section_heading= detected heading orf"Page {page_num}"
- Use
- Edge cases: PDF with no extractable text β return
[]; encrypted PDF β log warning and return[]
attach_metadata(text: str, meta: ChunkMetadata) -> dict
- Inputs: chunk text, metadata
- Output:
{"text": str, "metadata": ChunkMetadata} - Simple wrapper β ensures consistent structure
src/indexer.py
build_with_metadata(chunks: list[dict], model) -> tuple
- Inputs: list of
{"text": str, "metadata": ChunkMetadata}dicts, loaded sentence-transformer model - Output:
(faiss_index, embeddings, chunks)β index built from normalized embeddings - Behavior: embed all
chunk["text"]; normalize; buildIndexFlatIP
save_index(index, chunks: list[dict], file_mtimes: dict, index_dir: str) -> None
- Inputs: FAISS index, chunks,
{filepath: mtime}dict, directory - Behavior: write
index.faiss,metadata.json(chunks without FAISS array),file_mtimes.json
load_index(index_dir: str) -> tuple
- Output:
(index, chunks, file_mtimes) - Edge cases: any file missing β raise
FileNotFoundError("Run --index first")
search_with_metadata(query: str, model, index, chunks: list[dict], top_k: int = 5) -> list[dict]
- Inputs: query string, model, index, chunks, k
- Output: list of
{"text": str, "metadata": ChunkMetadata, "score": float}sorted by score descending
check_freshness_and_reindex(index_dir: str, docs_dir: str, model) -> bool
- Inputs: index directory, docs directory, loaded model
- Output:
Trueif re-index was needed and performed,Falseif all files are current - Behavior:
- Load existing
file_mtimes.json - Walk docs directory; compute current mtime for each file
- If any mtime differs from stored β full re-index of that file (or full rebuild for simplicity)
- Log:
Stale index detected: <filename> changed. Re-indexing. - Update stored mtimes after re-index
- Load existing
src/copilot.py
build_answer_with_citations(query: str, retrieved_chunks: list[dict]) -> str
- Inputs: user query, retrieved chunks with metadata
- Output: answer string with citations
- Behavior:
- Build context string: for each chunk, format as
[Source: {doc_title} > {section_heading}]\n{text} - System prompt: "You are a helpful documentation assistant. Answer the question using ONLY the provided context. After each statement cite the source using format [source_name > section]. If the context doesn't contain the answer, say 'I don't know.'"
- User prompt:
Context:\n{context}\n\nQuestion: {query} - Call
get_completion - Post-process: ensure every citation appears in format
[{doc_title} > {section_heading}]
- Build context string: for each chunk, format as
handle_below_threshold(query: str, top_score: float, threshold: float) -> str | None
- Inputs: original query, highest retrieval score, threshold
- Output: "I don't know" message string if below threshold,
Noneif above threshold - Behavior: if
top_score < threshold, returnf"I don't have enough information to answer '{query}'. The documentation may not cover this topic." - This is called before the LLM β if below threshold, skip the LLM call entirely
format_citations(chunks: list[dict]) -> list[str]
- Inputs: retrieved chunks
- Output: list of
"{doc_title} > {section_heading}"strings (deduplicated, preserving order)
src/main.py
CLI entry point using argparse.
Arguments:
--index <dir>β index a documentation directory--ask "..."β ask a question; requires index to exist--rebuildβ force full re-index (ignore mtimes)
Behavior for --index:
- Walk directory for
.mdand.pdffiles - Check freshness; skip unchanged files
- Chunk all new/changed files
- Embed and build/update index
- Print
Indexed N chunks from M files
Behavior for --ask:
- Load index
- Check freshness; if stale, re-index automatically
- Search
- Call
handle_below_thresholdwith top score - If below threshold: print "I don't know" message; exit 0
- Otherwise: call
build_answer_with_citations - Print answer
- Print
\nSources:followed by citation list
tests/ β what to test
Test 1 β Metadata attached to every chunk (test_chunker.py)
- Call
markdown_chunk_by_sectionontests/fixtures/docs/setup_guide.md(has 3 headings) - Assert 3 or more chunks returned
- Assert every chunk's metadata has non-empty
doc_title,section_heading,last_modified - Assert
last_modifiedis a positive float (valid mtime)
Test 2 β Freshness check detects changed file (test_indexer.py)
- Save a fake
file_mtimes.jsonwith an old mtime forsetup_guide.md - Call
check_freshness_and_reindex(with model mocked) - Assert it returns
True(re-index was needed) - Assert stdout contains "Stale index detected"
Test 3 β Below-threshold returns "I don't know" message (test_copilot.py)
- Call
handle_below_threshold(query="What is the airspeed velocity?", top_score=0.3, threshold=0.5) - Assert result is not
None - Assert "don't have enough information" appears in the returned string
- Assert the query text appears in the returned string
Test 4 β Citation format is [source > section] in answer (test_copilot.py)
- Mock
get_completionto return a response containing a citation - Call
build_answer_with_citationswith 2 chunks that have known doc_title and section_heading - Assert the response contains at least one
[and>combination - Assert the cited doc_title appears in the response
Test 5 β Markdown chunker handles file with no headings (test_chunker.py)
- Create a temp
.mdfile with 3 paragraphs but no headings - Call
markdown_chunk_by_section - Assert exactly 1 chunk is returned
- Assert
section_headingequals the doc title (filename without extension)
Test 6 β Search returns results above threshold (test_indexer.py)
- Build a small in-memory index from 2 chunks
- Search for a query semantically matching chunk 1
- Assert top result has
score > 0.0 - Assert result has
metadatakey withsection_heading
README.md content
# Docs Copilot
RAG-based documentation assistant with section-level citations, freshness tracking, and an "I don't know" threshold.
## Quick start
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # add your API key
# Index your docs
python src/main.py --index docs/
# Ask a question
python src/main.py --ask "How do I reset my password?"
# Force rebuild
python src/main.py --rebuild
What makes this different from basic RAG
| Feature | Basic RAG | Docs Copilot |
|---|---|---|
| Chunking | Fixed size | By section heading |
| Metadata | None | doc_title, section_heading, mtime |
| Freshness | Manual rebuild | Automatic mtime check |
| Low confidence | Returns something | Returns "I don't know" |
| Citations | Optional | Required: [source > section] |
Citation format
Every answer includes source citations:
You can reset your password from the Settings page. [setup_guide > Account Settings]
Running tests
pytest tests/ -v
---
## GUIDE.md content
```markdown
# Build Guide β Docs Copilot
## Step 1 β Metadata-aware chunking
The key difference from basic RAG: preserve the section heading with every chunk.
```python
def markdown_chunk_by_section(filepath):
content = open(filepath).read()
lines = content.splitlines()
current_heading = os.path.basename(filepath).replace(".md", "")
current_lines = []
chunks = []
for line in lines:
if line.startswith("#"):
if current_lines:
text = "\n".join(current_lines).strip()
meta = ChunkMetadata(
doc_title=os.path.basename(filepath).replace(".md", ""),
section_heading=current_heading,
last_modified=os.path.getmtime(filepath),
source_file=filepath,
chunk_index=len(chunks),
char_count=len(text),
)
chunks.append((text, meta))
current_lines = []
current_heading = line.lstrip("#").strip()
else:
current_lines.append(line)
# Don't forget the last section
if current_lines:
...
return chunks
Step 2 β Freshness tracking
Store mtimes, not content hashes. Mtime is cheaper to read than hashing:
import os, json
def check_freshness(index_dir, docs_dir):
stored = json.load(open(f"{index_dir}/file_mtimes.json"))
for filepath in walk_docs(docs_dir):
current_mtime = os.path.getmtime(filepath)
if stored.get(filepath) != current_mtime:
return True # stale
return False
Step 3 β The confidence gate
Check the top retrieval score BEFORE calling the LLM:
results = search_with_metadata(query, model, index, chunks, top_k)
top_score = results[0]["score"] if results else 0.0
if top_score < CONFIDENCE_THRESHOLD:
print(handle_below_threshold(query, top_score, CONFIDENCE_THRESHOLD))
return
This saves an LLM call and avoids hallucination on topics not covered by the docs.
Step 4 β Citations in the prompt
Format context so the LLM knows the source of each chunk:
[Source: setup_guide > Database Configuration]
To configure the database, set DATABASE_URL in your .env file...
[Source: api_reference > Authentication]
All API calls require a Bearer token in the Authorization header...
Instruct the LLM: "After each claim, cite the source in [source > section] format."
Debugging tips
- If citations are missing, check that the system prompt explicitly requires them
- If freshness check always triggers, verify mtime comparison uses
==notis - If PDF chunks are empty, use
pdfplumberβ not PyPDF2 β for better text extraction
How to talk about this in an interview
"How is this different from the RAG you built in Path 1?"
Three differences. First, I chunk by section heading instead of fixed size β this gives more semantically coherent chunks. Second, I attach metadata (doc title, section, mtime) to every chunk so citations are reliable. Third, I gate on a confidence threshold β if retrieval scores are below the threshold, I return 'I don't know' rather than risk hallucination.
"How does freshness tracking work?"
I store the mtime of each indexed file alongside the index. On every
--ask, I check current mtimes against stored ones. If any file changed, I re-index it before answering. This means the index is always current without requiring manual rebuilds.
"What threshold do you use and how did you choose it?"
I use 0.5 on the inner product of L2-normalized vectors (equivalent to cosine similarity). I tuned it by asking 10 questions I knew the docs covered and 10 I knew they didn't, and found 0.5 gave zero false "I know" responses on the second set.
---
## Failure modes to handle
| Failure | Where | How to handle |
|---|---|---|
| PDF is encrypted or empty | `chunker.py` | Log warning with filename; return `[]`; continue |
| Index missing when `--ask` called | `indexer.py` | Raise `FileNotFoundError("Run --index first")` |
| All retrieval scores below threshold | `copilot.py` | Return "I don't know" message without LLM call |
| Markdown file has no headings | `chunker.py` | Return single chunk with `section_heading = doc_title` |
| Doc directory has no supported files | `main.py` | Print "No .md or .pdf files found in <dir>." and exit 0 |
| `file_mtimes.json` missing (first run) | `indexer.py` | Treat all files as stale; build fresh index |
---
## The metric this project measures
**What is measured:** Chunk count with metadata, freshness detection accuracy, and citation presence rate in answers.
**Format (stdout):**
Indexed 23 chunks from 4 files (2 new, 2 unchanged) Answer generated | top_score=0.74 | citations=2 | sources: [setup_guide > Database Config]
**Target:** Every chunk in the index must have non-null `doc_title`, `section_heading`, and `last_modified`. When a fixture file is modified, the next `--ask` must trigger re-indexing before answering. Every answer must contain at least one `[source > section]` citation. These three confirm metadata attachment, freshness tracking, and citation generation are all wired correctly.
## Model source currency
- Anthropic model list: https://docs.anthropic.com/en/docs/about-claude/models/overview
- OpenAI model list: https://platform.openai.com/docs/models
- verifiedOn: 2026-08-07
- `LLM_MODEL` is required and has no implicit default; choose a supported model for the configured provider at setup time.
The quiz isn't written yet
The project itself is ready to build β the repo, the spec and the deployment steps are all live. What's missing is the written quiz that goes with it, and the order those get written in is decided by which ones people actually ask for.
No spam. Unsubscribe anytime. Replies go to a real person.
The assignment isn't written yet
The project itself is ready to build β the repo, the spec and the deployment steps are all live. What's missing is the written assignment that goes with it, and the order those get written in is decided by which ones people actually ask for.
No spam. Unsubscribe anytime. Replies go to a real person.