Build an Engineering Project Q&A System

Build a RAG pipeline that answers questions about any codebase with citations.

Introduction

30 Second Summary

Every engineering project accumulates knowledge across source code, documentation, and datasheets. Finding the answer to "how does the control loop work?" means digging through dozens of files manually.

In this project, you will build a Python CLI tool that indexes an engineering project's files and answers natural-language questions about them with source citations.

What You'll Build

A Retrieval-Augmented Generation (RAG) pipeline that ingests code, markdown docs, and PDFs into a local vector store, then uses Claude to synthesize cited answers from the retrieved context.

By the end of this project, you'll have:

  • A document ingestion pipeline that parses Python and C++ code with AST-aware chunking, markdown with structure-aware splitting, and PDFs with layout-aware extraction.
  • A persistent ChromaDB vector store for fast semantic search across all indexed content.
  • A query engine powered by Claude that retrieves relevant chunks and synthesizes answers with file-level citations.
  • Secret Mission: Add a system overview command that auto-generates an architecture summary with component relationships and a Mermaid diagram.

Are there any prerequisites?

You need basic Python familiarity (variables, functions, running scripts from the terminal). This guide includes full setup instructions for all required tools, so no prior experience with LlamaIndex or vector databases is needed.

Set Up Your Development Environment

Engineering projects accumulate hundreds of files over time. Source code, documentation, datasheets, and config files all contain critical knowledge. Finding the answer to a simple question often means manually searching through dozens of files.

In this step, you will install Python, set up a virtual environment with all the libraries your Q&A system needs, configure your API key for Claude, and prepare a sample engineering project to index.

In this step, get ready to:
  • Install Python 3.10+ and create a virtual environment with all dependencies.
  • Configure your Anthropic API key for Claude access.
  • Prepare a sample engineering project folder to use as your test corpus.
Install Python 3.10+ and create a virtual environment

Your RAG pipeline depends on LlamaIndex, which requires Python 3.10 or higher. Let's confirm you have the right version installed.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

py --version

✔️ I see version 3.10 or higher

You are all set. Move on to creating your project folder below.

ⓧ I see an older version

Your system has an older Python that won't work with LlamaIndex. You need to install Python 3.10 or higher alongside it.

  • Visit python.org/downloads and download the latest Python 3 installer.
  • Run the installer. On Windows, check the Add python.exe to PATH box before clicking Install Now.
  • Close and reopen your terminal, then run the version check again to confirm you see 3.10 or higher.

macOS default Python

macOS ships with an older Python by default. After installing a newer version from python.org, you may need to use python3.12 (or whichever version you installed) instead of python3 to get the correct version.

ⓧ Command not found

Python is not installed on your system yet.

  • Visit python.org/downloads and download the latest Python 3 installer for your operating system.
  • Run the installer. On Windows, check the Add python.exe to PATH box before clicking Install Now.
  • Close and reopen your terminal, then run the version check again to confirm Python is available.
  • Move to your Desktop by running this command:

macOS

cd ~/Desktop

Windows

cd %USERPROFILE%\Desktop
  • Create your project folder and move into it by running these commands:
mkdir project-qa
cd project-qa
  • Confirm the folder exists by running:
ls

You should see the project-qa folder listed if you check your Desktop.

  • Create a virtual environment and activate it by running these commands:

macOS

python3 -m venv .venv
source .venv/bin/activate

Windows

py -m venv .venv
.venv\Scripts\activate

What is a virtual environment?

A virtual environment is an isolated Python installation just for this project. It keeps your project's packages separate from your system Python, so different projects don't conflict with each other.

You will know the environment is active when you see (.venv) at the beginning of your terminal prompt.

  • Install all project dependencies by running:
pip install llama-index-core chromadb anthropic pymupdf4llm tree-sitter-language-pack

What are these packages?

llama-index-core provides the RAG framework for parsing, chunking, and indexing documents. chromadb is a vector database that stores your document embeddings locally.

anthropic is the SDK for calling Claude. pymupdf4llm extracts text from PDFs in a layout-aware format. tree-sitter-language-pack provides the language grammars needed for AST-based code splitting.

The install may take a minute or two depending on your internet connection. You should see a success message for each package.

Seeing install errors?

Make sure you see (.venv) in your terminal prompt before running the install. If not, re-run the activation command above.

If you see permission errors, do not use sudo. Deactivate and re-create the virtual environment instead.

Why is my pip install failing inside the virtual environment?

Configure your Anthropic API key

Your query engine uses Claude to synthesize answers from retrieved context. You need an API key from Anthropic to authenticate your requests.

  • Open your browser and go to console.anthropic.com.
  • Create an account if you do not already have one, or sign in.
  • Navigate to API Keys in the sidebar.
  • Click Create Key and give it a name like project-qa.
  • Copy the key immediately. You will not be able to see it again after closing the dialog.

How much does this cost?

Anthropic provides a small amount of free credits for new accounts. This project uses claude-sonnet-4-20250514 which costs a few cents per query. The entire project should cost between $0.10 and $0.50 depending on how many questions you ask during testing.

  • Back in your terminal (with the virtual environment still active), set the API key as an environment variable:

macOS

export ANTHROPIC_API_KEY=your-api-key-here

Windows

set ANTHROPIC_API_KEY=your-api-key-here
  • Replace your-api-key-here with the actual key you just copied from the Anthropic console.

Why an environment variable?

The Anthropic SDK automatically reads ANTHROPIC_API_KEY from your environment. This keeps your key out of source code so you never accidentally commit it to version control.

  • Verify the connection works by running this quick test:

macOS

python3 -c "import anthropic; client = anthropic.Anthropic(); print('Connection OK')"

Windows

py -c "import anthropic; client = anthropic.Anthropic(); print('Connection OK')"

You should see Connection OK printed in your terminal. This confirms the SDK can find your API key.

Seeing an authentication error?

Double-check that you copied the full key with no trailing spaces. The key should start with sk-ant-.

If you set the variable in a different terminal window, it will not carry over. Set it again in the same terminal where you activated your virtual environment.

Why is my Anthropic API key not being recognized?

Prepare a sample engineering project to index

Your RAG system needs a real project to index. You will create a small sample with Python source files, a markdown README, and a PDF datasheet. This gives the system all three file types to exercise during ingestion.

  • Create the sample project folder by running:
mkdir sample-project
  • Open the project-qa folder in your code editor. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code (or Cursor), and press Enter to open it.
  • Click File in the top menu bar, then Open Folder.
  • Navigate to your Desktop and select the project-qa folder.
  • Inside the sample-project folder, create a new file called sensor_reader.py with the following content:
