Table of Contents

Index Parsed Documents for RAG with Page Citations

Retrieval is only as good as the text it indexes. A PDF read as a flat text layer loses what answers depend on: a table's columns, a chart's numbers, the heading a paragraph belongs to. DocumentParser keeps that structure, and its elements map directly onto the chunks and metadata a RAG index needs.

This guide parses documents, indexes one section per page with Markdown-aware chunking, keeps the page number on every chunk, and answers questions with citations.


Prerequisites

Requirement Minimum
.NET SDK 8.0+
GPU Recommended for the parser's model levels (Minimal needs none); the embedding and chat models run on the CPU too
Disk The parser's models (about 2.5 GB) plus an embedding and a chat model

Read Parse Documents into Structured Elements first for the parser itself, and Build a RAG Pipeline for the retrieval side.


Step 1: Parse, One Section per Page

Index each page as its own section, so every retrieved chunk knows the page it came from. Build the page text from ParsedPage.Body: running heads and feet repeat on every page and only add noise to an index.

using LMKit.Data;
using LMKit.Document.Parsing;
using LMKit.Embeddings;
using LMKit.Model;
using LMKit.Retrieval;

using var parser = new DocumentParser(ParsingEffort.Medium);

LM embeddingModel = LM.LoadFromModelID("embeddinggemma-300m");
var rag = new RagEngine(new Embedder(embeddingModel));
var chunking = new MarkdownChunking { MaxChunkSize = 500 };

foreach (string file in Directory.GetFiles("reports", "*.pdf"))
{
    ParsedDocument document = parser.Parse(file);

    foreach (ParsedPage page in document.Pages)
    {
        string text = string.Join("\n\n", page.Body
            .Select(element => element.ToMarkdown())
            .Where(markdown => markdown.Length > 0));

        if (text.Length == 0)
        {
            continue;
        }

        var metadata = new MetadataCollection();
        metadata.Add("source", Path.GetFileName(file));
        metadata.Add("page", page.PageNumber.ToString());

        rag.ImportText(text, chunking, "reports", $"{Path.GetFileName(file)}#p{page.PageNumber}", metadata);
    }
}

MarkdownChunking splits on headings and paragraph breaks, so a chunk rarely separates a heading from its text. Tables stay HTML and charts stay tables, which embedding models read as data rather than as a stream of numbers.


Step 2: Answer with Citations

The section identifier carries the file and the page, so every answer can say where it came from.

using LMKit.TextGeneration;

LM chatModel = LM.LoadFromModelID("qwen3.5:9b");
var chat = new SingleTurnConversation(chatModel)
{
    SystemPrompt = "Answer only from the provided context and cite the sources you used."
};

string question = "How did revenue by region change between 2024 and 2025?";
var matches = rag.FindMatchingPartitions(question, topK: 5, minScore: 0.3f);

foreach (var match in matches)
{
    Console.WriteLine($"  source: {match.SectionIdentifier}  score {match.Similarity:F2}");
}

var answer = rag.QueryPartitions(question, matches, chat);
Console.WriteLine(answer.Completion);

Step 3: Parse Once, Index Many Times

Parsing is the expensive step. Store each parse as JSON and rebuild or re-chunk the index later without the source documents or the parser's models:

File.WriteAllText("report.parsed.json", document.ToJson());

// Later, on any machine:
ParsedDocument stored = ParsedDocument.FromJson(File.ReadAllText("report.parsed.json"));

The stored parse renders the same Markdown as the original, so a changed chunk size or a new embedding model only costs the indexing.


Choosing the Effort Level

Every level returns the same ParsedDocument, so the indexing code above does not change with the level; only the time per page and the fidelity of tables, charts and headings do.

Level Model Index it for
ParsingEffort.Minimal None: the text layer, OCR for scans, Word, HTML and e-mail from their structure Born-digital corpora at volume, machines without a GPU
ParsingEffort.Low The parser's model, the least time per page Large corpora of scans and mixed documents
ParsingEffort.Medium The parser's model, balanced Most business documents
ParsingEffort.High The parser's model, the most faithful Reports where answers quote tables and charts

A pipeline that must make documents searchable at once and faithful later indexes at Minimal, then parses again at a model level in the background and replaces the pages. This is how LM-Kit One search tenants index:

using var fast = new DocumentParser(ParsingEffort.Minimal);   // no model loaded
using var faithful = new DocumentParser(ParsingEffort.High);

ParsedDocument now = fast.Parse(file);        // index these pages immediately
ParsedDocument later = faithful.Parse(file);  // then replace them, off the request path

Choosing What to Index

Element Index it as Why
Titles Part of the chunk text MarkdownChunking keeps a heading with the text under it
Tables Their HTML Column relationships survive; embedding models read the cells as a table
Charts Their data table, caption and legend A question about a number finds the chart that plots it
Headers and footers Nothing They repeat on every page
Figures without data Their caption A photograph or a logo has no text to retrieve, but its caption does

Next Steps

Share