MityaNamed after Mitya, Dmitri Karamazov.Named after Mitya, Dmitri Karamazov.
Contents
1 · What it is
A question answering system over Dostoevsky's five novels. It answers in his voice, and it prints the chapters the answer came from.
The evaluation found at least 13 defects in its own instruments and 2 in the product, and every number now carries the configuration that produced it. A rewritten prompt cut jailbreak violations from 0.5625 to 0.1250 over 16 attempts (prompt extraction unmoved at 1 in 8) and, measured on a different model, raised judged groundedness (1 to 5 scale) by +0.724 against a detection floor of 0.343. Bounding admission took p99 from 64,405 ms with zero rejections to 4,429 ms with 13.
In the first version a fine tuned model supplied the voice and a retrieval stack supplied the substance, meeting in exactly one place, the prompt. The second retired the fine tune from the request path: a hosted model now takes the voice from a persona prompt, over the same retrieval stack. The Sources list under each answer is not generated text: it is built in code from retrieval metadata, a rule kept from the fine tune, which was trained to refuse external references.
The first version asked a hardware question: could a grounded, in character assistant run on one 6GB consumer GPU? It could. The second asks a delivery question: at a fixed quality bar, what is the cheapest and most reliable way to serve grounded answers, and can the system prove it stays that way? Answering that moved generation off the GPU and behind a two family provider gateway. The retrieval stack barely moved.
| Corpus | 5 novels, 231 chapters |
|---|---|
| Index | 3,284 points, 500 token chunks with 100 overlap, 768 dim nomic plus BM25 |
| Entity map | 678 people extracted, curated to 256 canonical keys and 441 aliases, zero same book collisions |
| Summaries | 231 of 231 chapters, no gaps |
| Retrieval | 50 per leg prefetch, RRF, 10 fused, FlashRank rerank, 3 served |
| Fine tune then | Mistral 7B Instruct v0.3, QLoRA r=32, 3 epochs, 6,979 synthetic pairs, served as GGUF q4_k_m |
| GPU fit, local arm then | 5.0 of 6.0 GB at 4096 context, full offload |
| Serving now | two hosted provider families, native APIs, local arm retired from the request path |
| Tenancy now | multi-tenant by design, shared token budget, synthetic traffic only |
2 · Where it came from
The first version shipped and worked: a QLoRA fine tune quantized to GGUF, hybrid dense and sparse retrieval, a reranker, an entity map, chapter summaries, FastAPI over SSE and a Vue front end, all on one 6GB card. The budget picked the quantization, a small CPU reranker, local mode Qdrant, a global lock of one and a 400 token ceiling on generation. Its own closing finding was that the remaining quality ceiling was the fine tune, not the pipeline.
What it could not do was answer questions about itself. It could say a request took about twenty seconds and could not say where the time went; the reranker's share was an estimate written into a design document and never measured. It could not say which configuration produced a given number, because settings were module constants and environment variables and provenance was whatever the operator remembered. And its headline retrieval number turned out to have been produced by a harness that asserted a route, logged that the route was fine, and then ran the default path for every row anyway.
The second version started by rebuilding that. Once generation moved off the box, three defaults inherited from the 6GB era stopped making sense. The global lock of one had been justified by running a single local model, but with generation gone it was binding on retrieval alone, and it was measurably the wall. The 400 token generation ceiling had been sized to fit a local context window, and carried into hosted serving it cut most answers off mid sentence. And "the GPU is already paid for" stopped being a cost argument at all, because the GPU was no longer in the request path. None of these were wrong when they were written; they were correct answers to a constraint that no longer applied.
The corpus parse, the chunking, the index, the summaries, the router, hybrid retrieval and the reranker all stayed, which kept quota and the vector store lock for the new work. What changed was the entity map, context assembly, prompt composition, generation, the cache and the API surface; the fine tune was retired from the request path rather than retrained. What is new is everything that lets the system make claims about itself: a measurement contract, telemetry with config identity, a provider gateway, a promotion gate, a traffic harness, auth and tenancy, conversation memory, adversarial evaluation sets, and an optimizer.
3 · How it works
A question arrives over SSE. The router decides whether it names a chapter, which picks a structural path or the default one. Retrieval runs both legs in a single Qdrant round trip: a dense leg over quantized ONNX embeddings on CPU, and a BM25 leg, submitted as prefetch clauses and fused server side by native RRF. FlashRank cuts the fused pool to the three spans that are served. Those spans, the conversation memory and the persona prompt compete for one token budget, and passages win by policy, admitted whole. Generation happens at a hosted provider behind a two family gateway. When the stream ends, the Sources list is assembled in code from retrieval metadata, never parsed out of the answer.
Two things the diagram makes visible. The lock covers everything from routing through context assembly and is released before the first token streams. And which configuration produced any of this is a content hash, so an arm (one configuration under test) is switched by promoting an artifact, never by setting an environment variable.
4a · Retrieval
The published recall number came from a harness running the wrong path, and recall varied between runs of one commit. Both fixed: recall@5 is 0.7105 on 38 rows. Retrieval still never sees the conversation.
Five novels are parsed into 231 chapters, chunked at 500 tokens with 100 overlap, and indexed as 3,284 points that carry book, part and chapter as payload rather than as text. A question is routed by a pure function with no model in it: name a resolvable chapter and ask for a summary and it takes a structural path, otherwise the default one. That path expands the query with canonical names and aliases from a human gated entity map, runs a dense leg and a BM25 leg in a single Qdrant round trip fused server side by RRF, then reranks down to the three spans that are served.
Two things went wrong, and both were in the measurement rather than in the stack.
The published recall number was the recall of a pipeline production does not run. The harness called the router, asserted the route, logged that the route was fine, then ran the default hybrid path for every row regardless. The structural retrieval function was imported by production, imported by the quality harness, and imported by nothing in the retrieval harness. The assertion passed the entire time.
Then recall turned out to be bimodal across repeated runs of one unchanged commit. The entity map's scan returned a set, so any query touching more than one character got its alias blocks back in a different order depending on the process hash seed. Different order, different expanded query, different embedding, different ranking. That is not only a measurement problem: two users could ask the same question and get different chunks.
Both fixed, and the baseline re-derived: recall@5 of 0.7105, 27 of 38 rows on the primary family.[1][1]0.7105 = 27/38, denominator sequence 26 / 25 / 38; MRR 0.5039.[1]0.7105 = 27/38, denominator sequence 26 / 25 / 38; MRR 0.5039. v1 had published 0.720, and the new number is not a regression from it, because the denominator moved three times in between.[2][2]v1 0.720 = 18/25, superseded.[2]v1 0.720 = 18/25, superseded. The two were never measuring the same set. Knowing which configuration produced which number is a separate problem, and the system did not have an answer to it yet.
One large defect was found and deliberately left alone. Retrieval never sees the conversation. In the session corpus, 68 of 108 turns are contentless follow-ups, so 63 percent of multi turn requests retrieve against a string with nothing in it to retrieve on.[3][3]63% = 68 of 108 turns.[3]63% = 68 of 108 turns. Documented, unrepaired, and the biggest known gap in the system.
4b · Generation
The original injection fence did nothing. Its replacement cut jailbreak violations and raised groundedness, after nearly losing to a metric that scored correct refusals as breaking character.
The prompt is assembled in blocks: a system message, the persona head, a precedence marker that names each block and declares it data, the memory block, the conversation, and a reinforcement tail. The user turn carries the question and nothing else. Generation runs on hosted APIs behind a gateway speaking to two provider families, picked for how much they diverge in streaming shape, error vocabulary and rate limit surface, and each reached through its own native API rather than a compatibility endpoint. The first token is pulled before the response body opens, so a transport failure, a refusal or a size rejection arrives as a typed status instead of a hung 200.
The first adversarial evaluation this project ever ran found that the prompt's injection fence was doing nothing. Against a control differing in exactly one respect, the shipped prompt was set-identical: the same attempts landed, with an empty symmetric difference in both directions. Its replacement, composition C, then nearly lost to a broken metric. An assistant-tell detector scored C four times worse for breaking character, and all 26 of 26 matches turned out to be the bare literal "I cannot" inside in-character narration, which is the persona correctly refusing. The metric was punishing the arm that obeys its own grounding instruction, and published unread it would have said the safer prompt breaks character four times as often.
Composition C shipped, and it is the only change the entire optimizer phase promoted. It cut jailbreak violations from 0.5625 to 0.1250 across 16 attempts, a 78 percent reduction in that family,[4][4]0.5625 → 0.1250 over 16 attempts, jailbreak family only.[4]0.5625 → 0.1250 over 16 attempts, jailbreak family only. and on the quality leg it came back superior on groundedness rather than merely non-inferior, +0.724 against a detection floor of 0.343.[5][5]+0.724 against a measured MDE (minimum detectable effect) of 0.343 at n = 58, judge scale 1 to 5: superior, not merely non-inferior.[5]+0.724 against a measured MDE (minimum detectable effect) of 0.343 at n = 58, judge scale 1 to 5: superior, not merely non-inferior. Prompt extraction did not move, holding at 1 in 8 on every arm tested, which is where the result stops.[6][6]1/8 on all three arms: control, shipped, promoted.[6]1/8 on all three arms: control, shipped, promoted. The two legs ran on different models, so they are two results rather than one.
The output ceiling from section 2 was raised from 400 to 1024, and the change was recorded as a measurement break enforced in code: ask the gate to compare a number from before it against one from after and it returns an error naming the break instead of a delta.
4c · Serving and degradation
The first load test never rejected a request and queued silently. At the same load, bounded admission took p99 from 64,405 ms with zero rejections to 4,429 ms with 13.
/chat takes a session cookie, resolves it server side on every request, derives the tenant, and streams SSE events: tokens, then sources, then meta. Sessions are opaque server side rows rather than JWTs, so a session can be revoked; the cookie is HttpOnly, Secure and SameSite Lax, and it rotates on login. The inbound tenant header was removed rather than supplemented, because an authenticated tenant that an unauthenticated header can override is not authenticated. In front of that sits pure ASGI admission middleware holding one in-flight slot per request, bounded per tenant and in total, releasing on all six exits including client disconnect.
The admission layer exists because of what the first load test found. The system had never rejected anything: at every concurrency level, on both arms, zero local 429s, zero provider 502s, not one erroring request, and a 62 second p99 at a 100 percent success rate. It was queueing silently and without bound: every request succeeded, and the only symptom was a tail nobody was watching. Bounding it at two per tenant and six in total, with the inequality chosen so the total binds before every tenant reaches its own cap, took p99 on the same arm at the same level from 64,405 ms with zero rejections to 4,429 ms with thirteen.[7][7]p99 64,405 ms / 0 rejections to 4,429 ms / 13.[7]p99 64,405 ms / 0 rejections to 4,429 ms / 13.
The five rung degradation ladder was written but had never been wired into the request path, and the readiness probe it depended on was querying llama-server, which no longer generates anything. Both were fixed together. Readiness is now deliberately config-only, configured and credentialed rather than reachable, because the front end polls it every few seconds against a free tier that allows very few requests per minute.
The concurrency knee moved from one to four after the lock was narrowed to cover routing through context assembly,[8][8]knee 1 -> 4 (first measurement, v1 published no concurrency number).[8]knee 1 -> 4 (first measurement, v1 published no concurrency number). and authentication went in at no measurable latency cost.[9][9]auth 2,045 ms p50 against 2,194 ms pre-auth.[9]auth 2,045 ms p50 against 2,194 ms pre-auth. What the admission bound buys is a bounded wait, not more throughput: retrieval is still mutually exclusive, so the lock was narrowed rather than parallelized.
4d · Telemetry and evaluation
Every number carries the configuration that produced it. Stripping every retrieved passage left both mechanical metrics at zero change; only the judge saw it.
One span per stage boundary feeds both the trace and the metric from a single set of instrumentation calls, so a timing and its distribution cannot disagree. A configuration version is the content hash of a frozen validated model, twelve hex characters of a canonical serialization, and every span, every metric and every evaluation row carries it. Two identical configurations are the same version by construction. Runs are stamped with the config version, the corpus version, the git SHA, whether the tree was dirty, the serving model and the prompt composition. On top of that sits a promotion gate that compares the promoted configuration against a candidate in three layers, with no hand set threshold anywhere: every bar is derived from the run being judged.
That design came out of finding what happens without it. A configuration version that did not pin the generation model meant the version did not churn between two provider arms, so arm B was served arm A's cached answers, contaminating quality, then latency, then cost, in that order. Setting an environment variable to select an arm turned out not to switch arms at all: both runs carried the same config version, labelled nothing, and produced scores plausible enough to have been believed. The rule that came out of it is short. To measure an arm, promote a configuration for it.
Then the gate was asked what it could actually see. Strip every retrieved passage from the prompt, which is a total and certain regression, and the two mechanical metrics move by 0.0000. Identical zeros. The LLM judge catches it at 5.4 times its own detection floor,[10][10]mechanical 0.0000 / 0.0000 vs judge groundedness 4.022 -> 1.000 at 5.4x MDE.[10]mechanical 0.0000 / 0.0000 vs judge groundedness 4.022 -> 1.000 at 5.4x MDE. but the mechanical layer does not respond at all, and no sample size fixes a metric that does not respond. Worse, the ungrounded configuration is 32 percent cheaper and passes the gate clean,[11][11]ungrounded arm 32% cheaper.[11]ungrounded arm 32% cheaper. so anything optimizing against cost under that gate is being steered toward it. The gate's report now carries a permanent caption excluding grounding from what it verifies, enforced by the report validator rather than by convention, and the schema rejects any overall, score or composite key outright: a single score would hide a weighting nobody has chosen.
Across the phase, thirteen or more defects were found in the instruments, and two in the product.[12][12]instrument defects 13+ vs product defects 2.[12]instrument defects 13+ vs product defects 2. Not one of the thirteen was caught by a run that failed. They were caught by runs that passed and looked fine.
4e · Cost and performance
The reranker was most of retrieval latency, not the fifth v1 assumed. Cutting the fused pool from 25 to 10 took retrieval from 2,121 ms to 917 ms with recall flat. Routing between arms was rejected on cost before it was built.
Three cost numbers exist because they answer different questions. A reconstructed estimate computable from a configuration and a manifest alone, which the promotion gate blocks on. An assembled estimate that requires retrieval and the memory read to have actually run, which the optimizer ranks on. And a realised figure from provider reported usage. Free tier calls are priced at published list rates and flagged unmetered, so any aggregate containing one renders as an estimate rather than a bill.
v1 had never measured where the time went. Its design document justified the reranker at roughly a fifth of the retrieval path. One trace put it at the overwhelming majority of it. Retrieval proper is flat at about 256 ms regardless of depth, so every millisecond above that is reranker work. Cutting the fused pool from 25 to 10 took retrieval wall clock from 2,121 ms to 917 ms, a 2.3 times reduction, and recall@5 did not move at any depth tested between 25 and 5.[13][13]fused 25 -> 10, 2,121 ms -> 917 ms, recall@5 flat at 0.7105.[13]fused 25 -> 10, 2,121 ms -> 917 ms, recall@5 flat at 0.7105. It is a latency axis, not a cost axis: the same change is worth about 0.04 percent of the money. Generation stopped being the bottleneck, and retrieval became it.
Context assembly gained the ability to merge adjacent retrieved spans and drop the text they duplicate. Over 97 retrieval rows it cut mean served span tokens from 1,535 to 1,502, with recall, coverage and multi hit rate identical, 28 rows cheaper and none more expensive.[14][14]merge-only 1,535 -> 1,502 tokens, -2.19% over 97 rows.[14]merge-only 1,535 -> 1,502 tokens, -2.19% over 97 rows. It ships as the default collision policy, and enabling it in the request path is a pointer write that is planned rather than done.
The cost model is exact and the spend is nothing. A production answer costs $0.003982 at list rate, the cheapest measured arm $0.001325, and actual spend across the phase was $0.00, because every rate row is unmetered.[15][15]$0.003982 production / $0.001325 cheapest / $0.00 actual.[15]$0.003982 production / $0.001325 cheapest / $0.00 actual. Routing between arms was rejected before it was built: the best routed family came out 74 percent more expensive than always sending traffic to the cheapest arm, and the hierarchy is inverted, since the cheaper arm is the larger model. That result is bounded by what a free tier could measure.
5 · Limitations
v1 measured its answer quality as capped by the fine tune, not by the pipeline. v2 retired the fine tune from the request path rather than retraining it, since weight training was out of scope, so no model in the current request path was trained for this project.
Retrieval blindness ships unrepaired. Feeding the transcript into retrieval has a live objection: if the model answers from the conversation while the Sources list cites what the current query retrieved, the citation lies. The repair needs the Sources contract to change first.
The composed quality gate ships empty and cannot block. After measurement it is not an answer quality gate at all: it is a catastrophe detector with an exact cost model attached.
Chunking, 500 tokens with 100 overlap, has never been measured. Three arms is about an hour of local CPU, but a winning arm re-freezes the corpus and invalidates the phase's evidence base, so it is priced rather than done.
The degradation ladder descends on misconfiguration, not on outage, because readiness is config only. On a real provider outage it does not descend at all.
The largest caveat: there is no real traffic. Every harness and session figure is synthetic, and the synthetic query distribution decides in advance whether a retrieval component can show value at all.
6 · What is next
v3 is in research, and nothing in it is measured yet. It asks which requests deserve which structure: a contentless follow up, a question about the persona and a question spanning two novels may not need the same retrieval, model or checks, yet today the router only tells a chapter request apart from everything else. The idea is to route each request to the cheapest path that meets its quality bar, check the output mechanically, and prove the cost on real bills against a frontier model on the same rows.
The first measurement decides whether any of it gets built. Every candidate path runs on every labelled row, and the best path per row is compared against the best single path, an upper bound on what any router could capture. If that bound is small, routing is not built and the negative is the published result.
Two things come first. Every paid call is metered and budgeted before it is sent. Then retrieval is re-baselined once (chunking, the embedding model, fusion, an arm with no reranker, the vector store mode, all picked for a 6GB card that no longer serves anything) on about 150 primary rows, which roughly halves the detection floor.[16][16]Recall MDE 0.144 at n = 38, about 0.072 at about 150 primary rows, computed at an assumed 10 percent discordance: it scales with the square root of n.[16]Recall MDE 0.144 at n = 38, about 0.072 at about 150 primary rows, computed at an assumed 10 percent discordance: it scales with the square root of n.
Several limitations above have a place in that plan. Chunking gets its first number. A breaker per provider gives the ladder a real outage to descend on. Citations checked against the spans actually served give the gate a mechanical view of grounding, which is the Sources change retrieval blindness has been waiting on. Two stay: nothing in v3 trains a model, and real traffic is still unlikely, because a beta costs money every day it runs.