DocQuery is live at docquery.vondraysanford.com. If you go there, you won't see a generic "upload your PDFs" toy, you'll see Interview Vondray. A read-only corpus of my resume, projects, certifications, and 31 self-authored interview answers. Recruiters and hiring managers can ask questions and every answer comes back cited to something I actually wrote. The project became its own resume, which is either clever or extremely on-brand depending on how you feel about that kind of thing.
This post is the Phase 4 retrospective. I'll cover the architecture, the cost story, two genuinely weird bugs, and the lesson that stuck with me longest, which has nothing to do with code.
The Architecture Is Deliberately Boring
I have a static React UI on Cloudflare Pages (free tier), I only paid for the domain. I have a .NET API running in an Azure Container App on the Consumption plan with scale-to-zeroabout $0 at idle, which takes a few seconds of cold start when someone actually shows up. The public image is freely hosted on GitHub's Container Registry. I built the image for linux/amd64 from an Apple Silicon Mac using docker buildx, because the Container Apps runtime isn't ARM and you will find out the hard way if you forget that.
The total Azure spend across all four phases of this project was roughly a dollar of my $50 budget. The Consumption plan deserves more credit than it gets for hobby and demo workloads. You pay for what you use and nothing you don't, and "nothing" is most of the time.
Demo mode is one config flag. When it's on, uploads return 403 status, and the corpus is seeded at startup from my curated files baked into the image. The Dockerfile COPYs each corpus file by name so nothing rides into a public image by accident. Seeding is idempotent via SHA-256 content fingerprints so all I have to do is edit a document, redeploy, and only the changed files re-ingest. Everything else stays exactly where it was.
Cost Defense in Depth (Because a Public LLM Endpoint Is a BIG Bill Waiting to Happen)
The moment you point a public URL at an LLM, you've created a cost surface. I didn't want to spend my weekend watching Azure spend alerts fire, so I layered defenses instead of relying on any single one.
The stack contains of per-IP rate limiting in the API, a question length cap before the request even hits the model, an output token cap in the application code, a tokens-per-minute ceiling on the Azure OpenAI deployment above that, and budget alerts above that. Any single layer can fail and the blast radius will be pocket change which means no single failure is catastrophic.
The output token cap is where things got interesting. I set it to 1,000 tokens, which felt generous for a Q&A demo, but then answers started coming back completely empty.
Two Bugs Worth Documenting
The empty-answer bug is the most funny bug from this whole project. Reasoning models spend output tokens thinking before they write anything visible. That internal reasoning chain counts against your cap. My 1,000-token ceiling was being consumed entirely by invisible thought, with zero tokens left over for an actual response. The user saw a blank string, no error, no warning, just nothing.
The fix was raising the cap to 3,000 to give the model enough headroom to think and still produce an answer. I couldn't find this documented clearly anywhere, which is part of why I'm writing it out here. If you're seeing empty completions from a reasoning model and you have a token cap set, that's almost certainly what's happening.
The other bug was much uglier and more annoying. The Azure .NET OpenAI SDK (version 2.1.0) serializes the output token cap under the legacy name max_tokens. Reasoning models reject that field outright because they specifically want max_completion_tokens. The SDK has an opt-in fix for this, but when I tried to enable it, it threw an ArgumentNullException which meant the SDK's own workaround was broken.
I ended up writing a pipeline policy that intercepts the HTTP request on the way out and rewrites the JSON body, renaming max_tokens to max_completion_tokens before it ever reaches the API. It looks like this:
public class MaxCompletionTokensPolicy : HttpPipelinePolicy
{
public override async ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory<HttpPipelinePolicy> pipeline)
{
if (message.Request.Content != null)
{
using var ms = new MemoryStream();
await message.Request.Content.WriteToAsync(ms, CancellationToken.None);
var body = Encoding.UTF8.GetString(ms.ToArray());
if (body.Contains("\"max_tokens\""))
{
var patched = body.Replace("\"max_tokens\"", "\"max_completion_tokens\"");
message.Request.Content = RequestContent.Create(Encoding.UTF8.GetBytes(patched));
}
}
await ProcessNextAsync(message, pipeline);
}
public override void Process(HttpMessage message, ReadOnlyMemory<HttpPipelinePolicy> pipeline)
=> ProcessAsync(message, pipeline).AsTask().GetAwaiter().GetResult();
}
It's an ugly and honest solution, documented with a comment that says exactly what SDK version broke it and what to check before deleting it. The day a fixed SDK ships, this goes in the trash, but until then, it works.
Secrets are handled with Container Apps secret storage, referenced by environment variables with double-underscore names that map onto .NET's config path conventions for example, OpenAI__ApiKey becomes OpenAI:ApiKey at runtime. Nothing sensitive in git, nothing in the image (.dockerignore blocks appsettings.json from ever entering the build context), and I regenerated both keys after launch anyway because they'd passed through terminal scrollback during setup which was just a safe call.
Corpus Curation Is a Security and Honesty Decision
The thing I keep coming back to is that retrieval has no judgment. A RAG pipeline will serve up whatever matches the question so it doesn't know what's accurate, what's fair, or what you'd be embarrassed to have a recruiter read. What goes in is entirely your responsibility.
For my Interview Vondray demo, I distilled my performance reviews into direct quotes rather than dropping in the raw documents. In the process, I caught an error in one of them where a manager had credited me with a project I never touched. If I'd fed that document into the corpus as-is, my demo would have confidently told strangers I'd done work I didn't do. The model would have cited it, the chunk would have matched queries, and it would have looked like a fact.
Every document you feed a public RAG system is something it will eventually say out loud, which is something you HAVE to keep in mind, and its a topic that deserves you attention.
The demo is up, the costs are negligible, and I learned more shipping this than I did in the first three phases combined. If you want to ask it something, go ahead AND it'll cite its sources. :)