Skip to content
Back to work
Architecture planPersonal MVPIn progress10 min read11 sections

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

01

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.

02

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.

DeskPilotGroundline
Hard partCost, latency, throughputRetrieval quality and grounding
Model's jobClassify and draftSummarise only what it was handed
Where failure startsThe providerThe chunker
Correct failure modeRoute to a humanRefuse to answer
Data shapeOne short ticketA corpus that changes under you
03

The ingestion path

Document uploadedPOST /documents
enqueue
Hangfire jobbackground
extract
Text + layoutper page
Structureheadings
chunk
Structure-aware chunkerwith overlap
embed
Embedding modelbatched
store
pgvectorvectors
PostgreSQL FTSkeywords
Span indexoffsets
Ingestion is entirely offline. Nothing here happens while somebody is waiting.

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.

04

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.
05

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.

StageToolCatches
Dense retrievalpgvector, cosine over embeddingsParaphrase and conceptual overlap
Sparse retrievalPostgreSQL full-text searchExact identifiers, codes, rare terms
FusionReciprocal rank fusionMerges both without tuning a weight per query
RerankCross-encode the top candidatesPrecision 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.

06

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
07

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.

08

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.

MeasuresMetricAnswers
RetrievalRecall@k over labelled question-to-chunk pairsDid the right passage make it into the window at all
GroundingShare of claims with a verified supporting spanIs the model staying inside what it was handed
RefusalBehaviour on a set of deliberately unanswerable questionsDoes 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.

09

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.
10

The stack, and why each piece is there

ConcernChoiceWhy this one
Model accessMicrosoft.Extensions.AIOne IChatClient abstraction over generation and verification, with a middleware pipeline for telemetry
VectorsPostgreSQL + pgvectorOne datastore, real transactions, tenant filtering as a WHERE clause
ReadsDapperHybrid retrieval is hand-written SQL by nature; an ORM adds nothing here
WritesEF Core 10Document and version bookkeeping, where change tracking earns its keep
IngestionHangfireLong, retryable, observable jobs with no user waiting on them
ResilienceMicrosoft.Extensions.Http.ResilienceFirst-party standard pipeline rather than hand-assembled Polly strategies
StreamingSignalRSources first, then verified claims — client-facing only
API docsScalar + built-in OpenAPIASP.NET Core generates the document itself; no Swashbuckle needed
ObservabilitySerilog + OpenTelemetryRetrieval recall, grounding rate and refusal rate as metrics, not log lines
TestsxUnit v3 + TestcontainersA real PostgreSQL with pgvector; mocking a vector index tests nothing
11

Trade-offs I am accepting

DecisionCost
Verification passTwo model calls per answer: slower and roughly double the cost
Refusing when unsupportedLower answer rate, and users who wanted a guess will find it unhelpful
pgvector over a vector databaseA ceiling on corpus size that a larger deployment would hit
Hybrid retrieval plus rerankThree stages to tune and measure instead of one
Claims instead of proseAnswers 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.