← Back to DevBytes

Building a Documentation Generator with LlamaIndex: Complete Guide

Building a Documentation Generator with LlamaIndex: Complete Guide

Documentation is often the most neglected part of the software development lifecycle. Teams ship code faster than they can write docs, and stale documentation becomes a liability rather than an asset. With the rise of large language models (LLMs) and retrieval-augmented generation (RAG), we now have the tools to build intelligent documentation generators that can read source code, understand structure, and produce human-readable explanations on demand. In this guide, you'll learn how to build a complete documentation generator using LlamaIndex, a popular framework for connecting LLMs to your data.

What Is LlamaIndex?

LlamaIndex is a data framework designed to ingest, structure, and access private or domain-specific data for use with LLMs. While it's commonly associated with building RAG-powered chatbots, its tooling is equally powerful for tasks like summarization, metadata extraction, and automated content generation. At its core, LlamaIndex provides abstractions for documents, nodes, indices, query engines, and response synthesizers — all of which we'll leverage to build our documentation generator.

Core Concepts You'll Use

Why Build a Documentation Generator?

Manual documentation is slow, error-prone, and often out of date the moment a pull request merges. An LLM-powered documentation generator offers several compelling advantages. It can analyze code structure, infer intent from naming conventions and comments, and produce consistent documentation across an entire codebase. It can also regenerate docs whenever the source changes, ensuring your documentation stays in sync with reality. Finally, because LlamaIndex supports retrieval, you can build a generator that not only writes docs but also answers follow-up questions about the codebase.

Project Setup

Let's start by setting up a clean Python environment. We'll use Python 3.10 or later, and we recommend creating a virtual environment to isolate dependencies.

# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install required packages
pip install llama-index llama-index-core llama-index-llms-openai
pip install llama-index-readers-file tree-sitter tree-sitter-languages
pip install python-dotenv

You'll need an OpenAI API key. Store it in a .env file at the root of your project:

OPENAI_API_KEY=sk-your-key-here

Now create the main project structure:

doc-generator/
├── .env
├── main.py
├── loader.py
├── generator.py
├── exporter.py
└── sample_project/
    ├── math_utils.py
    └── string_utils.py

Loading Source Code with LlamaIndex

The first step is loading your source files into LlamaIndex Document objects. LlamaIndex provides a SimpleDirectoryReader that can recursively walk a directory and load files based on extensions. We'll also attach useful metadata such as the file path and language.

# loader.py
import os
from llama_index.core import SimpleDirectoryReader, Document
from typing import List

SUPPORTED_EXTENSIONS = [".py", ".js", ".ts", ".java", ".go", ".rs"]

def load_source_files(directory: str) -> List[Document]:
    """Load all supported source files from a directory tree."""
    if not os.path.isdir(directory):
        raise ValueError(f"Directory does not exist: {directory}")

    reader = SimpleDirectoryReader(
        input_dir=directory,
        required_exts=SUPPORTED_EXTENSIONS,
        recursive=True,
        filename_as_id=True,
    )

    documents = reader.load_data(show_progress=True)

    # Enrich metadata
    for doc in documents:
        file_path = doc.metadata.get("file_path", "")
        ext = os.path.splitext(file_path)[1].lower()
        language_map = {
            ".py": "python",
            ".js": "javascript",
            ".ts": "typescript",
            ".java": "java",
            ".go": "go",
            ".rs": "rust",
        }
        doc.metadata["language"] = language_map.get(ext, "unknown")
        doc.metadata["filename"] = os.path.basename(file_path)

    return documents

This loader walks the target directory, picks up only the file types we care about, and tags each document with its programming language. That metadata will be valuable when we craft prompts for the LLM.

Parsing Code into Meaningful Nodes

Raw files can be large, and feeding an entire file to an LLM in one shot may exceed context limits or produce unfocused output. A better approach is to split each file into logical units — functions, classes, and methods — using a code parser. LlamaIndex's CodeSplitter leverages tree-sitter to split source code on syntax boundaries.

# generator.py (part 1)
from llama_index.core.node_parser import CodeSplitter
from llama_index.core.schema import Node

def split_into_nodes(documents, language: str = "python"):
    """Split documents into syntax-aware nodes."""
    splitter = CodeSplitter(
        language=language,
        chunk_lines=80,
        chunk_lines_overlap=15,
        max_chars=3000,
    )
    nodes = []
    for doc in documents:
        try:
            doc_nodes = splitter.get_nodes_from_documents([doc])
            nodes.extend(doc_nodes)
        except Exception as e:
            print(f"Skipping {doc.metadata.get('filename')}: {e}")
    return nodes

Each node now represents a coherent chunk of code — typically a function or class — along with the metadata inherited from its parent document. This granularity lets us generate documentation per function rather than per file, which produces more useful and navigable output.

Generating Documentation with an LLM

