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
- Parse Documents into Structured Elements: the parser, its effort levels and its exports.
- Extract Chart Data from Documents: charts as CSV.
- Build a RAG Pipeline: persistence, reranking and query tuning.
- Chunk HTML and Markdown for RAG: chunking strategies in depth.