Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~27 min· topic 6 of 10

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.

Draw the pipelinediagram
Rendering diagram…

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.

layoutwhole filetext
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.py

06What 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. 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. 2

    Change a stage without touching the others

    In the skeleton example, make ask_model return 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

Data classes, Protocols and a loader New tab

The folder is created inside the run's empty working directory. Add a third file and see it listed in sorted order.

Sign in to run this example in your browser.

Expected output

refunds.txt: 12 words
shipping.txt: 8 words
Hit(chunk=Chunk(doc='refunds.txt', index=0, text='...'), score=0.5)
A skeleton pipeline that already runs end to end New tab

Replace `retrieve` with a version that returns the last chunk instead of the first and see the sources change.

Sign in to run this example in your browser.

Expected output

stub answer using 1 source(s) | sources: ['a.txt']
stages in order: load -> chunk -> retrieve -> model

Break 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.

main.pywhole filepython
from dataclasses import dataclass


@dataclass(frozen=True)
class Chunk:
    doc: str
    text: str


chunk = Chunk("a.txt", "hello")
chunk.text = "changed"
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 11, in <module>
chunk.text = "changed"
^^^^^^^^^^
File "<string>", line 16, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'text'

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 Protocol is 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_checkable lets isinstance test 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. 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. 2

    Requirements first. A good list is short and testable. Docqa must: read .txt files 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. 3

    Architecture: a pipeline of small parts. loader reads files into Document objects. chunker turns a document into Chunk objects. embedder turns text into vectors (lists of numbers that stand for meaning; Topic 16.8). retriever stores the chunk vectors and returns the top-k Hit objects for a question. model takes a prompt and streams text. cli wires 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. 4

    **Interfaces with typing.Protocol** (Topic 10.6). An interface is the list of methods a part promises to offer. A Protocol describes the methods an object must have, without inheritance. Embedder needs embed(texts) -> array; ModelClient needs stream(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. 5

    Data classes carry the data between stages (Topic 8.8). Use @dataclass(frozen=True) for Document, Chunk, Hit and Answer: they are immutable (so a stage cannot secretly change what another stage returned), have readable repr for debugging, and are easy to compare in tests. Keep plain dictionaries for configuration you read from files, and convert them into a typed Config object at the edge.

  6. 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

01

What is retrieval-augmented generation, and why not just give the model the whole folder?

02

Why define Protocols and frozen dataclasses before writing the implementations?

03

Why build a stub pipeline first?

04

How would you decide what is in scope for the first version of a tool like docqa?

Practice

01

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.

02

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.

03

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 src layout.