Topic 17.6
Capstone: Plan a Document Question-Answering Tool
In one line
Before writing code, decide what the tool must do and how its parts fit. You will plan docqa, a tool that answers questions about a folder of text documents, with requirements, an architecture, typed data classes and interfaces, and a project layout.
Think of it like this
Building a small house. You do not start by laying bricks. You write down who will live there and what they need, draw a floor plan so the rooms connect, and decide where the pipes go before you pour concrete. A day of planning saves weeks of tearing walls down. The plan for docqa is the same: requirements, a floor plan (architecture) and a list of rooms (modules) with doors between them (interfaces).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- RAG
- Retrieval-augmented generation: find relevant text first, then give it to a language model along with the question.
- Chunk
- A piece of a document, small enough to fit in a prompt and focused enough to match a question.
- Embedding
- A list of numbers that stands for the meaning of a piece of text, so similar texts have similar numbers.
- Retriever
- The part that finds the chunks most related to a question.
- Protocol
- A typed description of the methods an object must have, without requiring inheritance.
- Frozen dataclass
- A data class whose fields cannot be changed after it is created.
- Pipeline
- Stages in a row where each stage's output is the next stage's input.
- Stub
- A placeholder function with the right inputs and outputs that returns fixed fake data.
Step by step
01Write the requirements as sentences you can test
Each requirement becomes one or more tests later. 'Returns the top 3 chunks' becomes a test with three documents. 'Cites sources' becomes a test that the answer lists the document names. 'Works without an API key' becomes a test that runs with the fake model. If you cannot imagine the test, the requirement is too vague.
02Draw the pipeline
The diagram shows the flow of data. The question enters at the retriever and the CLI. The documents are loaded and chunked once, then embedded; the model sees only the top chunks, never the whole folder. Keeping the model's input small is what keeps cost and delay low.
03Define the data classes and the interfaces first
The first runnable example defines Document, Chunk, Hit and Answer as frozen dataclasses and two Protocols. It then writes two small files into a folder with pathlib and loads them back with a function that returns Document objects, sorted by name so the order is stable. Notice that nothing imports a model library. The interfaces are enough to write and test the rest.
04Run a skeleton pipeline of stubs
The second runnable example wires four stub functions: load, chunk, retrieve and ask_model. Each has a typed signature and returns fake data, and answer calls them in order. It prints the order the stages ran in. This is the most valuable first milestone: a program that runs end to end on day one, so every later change is one stub becoming real.
05Decide the project layout
Use the src layout: the package lives under src/docqa/, tests under tests/. The src layout prevents accidentally importing the folder instead of the installed package, which is a classic source of 'works on my machine'. The tree below is the final shape; Topic 17.7 and 17.8 fill the files, and Topic 17.9 adds packaging.
docqa/
pyproject.toml
README.md
Dockerfile
src/docqa/
__init__.py
models.py # Document, Chunk, Hit, Answer
loader.py # read .txt files
chunking.py # chunk_text with overlap
embedding.py # Embedder protocol, ToyEmbedder
retrieval.py # Retriever (NumPy top-k)
llm.py # ModelClient protocol, FakeModelClient, real client
prompt.py # build_prompt with numbered sources
config.py # Config from args and environment
cli.py # argparse entry point
tests/
test_chunking.py
test_retrieval.py
test_cli.py06What you will not build, on purpose
Scope control is a professional skill. Docqa does not parse PDFs, does not call a real embedding model, and does not persist an index. Each is a good next step (listed in Topic 17.9), but adding them now would hide the main design. The interfaces you define make each of them a replacement, not a rewrite.
Try it yourself
- 1
Add a requirement and a test for it
Write a new requirement in one sentence, for example 'a question with no matching document says it does not know'. Then write, in words, the test that would check it using the skeleton pipeline. You will implement it in Topic 17.7.
- 2
Change a stage without touching the others
In the skeleton example, make
ask_modelreturn an answer in uppercase. Run it. Only one function changed, and the rest of the program did not notice. That is what clean interfaces buy you.
Code & diagrams
The folder is created inside the run's empty working directory. Add a third file and see it listed in sorted order.
Expected output
refunds.txt: 12 words
shipping.txt: 8 words
Hit(chunk=Chunk(doc='refunds.txt', index=0, text='...'), score=0.5)Replace `retrieve` with a version that returns the last chunk instead of the first and see the sources change.
Expected output
stub answer using 1 source(s) | sources: ['a.txt']
stages in order: load -> chunk -> retrieve -> modelBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Mutate a frozen data class
Create a frozen Chunk and try to change its text after creating it.
from dataclasses import dataclass
@dataclass(frozen=True)
class Chunk:
doc: str
text: str
chunk = Chunk("a.txt", "hello")
chunk.text = "changed"Myth vs fact
Myth
Planning is wasted time because the design will change anyway.
Fact
The design does change, and a plan with clean interfaces is what lets you change one part cheaply. Plan the boundaries, not every detail.
Myth
A RAG tool needs a vector database from day one.
Fact
For thousands of chunks, a NumPy matrix and a dot product are fast, simple and easy to test. Add a vector database when the data no longer fits in memory or needs to be shared.
Myth
You need a real language model to build and test the tool.
Fact
A scripted fake model is better for tests: it is instant, free and gives the same answer every time.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Keep the boundary between your code and the outside world thin: one module for the model client, one for file input. Everything else is pure functions and data classes, which is easy to test and to reason about.
- ▸
A
Protocolis checked by the type checker (mypy, pyright), not by Python at run time: a class that lacks the method still runs until the call fails.@runtime_checkableletsisinstancetest a Protocol, but it only checks that the method names exist, not their signatures, so use it sparingly. Type-check in CI and the contract is enforced where it is cheap. - ▸
Write down architecture decisions in the README (an 'ADR' is a short note: what we chose, why, and what else we considered). Future you, and your reviewers, will need them.
- ▸
Retrieval quality is the main lever in RAG systems, more than the choice of model. Plan how you will measure it (a small set of questions with the expected source), as Topic 16.11 describes.
Remember this
- 1
What docqa does. You point it at a folder of documents and ask a question in plain English. It finds the parts of the documents most related to the question, gives them to a language model together with the question, and prints an answer that cites the sources it used. This pattern is called retrieval-augmented generation (RAG). Topic 16.9 built it in miniature; here you build it as a proper, tested, shippable project.
- 2
Requirements first. A good list is short and testable. Docqa must: read
.txtfiles from a folder; split them into overlapping chunks; find the top-k chunks for a question; build a prompt that contains those chunks as numbered sources; stream the model's answer; print which sources were used; run from the command line with a config; work offline with a fake model (so tests and demos need no API key); and fail with a clear message, never a stack trace, for ordinary mistakes such as a missing folder. Write down what it will not do yet: PDFs, a web interface, a real vector database, authentication. - 3
Architecture: a pipeline of small parts.
loaderreads files intoDocumentobjects.chunkerturns a document intoChunkobjects.embedderturns text into vectors (lists of numbers that stand for meaning; Topic 16.8).retrieverstores the chunk vectors and returns the top-kHitobjects for a question.modeltakes a prompt and streams text.cliwires the parts together. Each part knows only the data classes and interfaces around it, so you can replace any one (a real embedding model for the toy one, a real language model for the fake one) without touching the others. - 4
**Interfaces with
typing.Protocol** (Topic 10.6). An interface is the list of methods a part promises to offer. AProtocoldescribes the methods an object must have, without inheritance.Embedderneedsembed(texts) -> array;ModelClientneedsstream(prompt) -> Iterator[str]. Any class with those methods fits, including a test fake, so the pipeline code is written once against the Protocol. This is the same dependency injection idea from Topic 17.5, and it is what makes the whole tool testable without the network. - 5
Data classes carry the data between stages (Topic 8.8). Use
@dataclass(frozen=True)forDocument,Chunk,HitandAnswer: they are immutable (so a stage cannot secretly change what another stage returned), have readablereprfor debugging, and are easy to compare in tests. Keep plain dictionaries for configuration you read from files, and convert them into a typedConfigobject at the edge. - 6
Build order. Start with the data classes and a skeleton pipeline of stubs that runs end to end and prints a fixed answer. Replace one stub at a time with the real thing, running the tests after each: loader, chunker, embedder and retriever, prompt builder and fake model, CLI, logging and retries, real model client. A program that always runs end to end is much easier to debug than five untested parts joined at the end.
Explain it without notes
What is retrieval-augmented generation, and why not just give the model the whole folder?
Why define Protocols and frozen dataclasses before writing the implementations?
Why build a stub pipeline first?
How would you decide what is in scope for the first version of a tool like docqa?
Practice
Write a frozen dataclass Source with doc (str) and index (int), and show that two equal sources compare equal and can be put in a set.
Write a Protocol called Scorer with a method score(self, question: str, text: str) -> float, and a class that satisfies it by counting shared words.
Write a function load_texts(folder) that returns a dict from file name to text for every .txt file in a folder, sorted by name. Test it by creating a folder with two files.
Trade-offs
- ↔
Small modules versus fewer files. Many small modules give clean boundaries but more files to navigate. Group by responsibility, and merge modules that always change together.
- ↔
Protocols versus abstract base classes. Protocols describe structure and need no inheritance, which suits fakes and third-party classes. Abstract base classes enforce implementation at class creation and can share code. Prefer Protocols for interfaces between parts.
Done when you can
Done when you can write the requirements of docqa as short, testable sentences.
Done when you can draw the pipeline and name the data that flows between stages.
Done when you can write frozen dataclasses and Protocols for the stages.
Done when you can explain why you build a stub pipeline first.
Done when you can lay out the project folders in the
srclayout.