Now we'll use an LLM to generate documentation for each node. We'll construct a prompt that asks the model to produce a docstring-style summary, parameter descriptions, return value information, and a usage example. LlamaIndex's LLM abstraction makes it easy to swap providers.

# generator.py (part 2)
from llama_index.llms.openai import OpenAI
from llama_index.core import Settings

def configure_llm(model: str = "gpt-4o-mini", temperature: float = 0.2):
    """Configure the global LLM settings."""
    llm = OpenAI(model=model, temperature=temperature)
    Settings.llm = llm
    return llm

PROMPT_TEMPLATE = """You are a senior software engineer writing clear, accurate documentation.

Below is a code snippet from the file `{filename}` written in {language}.

Generate documentation in Markdown with the following sections:
1. A one-paragraph summary of what the code does.
2. A "Parameters" section (if applicable) with a table of name, type, and description.
3. A "Returns" section (if applicable) describing the return value.
4. A "Raises" section (if applicable) listing exceptions.
5. A "Example" section with a short, runnable code example.

Code:
{code}
Respond with only the Markdown documentation, no preamble.
"""

def generate_doc_for_node(node, llm) -> str:
    """Generate documentation for a single code node."""
    prompt = PROMPT_TEMPLATE.format(
        filename=node.metadata.get("filename", "unknown"),
        language=node.metadata.get("language", "unknown"),
        code=node.get_content(),
    )
    response = llm.complete(prompt)
    return response.text.strip()

The low temperature of 0.2 keeps the output factual and consistent. The prompt is explicit about structure so that every generated doc follows the same format, which makes downstream rendering predictable.

Assembling and Exporting Documentation

Once we have documentation for each node, we need to assemble it into a coherent document. We'll group entries by source file and write the result to a Markdown file. We'll also generate an index page that links to each file's documentation.

# exporter.py
import os
from collections import defaultdict
from typing import List, Tuple

def export_documentation(
    results: List[Tuple[str, str, str]],
    output_dir: str = "docs_output"
):
    """
    Export generated documentation to Markdown files.

    Args:
        results: A list of (filename, language, doc_markdown) tuples.
        output_dir: Directory where Markdown files will be written.
    """
    os.makedirs(output_dir, exist_ok=True)

    grouped = defaultdict(list)
    for filename, language, doc_md in results:
        grouped[filename].append((language, doc_md))

    index_entries = []

    for filename, entries in grouped.items():
        safe_name = filename.replace(os.sep, "_").replace(".", "_")
        out_path = os.path.join(output_dir, f"{safe_name}.md")

        with open(out_path, "w", encoding="utf-8") as f:
            f.write(f"# Documentation: {filename}\n\n")
            language = entries[0][0]
            f.write(f"**Language:** {language}\n\n")
            f.write("---\n\n")

            for idx, (lang, doc_md) in enumerate(entries, start=1):
                f.write(f"## Section {idx}\n\n")
                f.write(doc_md)
                f.write("\n\n---\n\n")

        index_entries.append((filename, f"{safe_name}.md"))

    # Write index page
    index_path = os.path.join(output_dir, "index.md")
    with open(index_path, "w", encoding="utf-8") as f:
        f.write("# Project Documentation Index\n\n")
        f.write("This documentation was auto-generated from source code.\n\n")
        for filename, link in sorted(index_entries):
            f.write(f"- [{filename}]({link})\n")

    print(f"Documentation exported to {output_dir}/")

Putting It All Together

Now let's wire everything together in a main script. This script loads the source files, splits them into nodes, generates documentation for each node, and exports the results.

# main.py
import os
from dotenv import load_dotenv
from loader import load_source_files
from generator import configure_llm, split_into_nodes, generate_doc_for_node
from exporter import export_documentation

def main():
    load_dotenv()

    source_dir = os.environ.get("SOURCE_DIR", "sample_project")
    output_dir = os.environ.get("OUTPUT_DIR", "docs_output")
    model_name = os.environ.get("MODEL_NAME", "gpt-4o-mini")

    print(f"Loading source files from {source_dir}...")
    documents = load_source_files(source_dir)
    print(f"Loaded {len(documents)} document(s).")

    print("Configuring LLM...")
    llm = configure_llm(model=model_name)

    print("Splitting documents into nodes...")
    nodes = split_into_nodes(documents, language="python")
    print(f"Created {len(nodes)} node(s).")

    print("Generating documentation...")
    results = []
    for i, node in enumerate(nodes, start=1):
        print(f"  [{i}/{len(nodes)}] {node.metadata.get('filename')}")
        doc_md = generate_doc_for_node(node, llm)
        results.append((
            node.metadata.get("filename", "unknown"),
            node.metadata.get("language", "unknown"),
            doc_md,
        ))

    print("Exporting documentation...")
    export_documentation(results, output_dir)
    print("Done!")

if __name__ == "__main__":
    main()

Create a sample source file to test the generator:

# sample_project/math_utils.py

