Groundline
Grounded document Q&A that refuses to guess
Answers questions over a private corpus and declines anything it cannot cite. Ingestion, chunking and embedding run as background jobs, retrieval is pgvector, and every claim in a response is checked back against a span in a source document before a single token is streamed to the client.
Retrieval quality, grounding enforcement, and evaluation as a test suite.
- .NET 10
- ASP.NET Core
- Claude API
- PostgreSQL + pgvector
- Dapper
- Hangfire
- SignalR
- OpenTelemetry
Personal build — no public repository yet
The problem worth solving
Retrieval-augmented generation is easy to demo and hard to trust. Chunk some documents, embed them, stuff the top five into a prompt, and you get an answer that is usually right and occasionally invents a policy that does not exist. For anything a person will act on, “usually right” is the wrong target.
So the requirement is inverted. Rather than trying to make hallucination rare, the system is built so an unsupported claim cannot reach the client: a claim without a citation is a bug the pipeline catches, and a question the corpus cannot answer gets a refusal.
A system that answers 80% of questions correctly and refuses the rest is useful. One that answers 95% correctly and guesses the rest is not, because you cannot tell which is which.
Why this and not more of DeskPilot
DeskPilot puts a model in a request path under cost and latency pressure. Groundline puts one behind a retrieval system where the hard problem is upstream of the model entirely — if retrieval returns the wrong spans, no prompt saves the answer.
| DeskPilot | Groundline | |
|---|---|---|
| Hard part | Cost, latency, throughput | Retrieval quality and grounding |
| Model's job | Classify and draft | Summarise only what it was handed |
| Where failure starts | The provider | The chunker |
| Correct failure mode | Route to a human | Refuse to answer |
| Data shape | One short ticket | A corpus that changes under you |
The ingestion path
The span index is the part that makes citation possible. Every chunk keeps the document id, page and character offsets it came from, so a citation resolves to an exact range in the original rather than to a paraphrase the reader has to go and verify by hand.
Chunking is the quality ceiling
Almost every bad answer in a system like this traces back to a chunk boundary. Split mid-table and the numbers lose their headers. Split by fixed token count and a clause loses the condition that qualified it. No amount of prompt engineering repairs a chunk that arrived without its context.
- Split on document structure — headings and sections — before falling back to size limits.
- Never split a table; an oversized table becomes its own chunk with its caption attached.
- Each chunk carries its heading path as a prefix, so a fragment read in isolation still says what it is about.
- Overlap between adjacent chunks, so a fact near a boundary is retrievable from either side.
Hybrid retrieval, then reranking
Vector search is good at meaning and bad at exact tokens. Ask for an error code, a part number or a clause reference and embeddings will cheerfully return something semantically adjacent and literally wrong. Keyword search has the opposite failure. Running both and merging is not hedging; it is covering two different, well-understood weaknesses.
| Stage | Tool | Catches |
|---|---|---|
| Dense retrieval | pgvector, cosine over embeddings | Paraphrase and conceptual overlap |
| Sparse retrieval | PostgreSQL full-text search | Exact identifiers, codes, rare terms |
| Fusion | Reciprocal rank fusion | Merges both without tuning a weight per query |
| Rerank | Cross-encode the top candidates | Precision in the small window that reaches the model |
pgvector rather than a dedicated vector database, because the corpus is measured in hundreds of thousands of chunks, not billions. Keeping vectors in PostgreSQL means one datastore, one backup story, one transaction — and filtering by tenant or document is a WHERE clause instead of a metadata-filter feature request. That answer changes at a scale this MVP is not going to reach.
Grounding is enforced, not requested
Asking a model to cite its sources produces citations. It does not produce correct ones, and a fabricated citation is worse than none at all because it looks like diligence. So the check is mechanical and it runs after generation.
- 1Answer in claims, not proseThe model returns a list of claims via a tool schema, each with the chunk ids it came from — not a paragraph with footnotes bolted on.
- 2Resolve every citationEach cited chunk id must exist in the set that was actually retrieved for this question. A citation to anything else is a fabrication and fails the response outright.
- 3Verify supportA second cheap pass checks each claim against its cited span alone and answers one question: does this text support this claim. Unsupported claims are dropped.
- 4Refuse rather than thin outIf dropping claims leaves the answer incomplete, the response becomes an explicit “not answerable from these documents” with the closest retrieved passages shown. Refusal is a feature.
Streaming an answer you have not finished checking
Verification after generation and streaming during generation are in direct conflict — you cannot stream a token you might later retract. This is the sharpest tension in the design, and the resolution is to stream the part that is already safe.
- Retrieved sources stream first, immediately: the reader sees which documents are being used while the answer is still being written.
- Claims stream individually as each one clears verification, rather than the whole answer arriving at once.
- A dropped claim never appears, so nothing is ever visibly retracted mid-stream.
- SignalR carries it, and a reconnecting client refetches the completed answer instead of resuming a partial stream.
The result is slower to first complete sentence than an ungrounded system and faster to first useful signal, because the sources arrive almost instantly. That is a better trade than it sounds: knowing which documents are being consulted is often most of what the reader wanted.
Evaluation, split three ways
A single accuracy number for a system like this is useless, because it cannot tell you which stage regressed. Retrieval and generation are measured separately, and refusal is measured on its own.
| Measures | Metric | Answers |
|---|---|---|
| Retrieval | Recall@k over labelled question-to-chunk pairs | Did the right passage make it into the window at all |
| Grounding | Share of claims with a verified supporting span | Is the model staying inside what it was handed |
| Refusal | Behaviour on a set of deliberately unanswerable questions | Does it decline instead of improvising |
The refusal set matters most and is the one usually missing. Without it, every change that makes the system more willing to answer looks like an improvement, and the regression only surfaces when someone acts on a confident invention.
A corpus that changes underneath you
Documents get revised, superseded and withdrawn. A cited span that no longer exists turns a citation into a dead link, and an answer grounded in a withdrawn policy is worse than no answer.
- Documents are versioned; chunks belong to a version rather than to a filename.
- Re-ingestion is incremental — only changed sections are re-embedded, because embedding an unchanged corpus nightly is pure cost.
- Superseded versions stay queryable but are marked, so an old answer can still be explained after the fact.
- Every answer records the document versions it used, which is what makes an audit possible at all.
The stack, and why each piece is there
| Concern | Choice | Why this one |
|---|---|---|
| Model access | Microsoft.Extensions.AI | One IChatClient abstraction over generation and verification, with a middleware pipeline for telemetry |
| Vectors | PostgreSQL + pgvector | One datastore, real transactions, tenant filtering as a WHERE clause |
| Reads | Dapper | Hybrid retrieval is hand-written SQL by nature; an ORM adds nothing here |
| Writes | EF Core 10 | Document and version bookkeeping, where change tracking earns its keep |
| Ingestion | Hangfire | Long, retryable, observable jobs with no user waiting on them |
| Resilience | Microsoft.Extensions.Http.Resilience | First-party standard pipeline rather than hand-assembled Polly strategies |
| Streaming | SignalR | Sources first, then verified claims — client-facing only |
| API docs | Scalar + built-in OpenAPI | ASP.NET Core generates the document itself; no Swashbuckle needed |
| Observability | Serilog + OpenTelemetry | Retrieval recall, grounding rate and refusal rate as metrics, not log lines |
| Tests | xUnit v3 + Testcontainers | A real PostgreSQL with pgvector; mocking a vector index tests nothing |
Trade-offs I am accepting
| Decision | Cost |
|---|---|
| Verification pass | Two model calls per answer: slower and roughly double the cost |
| Refusing when unsupported | Lower answer rate, and users who wanted a guess will find it unhelpful |
| pgvector over a vector database | A ceiling on corpus size that a larger deployment would hit |
| Hybrid retrieval plus rerank | Three stages to tune and measure instead of one |
| Claims instead of prose | Answers read as more clipped than a single fluent paragraph |
Want to talk through the design?
It is a design, which means it is still arguable. If you think one of the trade-offs above is wrong, I would rather hear it now than after the build.