import time
import random


class SensorReader:
    """Reads temperature and humidity from connected sensors."""

    def __init__(self, sensor_id: str, sample_rate: float = 1.0):
        self.sensor_id = sensor_id
        self.sample_rate = sample_rate
        self.readings = []

    def read_temperature(self) -> float:
        """Read current temperature in Celsius."""
        # Simulate sensor reading
        return 20.0 + random.uniform(-5, 5)

    def read_humidity(self) -> float:
        """Read current relative humidity percentage."""
        return 50.0 + random.uniform(-10, 10)

    def collect_samples(self, duration_seconds: int) -> list:
        """Collect sensor samples over a time period."""
        samples = []
        start = time.time()
        while time.time() - start < duration_seconds:
            sample = {
                "timestamp": time.time(),
                "temperature": self.read_temperature(),
                "humidity": self.read_humidity(),
            }
            samples.append(sample)
            time.sleep(self.sample_rate)
        self.readings.extend(samples)
        return samples


def calculate_average(readings: list, field: str) -> float:
    """Calculate average value for a given field across readings."""
    values = [r[field] for r in readings if field in r]
    return sum(values) / len(values) if values else 0.0


if __name__ == "__main__":
    reader = SensorReader(sensor_id="TEMP-001", sample_rate=0.5)
    data = reader.collect_samples(duration_seconds=5)
    avg_temp = calculate_average(data, "temperature")
    print(f"Average temperature: {avg_temp:.1f}C")
  • Create a README.md file inside sample-project with the following content:
# Sensor Monitoring System

A Python-based system for reading and analyzing temperature and humidity sensors.

## Components

- **SensorReader**: Connects to hardware sensors and collects time-series data.
- **calculate_average**: Utility function for computing running averages.

## Usage

Run the main script to start collecting data:

```bash
python sensor_reader.py
```

## Configuration

- `sensor_id`: Unique identifier for the sensor hardware.
- `sample_rate`: How often to read the sensor (in seconds).

## Data Flow

1. SensorReader initializes with a sensor ID and sample rate.
2. `collect_samples()` polls the sensor at the configured interval.
3. Raw readings are stored in memory and returned as a list.
4. `calculate_average()` summarizes collected data.
  • Add a PDF file to the sample-project folder. This can be any technical PDF you have on hand. A sensor datasheet, a hardware manual, or a research paper all work well.

No PDF handy?

Search for any free sensor datasheet PDF online. For example, searching "DHT22 sensor datasheet PDF" gives you a multi-page technical document that works perfectly as test data.

  • Save both files in your editor.
  • Verify your project structure by running this in your terminal:
ls sample-project

You should see sensor_reader.py, README.md, and your PDF file listed.

  • Run the final verification to confirm all imports work:

macOS

python3 -c "import llama_index.core; import chromadb; import anthropic; import pymupdf4llm; print('All imports OK')"

Windows

py -c "import llama_index.core; import chromadb; import anthropic; import pymupdf4llm; print('All imports OK')"

You should see All imports OK printed with no errors.

Seeing an ImportError?

Confirm your virtual environment is active by checking for (.venv) in your prompt. If it is not there, re-run the activation command for your platform.

If a specific package fails to import, re-install it individually. For example: pip install llama-index-core.

Why am I getting an ImportError after installing my packages?

Your development environment is ready and your sample project is waiting to be indexed. Next up, you will build the document ingestion pipeline that reads these files and converts them into searchable chunks.

Build the Document Ingestion Pipeline

Your environment is ready. Now it is time to build the core of your Q&A system: the ingestion pipeline that reads an engineering project's files and converts them into searchable chunks.

The challenge with engineering projects is that they contain very different file types. Python source code has functions and classes that should stay intact. PDFs have multi-column layouts and page numbers. Markdown has headers and sections. A naive "split every 100 lines" approach destroys all of this structure and produces terrible search results. In this step, you will build specialized parsers that respect each file type's natural boundaries.

In this step, get ready to:
  • Create file discovery logic that walks a project folder and classifies files by type.
  • Implement AST-aware code chunking that preserves function boundaries.
  • Build PDF and markdown parsers that retain page numbers and section headers for citations.
Create the file discovery and routing logic

Before you can parse any files, your pipeline needs to know what it is looking at. Engineering projects contain a mix of source code, documentation, and datasheets scattered across nested directories. You will start by building a file discovery function that walks the project tree and sorts files into categories.

  • Create a new file called ingest.py in your project folder (the same folder that contains your .venv directory).
  • Add the imports and file-type constants by pasting this code into ingest.py:
import os
import hashlib
from pathlib import Path

from llama_index.core import Document, StorageContext, VectorStoreIndex
from llama_index.core.node_parser import CodeSplitter, MarkdownNodeParser, SentenceSplitter
from llama_index.core.schema import TextNode
import chromadb
import pymupdf4llm


# Supported file extensions for each category
SUPPORTED_CODE_EXTENSIONS = {".py", ".cpp", ".c", ".h", ".hpp", ".js", ".ts"}
SUPPORTED_DOC_EXTENSIONS = {".md", ".txt", ".rst"}
SUPPORTED_PDF_EXTENSIONS = {".pdf"}

# Map file extensions to programming languages for the code splitter
LANGUAGE_MAP = {
    ".py": "python",
    ".cpp": "cpp",
    ".c": "c",
    ".h": "c",
    ".hpp": "cpp",
    ".js": "javascript",
    ".ts": "typescript",
}

What does this code do?

The imports pull in LlamaIndex for document parsing, ChromaDB for vector storage, and pymupdf4llm for PDF extraction.

The constants define which file extensions your pipeline recognizes. LANGUAGE_MAP tells the code splitter which programming language grammar to use for each extension, so it can parse function and class boundaries correctly.

  • Now add the file discovery function below the constants in ingest.py:
def discover_files(project_path: str) -> dict[str, list[Path]]:
    """Walk project directory and classify files by type."""
    files = {"code": [], "docs": [], "pdf": []}
    project = Path(project_path)

    for file_path in project.rglob("*"):
        if file_path.is_file():
            ext = file_path.suffix.lower()
            if ext in SUPPORTED_CODE_EXTENSIONS:
                files["code"].append(file_path)
            elif ext in SUPPORTED_DOC_EXTENSIONS:
                files["docs"].append(file_path)
            elif ext in SUPPORTED_PDF_EXTENSIONS:
                files["pdf"].append(file_path)

    return files

