Vouch - automating security questionnaires without hallucinated answers
A pure .NET RAG demonstrator that refuses to output an answer without a cited source.
Related projectVouch - security questionnaire automation with cited RAG answersThe problem that made me build Vouch
You sell a B2B SaaS. Every deal above €50k arrives with a security questionnaire — SOC 2, ISO 27001, CAIQ v4, SIG, or the prospect's latest in-house variant. 200 rows, sometimes 400. It's a well-known B2B pre-sales pain: filling one of these typically takes several hours (often 4 to 12) of manual copy-paste from internal policies, with a real risk of factual error when the latest version of a policy was forgotten.
The question that always comes back: why is a human re-writing 80% of the same answers, by hand, with a higher error risk than a well-wired LLM?
Vouch is my answer to that question — a personal demonstrator, not a product with clients. The one-line pitch: upload your policies once, the engine fills any questionnaire while citing every source. And crucially, with no Python sidecar.
In short
A 100% .NET RAG engine (MEAI + pgvector + EF Core, zero Python) that answers
security questionnaires citing every source — with an invariant coded into
the domain: high confidence with no citation = rejected. What it shows:
strict DDD (the domain compiles with no dependency), native .NET GenAI in
production, and a demo that runs offline in 30 s (dotnet run -- demo), no
API key. 5 projects, 93 green tests.
Decision #1: 100% .NET, or nothing
The 2024–2026 GenAI ecosystem has a Python bias. Embeddings?
sentence-transformers. RAG? LangChain. Orchestration? LlamaIndex.
Everything is in Python. And so everyone builds their SaaS in .NET with a Python
sidecar alongside.
I refused, for three reasons:
- Operating cost. Two runtimes, two CI pipelines, two dependency registries, two on-call processes. For a company that doesn't yet have 10 paying customers, that's pure debt.
- Positioning. My CV is .NET. If I ship a .NET product with a Python sidecar doing all the interesting work, what exactly am I selling?
- Actual maturity.
Microsoft.Extensions.AI(MEAI) went official in late 2024. The API is clean, multi-provider, and Anthropic + OpenAI implementIChatClientdirectly. pgvector has an official .NET binding. ML.NET is available for classification (in Vouch it's a future swap, not active yet — see below). Functional parity is there — it just needed someone to demonstrate it publicly.
Vouch is that demonstrator. Not a line of Python. Everything goes through MEAI or the official C# SDKs.
Architecture: strict DDD, layers that compile in isolation
I've seen too many "Clean Architecture" projects where the domain imports
Microsoft.EntityFrameworkCore because "it's handy for the attributes." In
Vouch, the rule is non-negotiable: Vouch.Domain compiles with no external
dependency.
$ dotnet build src/Vouch.Domain
Vouch.Domain -> Vouch.Domain.dll
Build succeeded.
Zero NuGet, zero using Microsoft.*, zero leaking infrastructure abstraction.
The aggregates are pure C#:
public sealed class Answer : Entity<AnswerId>
{
public static Result<Answer> CreateDraft(
string text,
ConfidenceScore confidence,
string modelIdentifier,
IReadOnlyList<AnswerCitation> citations,
int revisionNumber = 1)
{
if (string.IsNullOrWhiteSpace(text))
return Error.Validation("answers.text.required", "...");
// The invariant that pays the entry ticket
if (confidence.Band == ConfidenceBand.High && citations.Count == 0)
return DomainErrors.Answers.NoCitationsForHighConfidence;
return new Answer(/* ... */);
}
}
Above it: Vouch.Application has only FluentValidation +
Microsoft.Extensions.Logging.Abstractions + the options. Vouch.Infrastructure
carries EF Core, pgvector, MEAI, the parsers. Vouch.Web and Vouch.Cli are
hosts that compose the DI root. Nobody skips a layer.
Concrete result: when I want to replace pgvector with Qdrant, I touch a single
file (EfVectorSearch.cs) and its tests. When I want to switch from OpenAI to
Anthropic, I touch DependencyInjection.RegisterLlmAdapters and that's it.
The invariant that changes everything: cited-or-nothing
The number-one risk of a product like Vouch is hallucination. An LLM inventing a SOC 2 answer is worse than filling nothing: pre-sales trusts it, sends it, and the prospect's compliance officer spots the inconsistency in 15 seconds. Dead deal.
Rather than putting this in a doc or a UI-side guard, I coded it into the aggregate:
if (confidence.Band == ConfidenceBand.High && citations.Count == 0)
return DomainErrors.Answers.NoCitationsForHighConfidence;
Impossible to build a high-confidence answer without a citation. Not a runtime
check in a controller — a domain invariant. Every code path that produces an
Answer goes through Answer.CreateDraft. No bypass possible.
On the generator side, the system prompt contains:
If no excerpt supports the question, output "INSUFFICIENT_EVIDENCE"
— do not improvise.
When the LLM answers INSUFFICIENT_EVIDENCE, the generator creates an Answer
at confidence 0.05 with no citation. The human reviewer sees the flagged row and
decides.
That's the kind of discipline a product manager can promise a prospect: "look at the code, it's impossible for you to receive a 99%-cited answer with no source attached."
The full pipeline
And the port-by-port detail, step by step:
fixtures/policies/*.{pdf,docx,md,html}
│
▼ PdfPig / DocumentFormat.OpenXml / regex split
segments
│
▼ TokenAwareChunker (500 tokens × 50 overlap)
chunks
│
▼ OpenAI embeddings (or DeterministicEmbeddingService offline)
vectors
│
▼ pgvector (HNSW, vector_cosine_ops, m=16, ef_construction=64)
indexed
fixtures/questionnaire.xlsx
│
▼ XlsxQuestionnaireImporter (CAIQ/SIG heuristic)
questions
│
▼ HeuristicQuestionClassifier (type + framework)
classified
│
▼ Per question:
embed → vector search top-K
→ MEAI ChatClient (JSON mode)
→ ResilientChatClient (Polly retry + timeout)
→ Answer.CreateDraft (cited-or-nothing invariant)
→ Question.AttachDraft
→ AuditEvent.AnswerGenerated
Linear. Readable. Each arrow is an I* port on the Application side with an
implementation on the Infrastructure side. All ports are mocked in the handler
tests; the real adapters are tested in infra unit tests.
Multi-tenant: automatic query filter
A trivial thing missed in 1 out of 2 B2B SaaS: forgetting the per-tenant filter on a query, and the audit log becomes a data-leak story.
Vouch has a single mechanism:
modelBuilder.Entity<Policy>().HasQueryFilter(
p => !_tenantContext.IsSet || p.TenantId == _tenantContext.CurrentTenantId);
The !_tenantContext.IsSet is the bypass — used only for tenant creation
(chicken-and-egg) and migrations. Every other code path runs with a tenant bound
to the DI scope.
And crucially: this filter works equally well with Npgsql and the InMemory
provider. Why? Because I avoided the Expression.Invoke trick that composes two
lambdas — InMemory can't translate it. An inline expression is more explicit and
stays compatible everywhere.
The offline fallback that changes the demo
The biggest brake on a GenAI project in your portfolio is: "yeah cool, but how do I run it without a $200/month OpenAI key?"
Vouch has two pragmatic fallbacks that trigger from config:
- No Postgres connection string → EF Core In-Memory +
InMemoryVectorSearchjoined to the DbContext for the excerpts. - No OpenAI/Anthropic API key →
DeterministicEmbeddingService(SHA-256 of trigrams, L2-normalized) +DeterministicAnswerGenerator(stitches the top-3 excerpts).
The quality is deliberately low, but the full pipeline runs. A recruiter who clones the repo and runs:
dotnet run --project src/Vouch.Cli -- demo
gets a filled XLSX in 30 seconds, having configured nothing. That zero-friction changes everything. Never again "I'd have to install Postgres and go find a key to see the project run."
What it taught me about native .NET GenAI in 2026
MEAI is production-ready. IChatClient + IEmbeddingGenerator + the
DelegatingChatClient for decoration (my ResilientChatClient is 60 lines
including Polly). Every official SDK (OpenAI, Anthropic, Ollama, Azure OpenAI)
implements it. Switching provider is a DI change.
pgvector + EF Core is underused. The Pgvector.EntityFrameworkCore binding
lets you map a Vector directly onto a vector(1536) column, and do
e.Embedding.CosineDistance(queryVec) in LINQ — which translates to
embedding <=> query on the Postgres side. The top-K query + scoring + metadata
join fits in 20 lines of C#.
ML.NET stays relevant for classification. My current
HeuristicQuestionClassifier is a baseline (heuristics + keyword matches), but
the IQuestionClassifier contract lets you swap in a trained ML.NET model as
soon as you have a labelled corpus. No PyTorch needed.
Polly v8 + DelegatingChatClient = retry/timeout in 60 lines. No Hystrix, no service mesh, just a decorator. Every LLM call is protected.
What I'll do with it
Vouch is my enterprise anchor on the portfolio. It's the project I bring to an interview when the recruiter asks "what's a production-grade B2B SaaS you built yourself?". 5 projects in the solution, 93 green tests, a demo that runs offline, ARCHITECTURE/ROADMAP/CONTRIBUTING docs that show I think as a team.
The short-term roadmap: Hangfire for async ingest, clickable citations to the
source PDF, JWT-based auth with a tenant_id claim. The long-term roadmap: a
consensus generator (OpenAI + Anthropic in parallel, vote-weighted confidence)
and active learning from human overrides.
But above all, Vouch is the public demonstration of a simple message: you don't need Python to do serious GenAI in 2026. MEAI + pgvector + EF Core are enough. The code is on the repo. The demo runs in 30 seconds. The rest is rhetoric.
More on the architecture and stack on the Vouch project page.
Next step
Sounds like something you need shipped? Let's talk.
I take on critical technical work — from scoping to production, no debt or lock-in once it's handed over. Fastest way to see if it fits: a 30-minute call.
I reply within 24h — often sooner.