def clamp(value, min_val, max_val):
    """Constrain a value within a range."""
    if value < min_val:
        return min_val
    if value > max_val:
        return max_val
    return value

def factorial(n):
    if n < 0:
        raise ValueError("n must be non-negative")
    if n == 0:
        return 1
    return n * factorial(n - 1)

class Statistics:
    def __init__(self, data):
        self.data = data

    def mean(self):
        return sum(self.data) / len(self.data)

    def variance(self):
        m = self.mean()
        return sum((x - m) ** 2 for x in self.data) / len(self.data)

Run the generator:

python main.py

You should see progress output in the terminal and a docs_output/ directory containing an index.md file plus one Markdown file per source file, each with structured documentation for every function and class.

Adding a Query Engine for Interactive Docs

Static documentation is useful, but interactive documentation is even better. Since we already have our code indexed as nodes, we can build a vector store index and expose a query engine that answers questions about the codebase in natural language.

# Add to main.py or a new query.py file
from llama_index.core import VectorStoreIndex

def build_query_engine(nodes):
    """Build a vector index and return a query engine."""
    index = VectorStoreIndex(nodes)
    query_engine = index.as_query_engine(
        similarity_top_k=5,
        response_mode="compact",
    )
    return query_engine

def interactive_session(query_engine):
    """Run a simple REPL for querying the codebase."""
    print("\nInteractive documentation query (type 'exit' to quit):")
    while True:
        question = input("\n> ").strip()
        if question.lower() in ("exit", "quit"):
            break
        response = query_engine.query(question)
        print(response.response)

Integrate this into main.py after generating documentation:

    # After export_documentation(results, output_dir)
    print("\nBuilding interactive query engine...")
    query_engine = build_query_engine(nodes)
    interactive_session(query_engine)

Now you can ask questions like "What does the factorial function do?" or "How is variance calculated?" and get answers grounded in your actual source code.

Best Practices

1. Validate and Review Generated Output

LLMs can hallucinate. Always treat generated documentation as a draft that requires human review. Consider adding a CI step that flags documentation files without a reviewer approval.

2. Use Consistent Prompts

Keep your prompt templates version-controlled. Small wording changes can dramatically alter output structure. Pin your prompt versions just as you would pin dependencies.

3. Cache Expensive Calls

Generating documentation for a large codebase can be costly. Implement caching keyed on a hash of the source code content so that unchanged files don't trigger regeneration. A simple approach uses hashlib and a JSON manifest file.

import hashlib, json, os

def file_hash(content: str) -> str:
    return hashlib.sha256(content.encode()).hexdigest()

def load_cache(cache_path: str = ".doc_cache.json") -> dict:
    if os.path.exists(cache_path):
        with open(cache_path) as f:
            return json.load(f)
    return {}

def save_cache(cache: dict, cache_path: str = ".doc_cache.json"):
    with open(cache_path, "w") as f:
        json.dump(cache, f, indent=2)

4. Choose the Right Model

For straightforward code summarization, a smaller and cheaper model like gpt-4o-mini is often sufficient. For complex codebases with intricate business logic, consider a more capable model like gpt-4o or an open-source alternative hosted locally.

5. Respect Context Limits

The CodeSplitter helps, but always verify that your nodes fit within the model's context window. If a single function is extremely long, consider splitting it further or summarizing in multiple passes.

6. Include Existing Docstrings

If your code already has docstrings or comments, include them in the prompt as additional context. The LLM can refine and expand existing documentation rather than starting from scratch, which improves accuracy.

7. Generate Multiple Output Formats

Markdown is a great default, but you can also generate reStructuredText for Python projects, JSDoc for JavaScript, or even OpenAPI specs for API endpoints. Simply swap the prompt template and exporter.

Extending the Generator

Once you have the basic pipeline working, there are several valuable extensions to consider. You can integrate with Git to generate documentation only for changed files in a pull request, producing inline review comments. You can add a web interface using Streamlit or FastAPI so non-developers can browse and query documentation. You can also incorporate call graph analysis to document relationships between modules, or use LlamaIndex's KnowledgeGraphIndex to build a semantic graph of your codebase.

Another powerful extension is multi-language support. The CodeSplitter supports many languages through tree-sitter, so you can document polyglot repositories by passing the appropriate language per file based on its extension, as we did in the loader.

Conclusion

Building a documentation generator with LlamaIndex gives you a flexible, maintainable pipeline for turning source code into structured, human-readable documentation. By combining LlamaIndex's document loading, syntax-aware splitting, LLM integration, and retrieval capabilities, you can create a tool that not only writes docs but also answers questions about your codebase interactively. Start with the pipeline in this guide, adapt the prompts to your team's documentation style, and iterate on the output until it meets your quality bar. With caching, model selection, and human review in place, you'll have a documentation workflow that scales with your codebase and keeps your docs as fresh as your code.

— Ad —

Google AdSense will appear here after approval

← Back to all articles