What does this code do?

  • project.rglob("*") recursively walks every file in the project directory and all its subdirectories.
  • Each file gets sorted into one of three buckets (code, docs, pdf) based on its extension. Files that don't match any supported extension are skipped.
  • The function returns a dictionary so the pipeline can route each category to its specialized parser.
  • Save ingest.py.

You will not see output yet. The next chunks add the parsers that process what this function discovers.

Implement code-aware chunking for source files

Naive text splitting cuts code at arbitrary line numbers, often slicing a function in half. LlamaIndex's CodeSplitter uses tree-sitter to parse an Abstract Syntax Tree (AST), splitting at function and class boundaries instead.

  • Add the code parsing function below discover_files() in ingest.py:
def parse_code_files(file_paths: list[Path]) -> list[TextNode]:
    """Parse code files using AST-aware chunking."""
    nodes = []
    for file_path in file_paths:
        ext = file_path.suffix.lower()
        language = LANGUAGE_MAP.get(ext, "python")

        try:
            content = file_path.read_text(encoding="utf-8", errors="ignore")
        except Exception:
            continue

        # Use CodeSplitter for AST-based boundaries
        splitter = CodeSplitter(
            language=language,
            chunk_lines=40,
            chunk_lines_overlap=15,
            max_chars=1500,
        )

        doc = Document(text=content, metadata={"file_path": str(file_path), "file_type": "code", "language": language})
        split_nodes = splitter.get_nodes_from_documents([doc])

        # Attach metadata to each chunk for citation tracking
        for node in split_nodes:
            node.metadata["file_path"] = str(file_path)
            node.metadata["file_type"] = "code"
            node.metadata["language"] = language
            nodes.append(node)

    return nodes

What does this code do?

  • CodeSplitter parses each file into an AST using tree-sitter, then splits at natural code boundaries (functions, classes) rather than arbitrary line numbers.
  • chunk_lines=40 targets 40 lines per chunk. chunk_lines_overlap=15 creates a 15-line overlap between adjacent chunks so context is not lost at boundaries.
  • Each node gets metadata (file_path, file_type, language) attached so the query engine can cite exact source files later.

Why AST-based chunking over line-based splitting?

Line-based splitting might cut a function in half, placing the first 20 lines in one chunk and the last 10 in another. When a user asks "What does this function do?", neither chunk contains the complete answer.

AST-aware splitting respects code structure. Complete functions and classes stay in one chunk, producing dramatically better retrieval results for code questions.

  • Save ingest.py.

You will not see output yet. The next chunk adds the PDF and markdown parsers.

Implement PDF and markdown parsing

Engineering datasheets are almost always PDFs with multi-column layouts, tables, and diagrams. Regular text extraction scrambles all of this. You will use pymupdf4llm to convert PDFs into structured markdown that preserves layout, then split the result into page-level chunks with metadata.

  • Add the PDF parsing function below parse_code_files() in ingest.py:
def parse_pdf_files(file_paths: list[Path]) -> list[TextNode]:
    """Parse PDF files using PyMuPDF4LLM for layout-aware extraction."""
    nodes = []
    sentence_splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=128)

    for file_path in file_paths:
        try:
            # Convert PDF to structured markdown with per-page metadata
            md_content = pymupdf4llm.to_markdown(str(file_path), page_chunks=True)
        except Exception:
            continue

        if isinstance(md_content, list):
            for page_data in md_content:
                text = page_data.get("text", "")
                metadata_info = page_data.get("metadata", {})
                page_num = metadata_info.get("page", 0) + 1

                if text.strip():
                    doc = Document(
                        text=text,
                        metadata={
                            "file_path": str(file_path),
                            "file_type": "pdf",
                            "page_number": page_num,
                        },
                    )
                    page_nodes = sentence_splitter.get_nodes_from_documents([doc])
                    for node in page_nodes:
                        node.metadata["file_path"] = str(file_path)
                        node.metadata["file_type"] = "pdf"
                        node.metadata["page_number"] = page_num
                        nodes.append(node)
        else:
            doc = Document(
                text=md_content,
                metadata={"file_path": str(file_path), "file_type": "pdf", "page_number": 1},
            )
            page_nodes = sentence_splitter.get_nodes_from_documents([doc])
            for node in page_nodes:
                node.metadata["file_path"] = str(file_path)
                node.metadata["file_type"] = "pdf"
                nodes.append(node)

    return nodes

What does this code do?

  • pymupdf4llm.to_markdown() converts each PDF into LLM-optimized markdown, handling multi-column layouts and tables automatically.
  • page_chunks=True returns a list of per-page dictionaries, each with the text content and page number metadata.
  • SentenceSplitter(chunk_size=1024, chunk_overlap=128) further splits large pages into sentence-boundary-respecting chunks so no single chunk overwhelms the embedding model.
  • The page_number metadata is preserved on every node so citations can point to exact pages.
  • Save ingest.py.

Now add the markdown and text documentation parser. This one is simpler because text files are already structured.

  • Add the documentation parsing function below parse_pdf_files() in ingest.py:
def parse_doc_files(file_paths: list[Path]) -> list[TextNode]:
    """Parse markdown and text documentation files."""
    nodes = []
    md_parser = MarkdownNodeParser()
    sentence_splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=128)

    for file_path in file_paths:
        try:
            content = file_path.read_text(encoding="utf-8", errors="ignore")
        except Exception:
            continue

        doc = Document(text=content, metadata={"file_path": str(file_path), "file_type": "doc"})

        # Use MarkdownNodeParser for .md files, SentenceSplitter for others
        if file_path.suffix.lower() == ".md":
            split_nodes = md_parser.get_nodes_from_documents([doc])
        else:
            split_nodes = sentence_splitter.get_nodes_from_documents([doc])

        for node in split_nodes:
            node.metadata["file_path"] = str(file_path)
            node.metadata["file_type"] = "doc"
            nodes.append(node)

    return nodes

What does this code do?

  • MarkdownNodeParser splits .md files at heading boundaries, keeping each section as a coherent chunk. This means a README section titled "Installation" stays in one piece.
  • Plain text files (.txt, .rst) fall back to SentenceSplitter which splits at sentence boundaries within the 1024-character limit.
  • Save ingest.py.

All three parsers are ready. Now you need an orchestrator function that ties discovery and parsing together, then stores the results in ChromaDB.

  • Add the orchestrator function below parse_doc_files() in ingest.py:
def ingest_project(project_path: str, db_path: str = "./.chroma_db") -> int:
    """Ingest all files from a project directory into ChromaDB."""
    files = discover_files(project_path)
    print(f"Discovered: {len(files['code'])} code, {len(files['docs'])} docs, {len(files['pdf'])} PDF files")

    all_nodes = []
    all_nodes.extend(parse_code_files(files["code"]))
    all_nodes.extend(parse_doc_files(files["docs"]))
    all_nodes.extend(parse_pdf_files(files["pdf"]))

    print(f"Parsed {len(all_nodes)} total chunks")

    chroma_client = chromadb.PersistentClient(path=db_path)
    collection = chroma_client.get_or_create_collection("project_index")

    # Clear existing data for fresh index
    if collection.count() > 0:
        collection.delete(where={})

    # Add nodes to ChromaDB
    ids = []
    documents = []
    metadatas = []

    for i, node in enumerate(all_nodes):
        node_id = hashlib.md5(f"{node.metadata.get('file_path', '')}_{i}".encode()).hexdigest()
        ids.append(node_id)
        documents.append(node.get_content())
        metadatas.append({k: str(v) for k, v in node.metadata.items()})

    # ChromaDB has a batch limit, insert in chunks of 500
    batch_size = 500
    for start in range(0, len(ids), batch_size):
        end = start + batch_size
        collection.add(
            ids=ids[start:end],
            documents=documents[start:end],
            metadatas=metadatas[start:end],
        )

    print(f"Indexed {collection.count()} chunks into ChromaDB at {db_path}")
    return collection.count()

What does this code do?

  • ingest_project() is the pipeline orchestrator. It discovers files, routes each category to its parser, then stores all the resulting nodes in ChromaDB.
  • chromadb.PersistentClient(path=db_path) creates a local database at ./.chroma_db. ChromaDB automatically embeds your text using its built-in all-MiniLM-L6-v2 model, so you do not need a separate embedding step.
  • Each node gets a unique ID (an MD5 hash of file path + index) so re-runs do not create duplicates.
  • The batch loop inserts 500 nodes at a time to stay within ChromaDB's batch limits.
  • Save ingest.py.

Finally, add the entry point at the very bottom of ingest.py so you can run the script from the command line.

  • Add the main entry point below ingest_project():
if __name__ == "__main__":
    import sys

    if len(sys.argv) < 2:
        print("Usage: py ingest.py <project_path>")
        sys.exit(1)

    project_path = sys.argv[1]
    if not Path(project_path).is_dir():
        print(f"Error: {project_path} is not a directory")
        sys.exit(1)

    ingest_project(project_path)

What does this code do?

This block only runs when you execute ingest.py directly. It checks that you provided a project folder path as an argument, validates the path exists, then kicks off the full ingestion pipeline.

  • Save ingest.py.
  • Run the ingestion on your sample project by running this command (replace the path with the actual path to your sample project folder):
py ingest.py [[PROJECT_PATH="path/to/your/sample-project"]]

You should see output listing the number of files discovered and chunks parsed. Something like:

Discovered: 3 code, 1 docs, 1 PDF files
Parsed 12 total chunks
Indexed 12 chunks into ChromaDB at ./.chroma_db

Your exact numbers will vary depending on the size of your sample project.

Seeing an error when running ingest.py?

  • If you see a ModuleNotFoundError, make sure your virtual environment is active. You should see (.venv) in your terminal prompt.
  • If you see "is not a directory", double-check the path you passed. It must point to a folder, not a file.
  • If you see 0 chunks parsed, confirm your sample project contains at least one .py file, one .md file, or one .pdf file with actual content.

Help me debug my ingest.py script

✔️ Awesome, I've got everything!

Great. Double check you have saved ingest.py and that your ingestion run produced chunks.

ⓧ I'd like to double check the full code

Here is the complete ingest.py file. Compare your code against this:

import os
import hashlib
from pathlib import Path

from llama_index.core import Document, StorageContext, VectorStoreIndex
from llama_index.core.node_parser import CodeSplitter, MarkdownNodeParser, SentenceSplitter
from llama_index.core.schema import TextNode
import chromadb
import pymupdf4llm


SUPPORTED_CODE_EXTENSIONS = {".py", ".cpp", ".c", ".h", ".hpp", ".js", ".ts"}
SUPPORTED_DOC_EXTENSIONS = {".md", ".txt", ".rst"}
SUPPORTED_PDF_EXTENSIONS = {".pdf"}

LANGUAGE_MAP = {
    ".py": "python",
    ".cpp": "cpp",
    ".c": "c",
    ".h": "c",
    ".hpp": "cpp",
    ".js": "javascript",
    ".ts": "typescript",
}


def discover_files(project_path: str) -> dict[str, list[Path]]:
    """Walk project directory and classify files by type."""
    files = {"code": [], "docs": [], "pdf": []}
    project = Path(project_path)

    for file_path in project.rglob("*"):
        if file_path.is_file():
            ext = file_path.suffix.lower()
            if ext in SUPPORTED_CODE_EXTENSIONS:
                files["code"].append(file_path)
            elif ext in SUPPORTED_DOC_EXTENSIONS:
                files["docs"].append(file_path)
            elif ext in SUPPORTED_PDF_EXTENSIONS:
                files["pdf"].append(file_path)

    return files


def parse_code_files(file_paths: list[Path]) -> list[TextNode]:
    """Parse code files using AST-aware chunking."""
    nodes = []
    for file_path in file_paths:
        ext = file_path.suffix.lower()
        language = LANGUAGE_MAP.get(ext, "python")

        try:
            content = file_path.read_text(encoding="utf-8", errors="ignore")
        except Exception:
            continue

        splitter = CodeSplitter(
            language=language,
            chunk_lines=40,
            chunk_lines_overlap=15,
            max_chars=1500,
        )

        doc = Document(text=content, metadata={"file_path": str(file_path), "file_type": "code", "language": language})
        split_nodes = splitter.get_nodes_from_documents([doc])

        for node in split_nodes:
            node.metadata["file_path"] = str(file_path)
            node.metadata["file_type"] = "code"
            node.metadata["language"] = language
            nodes.append(node)

    return nodes


def parse_pdf_files(file_paths: list[Path]) -> list[TextNode]:
    """Parse PDF files using PyMuPDF4LLM for layout-aware extraction."""
    nodes = []
    sentence_splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=128)

    for file_path in file_paths:
        try:
            md_content = pymupdf4llm.to_markdown(str(file_path), page_chunks=True)
        except Exception:
            continue

        if isinstance(md_content, list):
            for page_data in md_content:
                text = page_data.get("text", "")
                metadata_info = page_data.get("metadata", {})
                page_num = metadata_info.get("page", 0) + 1

                if text.strip():
                    doc = Document(
                        text=text,
                        metadata={
                            "file_path": str(file_path),
                            "file_type": "pdf",
                            "page_number": page_num,
                        },
                    )
                    page_nodes = sentence_splitter.get_nodes_from_documents([doc])
                    for node in page_nodes:
                        node.metadata["file_path"] = str(file_path)
                        node.metadata["file_type"] = "pdf"
                        node.metadata["page_number"] = page_num
                        nodes.append(node)
        else:
            doc = Document(
                text=md_content,
                metadata={"file_path": str(file_path), "file_type": "pdf", "page_number": 1},
            )
            page_nodes = sentence_splitter.get_nodes_from_documents([doc])
            for node in page_nodes:
                node.metadata["file_path"] = str(file_path)
                node.metadata["file_type"] = "pdf"
                nodes.append(node)

    return nodes


def parse_doc_files(file_paths: list[Path]) -> list[TextNode]:
    """Parse markdown and text documentation files."""
    nodes = []
    md_parser = MarkdownNodeParser()
    sentence_splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=128)

    for file_path in file_paths:
        try:
            content = file_path.read_text(encoding="utf-8", errors="ignore")
        except Exception:
            continue

        doc = Document(text=content, metadata={"file_path": str(file_path), "file_type": "doc"})

        if file_path.suffix.lower() == ".md":
            split_nodes = md_parser.get_nodes_from_documents([doc])
        else:
            split_nodes = sentence_splitter.get_nodes_from_documents([doc])

        for node in split_nodes:
            node.metadata["file_path"] = str(file_path)
            node.metadata["file_type"] = "doc"
            nodes.append(node)

    return nodes


def ingest_project(project_path: str, db_path: str = "./.chroma_db") -> int:
    """Ingest all files from a project directory into ChromaDB."""
    files = discover_files(project_path)
    print(f"Discovered: {len(files['code'])} code, {len(files['docs'])} docs, {len(files['pdf'])} PDF files")

    all_nodes = []
    all_nodes.extend(parse_code_files(files["code"]))
    all_nodes.extend(parse_doc_files(files["docs"]))
    all_nodes.extend(parse_pdf_files(files["pdf"]))

    print(f"Parsed {len(all_nodes)} total chunks")

    chroma_client = chromadb.PersistentClient(path=db_path)
    collection = chroma_client.get_or_create_collection("project_index")

    # Clear existing data for fresh index
    if collection.count() > 0:
        collection.delete(where={})

    # Add nodes to ChromaDB
    ids = []
    documents = []
    metadatas = []

    for i, node in enumerate(all_nodes):
        node_id = hashlib.md5(f"{node.metadata.get('file_path', '')}_{i}".encode()).hexdigest()
        ids.append(node_id)
        documents.append(node.get_content())
        metadatas.append({k: str(v) for k, v in node.metadata.items()})

    # ChromaDB has a batch limit, insert in chunks of 500
    batch_size = 500
    for start in range(0, len(ids), batch_size):
        end = start + batch_size
        collection.add(
            ids=ids[start:end],
            documents=documents[start:end],
            metadatas=metadatas[start:end],
        )

    print(f"Indexed {collection.count()} chunks into ChromaDB at {db_path}")
    return collection.count()


if __name__ == "__main__":
    import sys

    if len(sys.argv) < 2:
        print("Usage: py ingest.py <project_path>")
        sys.exit(1)

    project_path = sys.argv[1]
    if not Path(project_path).is_dir():
        print(f"Error: {project_path} is not a directory")
        sys.exit(1)

    ingest_project(project_path)

Your ingestion pipeline is complete. It handles code, markdown, and PDFs with specialized parsers that preserve the structure needed for accurate citations. Next up, you will build the query engine that searches these chunks and synthesizes answers with Claude.

Create the Vector Store and Embedding Index

Your parsing functions can break code, markdown, and PDFs into meaningful chunks. But those chunks need somewhere to live where they can be searched by meaning, not just by filename or keyword.

That's what a vector store does. It converts each chunk into a numerical representation (an embedding) and stores it so you can later ask "find me the chunks most similar to this question." In this step, you'll run the ingestion pipeline to populate a ChromaDB vector store and verify it's working with a direct similarity search.

In this step, get ready to:
  • Run the ingestion pipeline on your sample project to populate ChromaDB.
  • Confirm the chunk count stored in the vector store.
  • Test a similarity search to verify relevant chunks are returned.
Run the ingestion pipeline on your sample project

The ingest_project() function you built in the previous step handles the full ingestion flow. It discovers files, parses them into chunks, and stores everything in ChromaDB. Now it's time to run it.

  • Run the ingestion script against your sample project folder by running this command:
py ingest.py [[PROJECT_PATH="path/to/your/sample-project"]]

You should see output listing the number of discovered files and total parsed chunks, followed by the number of chunks indexed into ChromaDB.

What happens when you run this?

The script walks your project folder, classifies every file by type, then routes each file to the right parser. Code files get AST-aware chunking. PDFs get layout-aware extraction. Markdown gets structure-aware splitting.

Once all chunks are parsed, ingest_project() creates a ChromaDB PersistentClient at ./.chroma_db and stores every chunk with its metadata. ChromaDB automatically generates vector embeddings using its built-in all-MiniLM-L6-v2 model. No extra embedding setup needed.

Why ChromaDB over FAISS?

ChromaDB provides persistent storage, built-in embedding, and a simple API. FAISS requires manual embedding management and has no built-in persistence. For a prototype that will grow into a product, ChromaDB's architecture allows future scaling without rewriting your storage layer.

Seeing an import error?

Make sure your virtual environment is still activated. You should see (.venv) at the start of your terminal prompt. If not, re-activate it with .venv\Scripts\activate and try again.

If you see an error about tree_sitter or tree_sitter_languages, install it separately with pip install tree-sitter-languages. The CodeSplitter depends on it for AST-based parsing.

help me debug my ingest.py import error

Confirm the chunk count in ChromaDB

The ingestion printed how many chunks it indexed. You can verify this independently by querying ChromaDB directly.

  • Check the collection count by running this command:
py -c "import chromadb; c = chromadb.PersistentClient(path='./.chroma_db'); print(c.get_collection('project_index').count())"

The number printed should match the "Indexed X chunks" line from your ingestion output.

What is the project_index collection?

A ChromaDB collection is like a table in a database, but optimized for vector similarity search. Your project_index collection holds every chunk from your project along with its metadata (file path, file type, page number) and its embedding vector.

The PersistentClient stores everything in the .chroma_db folder on disk. This means you can close your terminal, come back later, and query the same index without re-running ingestion.

Count shows 0?

Make sure you ran the py -c command from the same directory where you ran py ingest.py. The .chroma_db folder is created relative to your current working directory.

Also confirm your sample project folder actually contains files with supported extensions (.py, .md, .pdf, etc.).

help me figure out why my ChromaDB collection is empty

Test a similarity search for a known term

The real test of a vector store is whether it returns relevant results when you search by meaning. You'll query the collection for a term you know exists in your sample project.

  • Run a similarity search against your indexed project by running this command:
py -c "import chromadb; c = chromadb.PersistentClient(path='./.chroma_db'); col = c.get_collection('project_index'); results = col.query(query_texts=['[[SEARCH_TERM="a term you know exists in your project, e.g. main function"]'], n_results=3); print(results['documents'][0][0][:200]); print('---'); print(results['metadatas'][0][0])"

You should see a snippet of text from your project that relates to your search term, followed by its metadata showing the source file path and type.

What does this prove?

This query converts your search text into a vector embedding, then finds the 3 closest chunks by cosine similarity. The fact that it returns content related to your search term (not just keyword-matched text) proves the semantic search is working.

The metadata attached to each result is what makes citations possible later. When the query engine retrieves these chunks, it can tell you exactly which file (and which page for PDFs) the information came from.

Results don't look relevant?

Try a more specific search term that you know appears in your project files. Generic terms like "code" or "function" will match many chunks with low specificity.

If results contain garbled text, your PDF files may have scanned images instead of selectable text. Try searching for content from your .py or .md files instead.

help me understand my ChromaDB similarity search results

Your vector store is live and searchable. Next up, you'll build a query engine that retrieves these chunks and uses Claude to synthesize answers with source citations.

Build the Query Engine with Citations

Your vector store is loaded with indexed chunks from the engineering project. Now you need a way to search those chunks and turn the results into useful, cited answers.

In this step, you will build the query engine that retrieves relevant context from ChromaDB, sends it to Claude with a carefully designed prompt, and formats the response with file-level citations. You will also create an interactive CLI so you can ask questions from the terminal.

In this step, get ready to:
  • Configure Claude as the LLM and design prompts for cited answers.
  • Build the retrieval and formatting pipeline that feeds context to Claude.
  • Create an interactive CLI for asking questions about your project.
Configure the Claude-powered query engine

The query engine needs three things: a connection to your vector store, a way to retrieve relevant chunks, and a prompt that tells Claude exactly how to cite its sources.

  • Create a new file called query_engine.py in your project folder.
  • Add the imports and prompt constants by pasting the following code:
import os

import anthropic
import chromadb


SYSTEM_PROMPT = """You are an expert engineering assistant analyzing a project's codebase and documentation.
When answering questions:
1. Base your answers ONLY on the provided context chunks.
2. Cite your sources using the format [source: filename] or [source: filename, page N] for PDFs.
3. If the context does not contain enough information to answer, say so explicitly.
4. Be specific and technical in your answers.
5. When describing code, reference the actual function/class names from the context."""

QUERY_PROMPT_TEMPLATE = """Based on the following context from the project files, answer the user's question.

CONTEXT:
{context}

QUESTION: {question}

Provide a detailed answer with citations to the source files. End with a "Sources:" section listing all files referenced."""

What does this code do?

SYSTEM_PROMPT gives Claude its role and citation rules. It instructs Claude to only answer from the provided context and to always cite the source file.

QUERY_PROMPT_TEMPLATE is a template string with placeholders for {context} and {question}. Each query fills these in before sending to Claude.

  • Save query_engine.py.

Next, you need functions that connect to your ChromaDB index and retrieve relevant chunks for a given query.

  • Add the retrieval functions below your prompt constants in query_engine.py:
def get_chroma_collection(db_path: str = "./.chroma_db"):
    """Get the ChromaDB collection."""
    client = chromadb.PersistentClient(path=db_path)
    return client.get_collection("project_index")


def retrieve_context(collection, query: str, n_results: int = 5) -> list[dict]:
    """Retrieve relevant chunks from ChromaDB."""
    results = collection.query(query_texts=[query], n_results=n_results)

    chunks = []
    for i in range(len(results["ids"][0])):
        chunk = {
            "content": results["documents"][0][i],
            "metadata": results["metadatas"][0][i],
            "distance": results["distances"][0][i] if results.get("distances") else None,
        }
        chunks.append(chunk)

    return chunks

What does this code do?

get_chroma_collection() connects to your local ChromaDB database and returns the project_index collection you created during ingestion.

retrieve_context() runs a similarity search against the collection, returning the top 5 most relevant chunks along with their metadata and distance scores.

  • Save query_engine.py.

Seeing an import error?

Make sure your virtual environment is still activated. You should see (.venv) at the start of your terminal prompt. If not, re-activate it.

help me fix import errors in query_engine.py

Implement the citation formatting

Retrieved chunks are raw text with metadata. You need a function that formats them into a structured context string Claude can read, and another function that orchestrates the full question-answering flow.

  • Add the format_context() function below retrieve_context() in query_engine.py:
def format_context(chunks: list[dict]) -> str:
    """Format retrieved chunks into a context string for the LLM."""
    context_parts = []
    for i, chunk in enumerate(chunks, 1):
        meta = chunk["metadata"]
        file_path = meta.get("file_path", "unknown")
        file_type = meta.get("file_type", "unknown")
        page = meta.get("page_number", "")

        header = f"[Chunk {i}] File: {file_path}"
        if page:
            header += f" | Page: {page}"
        header += f" | Type: {file_type}"

        context_parts.append(f"{header}\n{chunk['content']}")

    return "\n\n---\n\n".join(context_parts)

What does this code do?

Each chunk gets a header line showing its file path, page number (for PDFs), and file type. This gives Claude explicit source information so it can cite the correct file in its answer.

The chunks are joined with --- separators so Claude can clearly distinguish where one chunk ends and the next begins.

  • Save query_engine.py.

Now add the main function that ties retrieval, formatting, and Claude together into one call.

  • Add the ask_question() function below format_context() in query_engine.py:
def ask_question(collection, question: str, n_results: int = 5) -> str:
    """Ask a question and get a cited answer."""
    chunks = retrieve_context(collection, question, n_results)

    if not chunks:
        return "No relevant context found in the indexed project."

    context = format_context(chunks)
    prompt = QUERY_PROMPT_TEMPLATE.format(context=context, question=question)

    client = anthropic.Anthropic()
    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=2048,
        system=SYSTEM_PROMPT,
        messages=[{"role": "user", "content": prompt}],
    )

    return message.content[0].text

What does this code do?

ask_question() is the full pipeline: retrieve the top 5 chunks, format them into context, fill in the prompt template, and send everything to Claude.

anthropic.Anthropic() automatically uses the ANTHROPIC_API_KEY environment variable you set earlier. The model claude-sonnet-4-20250514 provides strong reasoning at a lower cost than Opus, which is ideal for a development project.

  • Save query_engine.py.

Getting an authentication error?

The Anthropic SDK looks for the ANTHROPIC_API_KEY environment variable automatically. Confirm it is set in your current terminal session. If you opened a new terminal, you will need to set it again.

help me fix my Anthropic API key authentication

Build the interactive CLI loop

The query engine functions work, but you need a way to call them interactively. You will add one more function to query_engine.py for generating system overviews, then create a separate CLI file.

  • Add the generate_overview() function at the bottom of query_engine.py:
def generate_overview(collection) -> str:
    """Generate a system overview by querying multiple aspects of the project."""
    queries = [
        "What is the main purpose and entry point of this project?",
        "What are the major components, modules, or classes?",
        "How do the components interact? What is the data flow?",
        "What external dependencies and libraries does this project use?",
        "What configuration files and build steps are needed?",
    ]

    all_context = []
    for query in queries:
        chunks = retrieve_context(collection, query, n_results=3)
        all_context.extend(chunks)

    # Deduplicate by content
    seen = set()
    unique_chunks = []
    for chunk in all_context:
        content_hash = hash(chunk["content"][:200])
        if content_hash not in seen:
            seen.add(content_hash)
            unique_chunks.append(chunk)

    context = format_context(unique_chunks[:15])

    overview_prompt = f"""Based on the following context from a project's codebase and documentation, generate a comprehensive system overview.

CONTEXT:
{context}

Generate a structured overview with these sections:
1. **What it does** - High-level purpose and functionality
2. **Major components** - Key modules, classes, or subsystems
3. **How they interact** - Data flow and control flow between components
4. **Key build/run steps** - How to set up and run the project
5. **Assumptions and unknowns** - What is unclear or undocumented

Also include a Mermaid diagram showing component relationships:
```mermaid
graph TD
    ...
```

Cite sources using [source: filename] format. End with a Sources section."""

    client = anthropic.Anthropic()
    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4096,
        system="You are an expert systems engineer creating documentation for an engineering project.",
        messages=[{"role": "user", "content": overview_prompt}],
    )

    return message.content[0].text

What does this code do?

Instead of answering a single question, generate_overview() runs 5 different queries to retrieve broad context about the project. It deduplicates overlapping chunks, then asks Claude to synthesize everything into a structured overview with a Mermaid diagram.

This uses max_tokens=4096 (double the normal limit) because the overview output is much longer than a typical Q&A response.

  • Save query_engine.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved query_engine.py before moving on.

ⓧ I'd like to double check the full code

import os

import anthropic
import chromadb


SYSTEM_PROMPT = """You are an expert engineering assistant analyzing a project's codebase and documentation.
When answering questions:
1. Base your answers ONLY on the provided context chunks.
2. Cite your sources using the format [source: filename] or [source: filename, page N] for PDFs.
3. If the context does not contain enough information to answer, say so explicitly.
4. Be specific and technical in your answers.
5. When describing code, reference the actual function/class names from the context."""

QUERY_PROMPT_TEMPLATE = """Based on the following context from the project files, answer the user's question.

CONTEXT:
{context}

QUESTION: {question}

Provide a detailed answer with citations to the source files. End with a "Sources:" section listing all files referenced."""


def get_chroma_collection(db_path: str = "./.chroma_db"):
    """Get the ChromaDB collection."""
    client = chromadb.PersistentClient(path=db_path)
    return client.get_collection("project_index")


def retrieve_context(collection, query: str, n_results: int = 5) -> list[dict]:
    """Retrieve relevant chunks from ChromaDB."""
    results = collection.query(query_texts=[query], n_results=n_results)

    chunks = []
    for i in range(len(results["ids"][0])):
        chunk = {
            "content": results["documents"][0][i],
            "metadata": results["metadatas"][0][i],
            "distance": results["distances"][0][i] if results.get("distances") else None,
        }
        chunks.append(chunk)

    return chunks


def format_context(chunks: list[dict]) -> str:
    """Format retrieved chunks into a context string for the LLM."""
    context_parts = []
    for i, chunk in enumerate(chunks, 1):
        meta = chunk["metadata"]
        file_path = meta.get("file_path", "unknown")
        file_type = meta.get("file_type", "unknown")
        page = meta.get("page_number", "")

        header = f"[Chunk {i}] File: {file_path}"
        if page:
            header += f" | Page: {page}"
        header += f" | Type: {file_type}"

        context_parts.append(f"{header}\n{chunk['content']}")

    return "\n\n---\n\n".join(context_parts)


def ask_question(collection, question: str, n_results: int = 5) -> str:
    """Ask a question and get a cited answer."""
    chunks = retrieve_context(collection, question, n_results)

    if not chunks:
        return "No relevant context found in the indexed project."

    context = format_context(chunks)
    prompt = QUERY_PROMPT_TEMPLATE.format(context=context, question=question)

    client = anthropic.Anthropic()
    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=2048,
        system=SYSTEM_PROMPT,
        messages=[{"role": "user", "content": prompt}],
    )

    return message.content[0].text


def generate_overview(collection) -> str:
    """Generate a system overview by querying multiple aspects of the project."""
    queries = [
        "What is the main purpose and entry point of this project?",
        "What are the major components, modules, or classes?",
        "How do the components interact? What is the data flow?",
        "What external dependencies and libraries does this project use?",
        "What configuration files and build steps are needed?",
    ]

    all_context = []
    for query in queries:
        chunks = retrieve_context(collection, query, n_results=3)
        all_context.extend(chunks)

    # Deduplicate by content
    seen = set()
    unique_chunks = []
    for chunk in all_context:
        content_hash = hash(chunk["content"][:200])
        if content_hash not in seen:
            seen.add(content_hash)
            unique_chunks.append(chunk)

    context = format_context(unique_chunks[:15])

    overview_prompt = f"""Based on the following context from a project's codebase and documentation, generate a comprehensive system overview.

CONTEXT:
{context}

Generate a structured overview with these sections:
1. **What it does** - High-level purpose and functionality
2. **Major components** - Key modules, classes, or subsystems
3. **How they interact** - Data flow and control flow between components
4. **Key build/run steps** - How to set up and run the project
5. **Assumptions and unknowns** - What is unclear or undocumented

Also include a Mermaid diagram showing component relationships:
```mermaid
graph TD
    ...
```

Cite sources using [source: filename] format. End with a Sources section."""

    client = anthropic.Anthropic()
    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4096,
        system="You are an expert systems engineer creating documentation for an engineering project.",
        messages=[{"role": "user", "content": overview_prompt}],
    )

    return message.content[0].text

Now create the CLI file that lets you interact with the query engine from your terminal.

  • Create a new file called cli.py in your project folder and paste the following code:
import sys

from query_engine import get_chroma_collection, ask_question, generate_overview


def main():
    """Interactive CLI for project Q&A."""
    print("=" * 60)
    print("  Engineering Project Q&A System")
    print("=" * 60)
    print()
    print("Commands:")
    print("  Type a question to query the project")
    print("  'overview'  - Generate a system overview")
    print("  'quit'      - Exit the program")
    print()

    try:
        collection = get_chroma_collection()
        doc_count = collection.count()
        print(f"Connected to index with {doc_count} chunks.")
    except Exception as e:
        print(f"Error: Could not connect to index. Run ingest.py first. ({e})")
        sys.exit(1)

    print()

    while True:
        try:
            question = input("You: ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\nGoodbye!")
            break

        if not question:
            continue

        if question.lower() == "quit":
            print("Goodbye!")
            break

        if question.lower() == "overview":
            print("\nGenerating system overview (this may take a moment)...\n")
            result = generate_overview(collection)
            print(result)
            print()
            continue

        print("\nSearching and synthesizing...\n")
        result = ask_question(collection, question)
        print(result)
        print()


if __name__ == "__main__":
    main()

What does this code do?

The CLI prints a header, connects to the ChromaDB collection, and enters an infinite loop waiting for user input. Typing a question triggers ask_question() which retrieves context and calls Claude.

The overview command triggers the multi-query generate_overview() function for a full project summary. Typing quit exits cleanly.

  • Save cli.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved cli.py before testing.

ⓧ I'd like to double check the full code

import sys

from query_engine import get_chroma_collection, ask_question, generate_overview


def main():
    """Interactive CLI for project Q&A."""
    print("=" * 60)
    print("  Engineering Project Q&A System")
    print("=" * 60)
    print()
    print("Commands:")
    print("  Type a question to query the project")
    print("  'overview'  - Generate a system overview")
    print("  'quit'      - Exit the program")
    print()

    try:
        collection = get_chroma_collection()
        doc_count = collection.count()
        print(f"Connected to index with {doc_count} chunks.")
    except Exception as e:
        print(f"Error: Could not connect to index. Run ingest.py first. ({e})")
        sys.exit(1)

    print()

    while True:
        try:
            question = input("You: ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\nGoodbye!")
            break

        if not question:
            continue

        if question.lower() == "quit":
            print("Goodbye!")
            break

        if question.lower() == "overview":
            print("\nGenerating system overview (this may take a moment)...\n")
            result = generate_overview(collection)
            print(result)
            print()
            continue

        print("\nSearching and synthesizing...\n")
        result = ask_question(collection, question)
        print(result)
        print()


if __name__ == "__main__":
    main()

Time to test your Q&A system end-to-end.

  • Run the CLI by entering this command:
py cli.py
  • You should see the header banner and a message like Connected to index with N chunks. followed by a You: prompt waiting for input.
  • Type What does this project do? and press Enter.
  • Claude responds with a detailed answer referencing actual file names from your indexed project, followed by a Sources: section.
  • Type quit to exit the CLI.

Not seeing cited answers?

If the CLI says it could not connect to the index, make sure you ran ingest.py on your sample project first. The .chroma_db directory must exist in the same folder as cli.py.

If you get an API error, verify your ANTHROPIC_API_KEY is set and has available credits. You can check your usage at console.anthropic.com.

help me debug my CLI not returning cited answers

Your engineering Q&A system is live. You can now ask any natural-language question about the project and get cited answers pointing to exact files. Next up, you will take this further with a Secret Mission that auto-generates full system overviews.

Secret mission

Auto-Generate System Overview

Your Q&A system answers individual questions well. But what if you could generate a complete architecture overview of an entire project in one command? In this secret mission, you will explore the multi-query synthesis approach that retrieves broadly across your index and produces a structured system overview with a Mermaid diagram and source citations.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no ongoing costs. The Anthropic API only charges when you actively run queries.

Resources you used:

  • ChromaDB vector store at ./.chroma_db (local files on disk).
  • Python virtual environment at .venv.
  • Project files: ingest.py, query_engine.py, cli.py.
  • Anthropic API key (managed at console.anthropic.com).

Keep everything running

No action needed. Choose this if you plan to keep querying your indexed project or want to index additional projects.

  • The .chroma_db folder persists your vector index between runs. You can re-run cli.py without re-indexing.
  • Your ANTHROPIC_API_KEY environment variable is session-only. You will need to set it again in a new terminal window.
  • The virtual environment at .venv stays on disk. Reactivate it next time you open the project.

Pause - I'll come back to this later

There are no running services to stop. Everything in this project is files on disk. Your index and source files will be waiting for you when you come back.

  • Deactivate your virtual environment by running:
deactivate
  • That is the only action needed. When you return, reactivate the environment and set your API key again.

Delete - I don't want to use this again

Remove all project resources and revoke your API key. This is a complete teardown.

  • Deactivate your virtual environment first:
deactivate
  • Navigate up one level out of your project folder:
cd ..
  • Delete the ChromaDB database, virtual environment, and project files.

macOS

rm -rf .chroma_db .venv ingest.py query_engine.py cli.py

Windows

rmdir /s /q .chroma_db .venv
del ingest.py query_engine.py cli.py
  • Revoke your Anthropic API key so it cannot be used again.
  • Go to console.anthropic.com.
  • Navigate to your API Keys page.
  • Find the key you created for this project and delete it.

Nice Work!

Nice Work!

You just built a fully functional engineering project Q&A system powered by Retrieval-Augmented Generation. What used to take hours of manual code reading now takes a single question in your CLI.

You've learned how to:

  • Build a document ingestion pipeline that parses source code with AST-aware chunking, markdown with structure-aware splitting, and PDFs with layout-aware extraction.
  • Create a persistent ChromaDB vector store for fast semantic search across an entire engineering project.
  • Implement a Claude-powered query engine that retrieves relevant context and synthesizes answers with file-level source citations.
  • (Secret Mission) Added auto-generation of structured system overviews with Mermaid diagrams and cited source references.

Ready to quiz yourself?