<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: elvisyao007</title>
    <description>The latest articles on DEV Community by elvisyao007 (@elvisyao007).</description>
    <link>https://dev.to/elvisyao007</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3964875%2Fbeb8b912-fc43-4b63-ac46-6a08efe481d9.jpg</url>
      <title>DEV Community: elvisyao007</title>
      <link>https://dev.to/elvisyao007</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/elvisyao007"/>
    <language>en</language>
    <item>
      <title>A Chinese 8B model beat the Western 8B models at Japanese RAG. I still wouldn't put it in the default deployment — and that distinction is the point.</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Sun, 14 Jun 2026 06:39:00 +0000</pubDate>
      <link>https://dev.to/elvisyao007/a-chinese-8b-model-beat-the-western-8b-models-at-japanese-rag-i-still-wouldnt-put-it-in-the-4394</link>
      <guid>https://dev.to/elvisyao007/a-chinese-8b-model-beat-the-western-8b-models-at-japanese-rag-i-still-wouldnt-put-it-in-the-4394</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Extends an earlier model-selection benchmark to three model families (Japanese / Western / Chinese) on a Japanese RAG task.&lt;br&gt;
Repo + raw results: &lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/model-selection-v2" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/model-selection-v2&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An earlier post benchmarked local models for a Japanese RAG task and settled on selecting by constraint rather than raw capability. This post widens the field to three families — Japanese-tuned, Western open, and Chinese — and the result forces a distinction that matters more than any single score: &lt;strong&gt;model capability and deployment eligibility are two different questions, and conflating them is how people get model selection wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same Japanese RAG task, same judge protocol, same discriminating golden set (oracle 87.5%, only 11% of questions answered by all models — it actually separates the field). hit@5, 8B class unless noted:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;hit@5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Swallow-8B&lt;/td&gt;
&lt;td&gt;Japanese-tuned&lt;/td&gt;
&lt;td&gt;~0.53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nemotron-9B-JP&lt;/td&gt;
&lt;td&gt;Japanese-tuned&lt;/td&gt;
&lt;td&gt;~0.62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ELYZA-JP-8B&lt;/td&gt;
&lt;td&gt;Japanese-tuned&lt;/td&gt;
&lt;td&gt;~0.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;deepseek-r1-8b&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Chinese&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~0.51&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama-3.1-8B&lt;/td&gt;
&lt;td&gt;Western&lt;/td&gt;
&lt;td&gt;~0.22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral-7B&lt;/td&gt;
&lt;td&gt;Western&lt;/td&gt;
&lt;td&gt;~0.18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma4-31b&lt;/td&gt;
&lt;td&gt;Western (31B)&lt;/td&gt;
&lt;td&gt;~0.62&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh56yh1oju4jwr71ehs13.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh56yh1oju4jwr71ehs13.png" alt=" " width="799" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things fall out of this, and they don't all point the same direction.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. At 8B, Japanese fine-tuning is decisive — and generic Western models just aren't competitive
&lt;/h2&gt;

&lt;p&gt;The Western 8B models cratered: Llama-3.1-8B at 0.22, Mistral-7B at 0.18, against a Japanese-tuned average around 0.52. That's not a small gap; it's the difference between usable and not.&lt;/p&gt;

&lt;p&gt;This answers a question people sometimes ask skeptically — &lt;em&gt;why do Japanese-specific models exist when Llama is right there?&lt;/em&gt; At the 8B scale, on a Japanese retrieval-grounded task, a generic Western model without Japanese fine-tuning is not in the running. The Japanese tuning is doing decisive work.&lt;/p&gt;

&lt;p&gt;One honest qualifier on the table: &lt;strong&gt;gemma4-31b (0.62) is the one Western model that holds up — but it's 31B, not 8B.&lt;/strong&gt; It earns its score with 4× the parameters, not with Japanese optimization. So read the table in two tiers: within the 8B class, Japanese-tuned wins clearly; across sizes, you can buy Western competitiveness with a much bigger model. Don't read "gemma is strong" as "Western 8B is fine" — the 8B Western models specifically failed.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Chinese model was capable — genuinely competitive
&lt;/h2&gt;

&lt;p&gt;deepseek-r1-8b scored 0.51 — above the Western 8B models by a wide margin, and right in the range of the Japanese-tuned models. On capability alone, measured on this task, it's a real contender.&lt;/p&gt;

&lt;p&gt;I want to be precise here because it's easy to be sloppy: the data says this model is good at the task. That's a measurement, and I'm reporting it straight.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. ...and I still wouldn't put it in the default deployment stack — for reasons that have nothing to do with capability
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft5o1e9w7vudnw214p22f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft5o1e9w7vudnw214p22f.png" alt=" " width="799" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For Japanese enterprise deployment, my default model lineup excludes Chinese models. Not because of the score — the score is fine — but because of &lt;strong&gt;deployment-policy constraints that are independent of capability&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data sovereignty posture.&lt;/strong&gt; Japanese enterprises, particularly in regulated or security-sensitive contexts, have specific concerns about model provenance in on-prem and data-handling decisions. A solutions engineer deploying into that environment inherits those constraints whether or not they're technically about the model's quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Procurement and compliance review.&lt;/strong&gt; Model provenance is a line item in enterprise procurement and security review. A model that's excellent but doesn't clear that review is, operationally, not deployable for that client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the model goes in my &lt;strong&gt;content/research layer&lt;/strong&gt; — where I'll benchmark it, learn from it, report its numbers honestly (as I just did) — but not in the &lt;strong&gt;deployment default&lt;/strong&gt; I'd recommend to a Japanese enterprise client. That separation is a standing decision in how I structure this work, and this benchmark is exactly why the separation has to be explicit: &lt;em&gt;if you collapse capability and deployability into one axis, you'll either deploy something that fails procurement, or dismiss something that's actually good.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is, I think, the part of the job that separates a solutions/forward-deployed engineer from someone who only runs benchmarks. The benchmark tells you what's capable. The deployment decision is a different function — it takes in the score &lt;strong&gt;and&lt;/strong&gt; the client's compliance reality, the procurement constraints, the data-handling posture — and those are not the model's fault or merit, they're the deployment context. Keeping the two reasoning steps separate is the skill.&lt;/p&gt;




&lt;h2&gt;
  
  
  The caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;n = 45 questions.&lt;/strong&gt; Scores carry roughly ±5–8% uncertainty. The &lt;em&gt;direction&lt;/em&gt; (Western 8B weak, Japanese-tuned and the Chinese model strong) is clear; treat exact values as approximate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;32GB single-GPU constraint.&lt;/strong&gt; I did not evaluate 70B-class models (Llama-70B, Mistral-Large) — they don't fit. So "Western 8B is weak here" is a statement about the 8B class on one GPU, not about Western models in general. A 70B might change the picture; I can't test it on this hardware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge independence.&lt;/strong&gt; The judge is a non-contestant model; cross-validation on a 25-question subset gave 96% hit agreement, κ = 0.920 — real agreement over real variance, not a zero-variance artifact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One task, one embedder.&lt;/strong&gt; Japanese RAG with a Japanese embedder. Different task, different story possible.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Selecting a model for deployment is not "pick the highest score." It's a two-step function: measure capability honestly, then filter by the deployment context — size constraints, latency, language fit, and procurement/compliance reality. The Chinese model passed step one and is filtered at step two for reasons that aren't about its quality. The Western 8B models failed step one outright. The Japanese-tuned models pass both for this client profile.&lt;/p&gt;

&lt;p&gt;Reporting all of that accurately — including saying clearly that the model I won't deploy is genuinely good — is the job.&lt;/p&gt;

&lt;p&gt;Raw numbers, judge protocol, the discriminating golden set:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/model-selection-v2" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/model-selection-v2&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Companion: &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;eval-sanity&lt;/a&gt;&lt;/strong&gt; (the sanity gate confirming the metric discriminates before any score is trusted).&lt;/p&gt;

</description>
      <category>llm</category>
      <category>rag</category>
      <category>machinelearning</category>
      <category>japan</category>
    </item>
    <item>
      <title>Which Chinese open-source parser is better for Japanese RAG? It's a crossover — BM25 says DeepDoc, dense says MinerU</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Sat, 13 Jun 2026 14:29:01 +0000</pubDate>
      <link>https://dev.to/elvisyao007/which-chinese-open-source-parser-is-better-for-japanese-rag-its-a-crossover-bm25-says-deepdoc-25mi</link>
      <guid>https://dev.to/elvisyao007/which-chinese-open-source-parser-is-better-for-japanese-rag-its-a-crossover-bm25-says-deepdoc-25mi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Final part of a series measuring Chinese open-source document parsing on Japanese documents.&lt;br&gt;
Repo + raw 3×2 results: &lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v2" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v2&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two posts ago I measured RAGFlow's DeepDoc against plain text extraction on Japanese PDFs and found its layout-aware parsing helped retrieval. Last post I found that help was bigger under dense retrieval than BM25 — structured parsing matters more the more your retriever depends on chunk coherence.&lt;/p&gt;

&lt;p&gt;This post adds the obvious next question: &lt;strong&gt;is DeepDoc actually the best Chinese open-source parser for this, or just the one I tested first?&lt;/strong&gt; So I added MinerU (another major Chinese parser) to the comparison. (I excluded PaddleOCR — it depends on Baidu's PaddlePaddle framework rather than PyTorch, with known CUDA/cudnn conflicts; the environment isolation cost wasn't worth it for this comparison.)&lt;/p&gt;

&lt;p&gt;The answer isn't "one of them wins." It's a clean crossover: &lt;strong&gt;the better parser depends on your retriever.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3×2
&lt;/h2&gt;

&lt;p&gt;Same 3 Japanese government PDFs, same 32-question golden set (oracle ceiling 87.5%), same Japanese embedder (ruri-v3). hit@5:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline&lt;/th&gt;
&lt;th&gt;BM25&lt;/th&gt;
&lt;th&gt;Dense&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plain text (pdfplumber)&lt;/td&gt;
&lt;td&gt;56.2%&lt;/td&gt;
&lt;td&gt;40.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepDoc&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;65.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MinerU&lt;/td&gt;
&lt;td&gt;62.5%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;71.9%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpxzn5jym0xpye0jf7gb6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpxzn5jym0xpye0jf7gb6.png" alt=" " width="799" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the two structured parsers against each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On BM25, DeepDoc wins&lt;/strong&gt; (68.8% vs 62.5%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On dense, MinerU wins&lt;/strong&gt; (71.9% vs 65.6%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A crossover. And the single best cell in the entire table is &lt;strong&gt;MinerU × dense at 71.9%&lt;/strong&gt; — so if you're building Japanese dense RAG, MinerU is the current pick. If you're on a lexical/BM25 system, DeepDoc.&lt;/p&gt;

&lt;p&gt;This is the answer to "how do you choose a parser" that's actually useful: not "the strong one," but "match the parser to your retriever." MinerU's chunking suits dense embedding; DeepDoc's suits keyword matching. Neither is universally better.&lt;/p&gt;




&lt;h2&gt;
  
  
  A confirmation worth noting: the era-name failure is ecosystem-wide, not DeepDoc-specific
&lt;/h2&gt;

&lt;p&gt;In the first post, DeepDoc's OCR fallback corrupted Japanese era names (令→今) on form/scanned PDFs, while its native-text path was clean. MinerU showed &lt;strong&gt;zero&lt;/strong&gt; era-name errors on these documents — because, like DeepDoc, it routes text-layer PDFs through direct extraction (PyMuPDF) rather than OCR.&lt;/p&gt;

&lt;p&gt;That's the useful confirmation: the clean-vs-corrupt split is a property of the &lt;strong&gt;font path&lt;/strong&gt; (text-layer extraction vs OCR fallback), not of any one parser. Both Chinese parsers handle embedded-font government PDFs cleanly. The era-name risk lives in the OCR fallback that scanned/form documents force — and that's where you'd need to test either tool carefully for a Japanese deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The caveats — three, and they're the price of trusting the numbers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The speed comparison is NOT apples-to-apples.&lt;/strong&gt; MinerU ran on GPU (PyTorch cu128 on the RTX 5090); the DeepDoc numbers from the earlier phase were CPU. So while MinerU parsed the three documents in 34–46s each, I'm &lt;strong&gt;not&lt;/strong&gt; reporting a clean "MinerU is N× faster than DeepDoc" — that would compare GPU against CPU and mislead. Aligning both on the same hardware is left for a follow-up. Take the speed dimension as unmeasured here, not as a MinerU win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Three documents, 32 questions.&lt;/strong&gt; The crossover is an observation on this set, not a settled result. At this sample size, a ±6% gap is a handful of questions — it could move with more documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The symmetry is suspicious.&lt;/strong&gt; DeepDoc leads BM25 by +6.2% and MinerU leads dense by +6.2% — the exact same margin both directions. That clean symmetry is more likely a quantization artifact of a 32-question set (each question ≈ 3.1%) than a real law of nature. I'm reporting the direction of the crossover with confidence and the precise magnitude with none.&lt;/p&gt;

&lt;p&gt;The honest version: &lt;em&gt;on this testbed, with a Japanese embedder, the direction is a crossover — DeepDoc better for lexical, MinerU better for dense. The exact numbers need a bigger set.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the series lands
&lt;/h2&gt;

&lt;p&gt;Three posts, one arc:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DeepDoc on Japanese&lt;/strong&gt; — found a font-path-specific OCR failure (era names), and that layout parsing helps retrieval (+12.5% on BM25).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dense vs BM25&lt;/strong&gt; — that help doubles under dense retrieval, because dense punishes incoherent chunks harder. A statement about RAG architecture, not one tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeepDoc vs MinerU&lt;/strong&gt; (this post) — the best parser is a crossover on your retriever; MinerU × dense is the strongest combination measured; the era-name failure is an OCR-path property shared across parsers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The throughline isn't "Chinese parsers are good/bad." It's that &lt;strong&gt;parser choice is a constrained decision&lt;/strong&gt; — your retriever, your document font path, your language — and the only way to get the answer is to measure your own stack, traced through to retrieval, on a test set hard enough to actually separate the options.&lt;/p&gt;

&lt;p&gt;That's the part most tooling comparisons skip, and it's the part that's worth doing.&lt;/p&gt;

&lt;p&gt;Raw 3×2 numbers, the parser-comparison breakdown, reproducible scripts:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v2" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v2&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Companion: &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;eval-sanity&lt;/a&gt;&lt;/strong&gt; (the sanity gate that confirmed the metric before each delta was trusted).&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>japan</category>
    </item>
    <item>
      <title>Structured parsing helps dense retrieval more than it helps BM25 — measured on Japanese docs, and the gap doubled</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Sat, 13 Jun 2026 07:17:12 +0000</pubDate>
      <link>https://dev.to/elvisyao007/structured-parsing-helps-dense-retrieval-more-than-it-helps-bm25-measured-on-japanese-docs-and-4dj7</link>
      <guid>https://dev.to/elvisyao007/structured-parsing-helps-dense-retrieval-more-than-it-helps-bm25-measured-on-japanese-docs-and-4dj7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Phase 3 of a series measuring Chinese open-source parsing (RAGFlow's DeepDoc) on Japanese documents. This tightens two limits I flagged in the earlier post.&lt;br&gt;
Repo + raw 2×2 results: &lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v2" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v2&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In an earlier post I measured DeepDoc (RAGFlow's document parser) against plain text extraction on Japanese PDFs and found a +12.5% hit@5 advantage from its layout-aware chunking — with two caveats I wrote down explicitly: it was &lt;strong&gt;BM25-only&lt;/strong&gt;, and the golden set had a &lt;strong&gt;100% oracle ceiling&lt;/strong&gt; (likely too easy, which could amplify the gap).&lt;/p&gt;

&lt;p&gt;This post closes both. I added a dense-retrieval dimension and built a harder golden set (oracle 87.5%). I expected DeepDoc's advantage to &lt;em&gt;shrink&lt;/em&gt; under dense retrieval — my reasoning was that embeddings might be less sensitive to chunk boundaries than lexical matching.&lt;/p&gt;

&lt;p&gt;It did the opposite. The advantage &lt;strong&gt;doubled&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 2×2
&lt;/h2&gt;

&lt;p&gt;Same documents, same questions, four pipeline/retriever combinations. hit@5:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline&lt;/th&gt;
&lt;th&gt;BM25&lt;/th&gt;
&lt;th&gt;Dense&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A — plain text (pdfplumber)&lt;/td&gt;
&lt;td&gt;56.2%&lt;/td&gt;
&lt;td&gt;40.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B — DeepDoc structured&lt;/td&gt;
&lt;td&gt;68.8%&lt;/td&gt;
&lt;td&gt;65.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Delta (B − A)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+12.5%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+25.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;DeepDoc's edge over plain text is &lt;strong&gt;+12.5% on BM25 but +25.0% on dense&lt;/strong&gt;. The structured parse helps dense retrieval roughly twice as much as it helps lexical.&lt;/p&gt;

&lt;p&gt;And look at what falls apart: plain text + dense is the worst cell in the table at &lt;strong&gt;40.6%&lt;/strong&gt; — well below plain text + BM25. Switching plain-text extraction from lexical to dense retrieval made it &lt;em&gt;worse&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flx1aahblabkuo3mmyg1w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flx1aahblabkuo3mmyg1w.png" alt=" " width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why: chunk quality matters more to dense than to BM25
&lt;/h2&gt;

&lt;p&gt;The mechanism is in the chunk counts. Plain-text extraction produced &lt;strong&gt;2,934 sliding-window chunks&lt;/strong&gt;; DeepDoc's structured parse produced &lt;strong&gt;630&lt;/strong&gt;. That's a 4.6× difference, and it cuts differently for each retriever:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BM25 matches keywords.&lt;/strong&gt; A fragmented chunk still contains its keywords, so lexical matching mostly survives fragmentation. Plain text holds up okay (56.2%).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dense embeds meaning.&lt;/strong&gt; A context-stripped sliding-window fragment produces a &lt;em&gt;low-quality vector&lt;/em&gt; — there isn't enough coherent context to embed well. So fragmentation hurts dense retrieval badly (40.6%).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DeepDoc's layout separation yields larger, semantically coherent chunks — which is exactly what a dense embedder needs to produce a good vector. So the value of structured parsing isn't constant across retrievers: &lt;strong&gt;it's worth more the more your retriever depends on chunk coherence, and dense retrieval depends on it most.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flcb350pvzr8wx2r4rxkj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flcb350pvzr8wx2r4rxkj.png" alt=" " width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That generalizes beyond this one parser and this one language. If you're running dense RAG, your chunking strategy is doing more work than you might think — and a parser that respects document structure is buying you more than the same parser would on a BM25 system.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest caveats (three of them, all load-bearing)
&lt;/h2&gt;

&lt;p&gt;This is an initial signal, and the limits matter as much as the headline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. This is an end-to-end comparison, not an isolated chunk-strategy test.&lt;/strong&gt; Pipeline B is "DeepDoc's parse &lt;em&gt;plus&lt;/em&gt; the chunking it naturally yields." Pipeline A is "plain text extraction &lt;em&gt;plus&lt;/em&gt; sliding-window chunking." I did &lt;strong&gt;not&lt;/strong&gt; hold chunking constant and swap only the parser. So strictly, the 2×2 compares two whole pipelines — which is what an enterprise actually deploys — but it does not by itself prove the gain comes from &lt;em&gt;parsing&lt;/em&gt; rather than from &lt;em&gt;chunk strategy&lt;/em&gt;. Isolating that (same chunker, swap only the parse layer) is the next step. I'm not claiming the layout model alone causes the lift; I'm claiming the end-to-end DeepDoc pipeline retrieves better, and the dense-vs-BM25 split points strongly at chunk coherence as the lever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The embedding is Japanese-specific (ruri-v3-310m).&lt;/strong&gt; Dense retrieval quality on Japanese is sensitive to the embedder. I used cl-nagoya's ruri-v3, a Japanese-first model, not a multilingual general one. The "+25% on dense" result holds &lt;em&gt;for this embedder&lt;/em&gt;. A different embedding model could shift the numbers — the conclusion is conditioned on a Japanese-tuned embedder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Three documents, 32 questions.&lt;/strong&gt; I narrowed to the three documents that work as a retrieval testbed (the form-type PDFs that DeepDoc fails to parse — covered in the previous post — aren't usable as a clean retrieval corpus). The golden set is harder than v1 (oracle 87.5% vs 100%: four questions ask for arithmetic-derived values that don't appear verbatim in the corpus, so even a perfect retriever can't surface them). But it's still a small sample. Signal, not verdict.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the harder golden set bought
&lt;/h2&gt;

&lt;p&gt;The v1 golden set had a 100% oracle ceiling — every relevant doc was reachable in the top-5, meaning the questions were easy enough that the retriever was never really stressed. v2's ceiling is &lt;strong&gt;87.5%&lt;/strong&gt;: four of the 32 questions (asking for figures like 664,957億円, computed totals not present verbatim) can't be answered by any retriever from this corpus.&lt;/p&gt;

&lt;p&gt;That matters because the +12.5%/+25.0% deltas are now measured under genuine difficulty, not on a set so easy the gap could be an artifact of headroom. Tightening the test is what turns "an interesting number" into "a number I'd defend."&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this lands
&lt;/h2&gt;

&lt;p&gt;The previous post's framing was "does a Chinese parser work on Japanese docs" — useful, but niche. This result is broader: &lt;strong&gt;structured parsing pays off more under dense retrieval than under lexical, because dense embeddings punish incoherent chunks harder.&lt;/strong&gt; That's a statement about RAG architecture, not about one parser or one language — and if you're building dense RAG on any messy document corpus, it's a reason to take your parse-and-chunk layer more seriously than the embedding model choice you probably agonized over instead.&lt;/p&gt;

&lt;p&gt;Next in the series: isolate the chunk-strategy variable (same chunker, swap only the parser), and — environment permitting — the same Japanese documents through MinerU and PaddleOCR, to see whether the structured-parse advantage is DeepDoc-specific or holds across the Chinese parser ecosystem.&lt;/p&gt;

&lt;p&gt;Raw 2×2 numbers, the harder golden set, the reproducible script:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v2" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v2&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Companion tooling: &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;eval-sanity&lt;/a&gt;&lt;/strong&gt; (the sanity gate that confirmed the metric before I trusted the delta).&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>japan</category>
    </item>
    <item>
      <title>Half of agent evaluation needs no LLM judge — and it's the half that catches the failures that actually hurt</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Fri, 12 Jun 2026 17:17:45 +0000</pubDate>
      <link>https://dev.to/elvisyao007/half-of-agent-evaluation-needs-no-llm-judge-and-its-the-half-that-catches-the-failures-that-2la3</link>
      <guid>https://dev.to/elvisyao007/half-of-agent-evaluation-needs-no-llm-judge-and-its-the-half-that-catches-the-failures-that-2la3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Part of an eval-first series. The trajectory evaluator described here shipped as eval-sanity v0.3 (zero dependencies, deterministic).&lt;br&gt;
Repo: &lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-sanity&lt;/a&gt; · Agent + traces: &lt;a href="https://github.com/elvisyao007/onprem-llm-stack/tree/main/payloads/invoice-agent" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/onprem-llm-stack/tree/main/payloads/invoice-agent&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;By 2026 the agent-evaluation problem is no longer hypothetical. LangChain's State of AI Agents report puts 57% of organizations with agents in production and names quality as the top deployment barrier. The standard answer to "how do you evaluate an agent" has become: capture the trajectory, then have an LLM judge it.&lt;/p&gt;

&lt;p&gt;LLM-as-judge is real and necessary — for the parts that need it. But a large fraction of agent evaluation is &lt;strong&gt;deterministic&lt;/strong&gt;, needs no judge at all, and happens to catch the failures that hurt most in an enterprise setting: the agent calling the wrong tool, skipping a required check, or writing bad data into a system of record. I built a small deterministic trajectory evaluator to make exactly that point, and ran it against a real invoice-processing agent.&lt;/p&gt;

&lt;p&gt;Here's the case for doing the cheap, deterministic layer first — and doing it well.&lt;/p&gt;




&lt;h2&gt;
  
  
  The agent: invoice extraction with a refusal condition
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1wivxckhe5relfkpc82k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1wivxckhe5relfkpc82k.png" alt=" " width="799" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The test subject is deliberately boring: a Japanese invoice (請求書) agent running on a self-hosted stack (LiteLLM gateway → local model). Three tools, no framework — just native function calling, because the agent is the thing being &lt;em&gt;evaluated&lt;/em&gt;, not the thing being &lt;em&gt;engineered&lt;/em&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;extract_fields(pdf)&lt;/code&gt; — pull structured fields from the invoice&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;validate(fields)&lt;/code&gt; — check the consumption-tax math and that line items sum to the stated total&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;write_back(fields)&lt;/code&gt; — commit to a (mock) accounting system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting behavior isn't extraction — OCR can extract. It's the &lt;strong&gt;refusal&lt;/strong&gt;: when &lt;code&gt;validate&lt;/code&gt; fails, the agent must &lt;em&gt;not&lt;/em&gt; write back. An agent that dutifully commits a invoice whose tax is miscalculated is worse than no agent, because it launders a bad number into your books with an audit trail that says "automated."&lt;/p&gt;

&lt;p&gt;I seeded five invoices: three clean, two with planted arithmetic errors (wrong 消費税 on one, wrong total on another). The good agent extracts all three clean ones and writes them back; on the two broken ones, it flags and refuses. That refusal is the whole value proposition.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you can check without a judge
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgklzcz0s1g3tsrdwwtdq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgklzcz0s1g3tsrdwwtdq.png" alt=" " width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the part the "just use an LLM judge" framing underrates. For an agent like this, most of what you care about is decidable by assertion, not by opinion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool-call correctness&lt;/strong&gt; — did the expected tools get called, with valid arguments?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order constraints&lt;/strong&gt; — was &lt;code&gt;write_back&lt;/code&gt; always preceded by a &lt;em&gt;passing&lt;/em&gt; &lt;code&gt;validate&lt;/code&gt;? This is a pure structural property of the trace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step efficiency&lt;/strong&gt; — how many steps, and were there redundant or repeated calls?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task completion&lt;/strong&gt; — against ground truth, did the right thing happen (write-back for clean, refusal for broken)?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these need a model to grade them. They're exact, reproducible, and fast enough to run as a CI gate on every prompt change, tool addition, or model swap. The 2026 consensus is converging on exactly this ordering — cheap deterministic checks first, escalate to an LLM judge only for what rules genuinely can't reach (was the agent's &lt;em&gt;prose&lt;/em&gt; helpful, was its &lt;em&gt;reasoning&lt;/em&gt; sound). I'm not arguing against LLM judges. I'm arguing that skipping straight to them skips the layer that catches the operationally worst failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Proving the evaluator actually discriminates
&lt;/h2&gt;

&lt;p&gt;A familiar trap — one I walked into on an earlier model-selection benchmark — is an evaluator that passes everything. An evaluator that rubber-stamps good traces tells you nothing; you have to show it fails the bad ones.&lt;/p&gt;

&lt;p&gt;So I didn't only run it on the five real (passing) traces. I constructed deliberately broken trajectories and confirmed each one gets caught, deterministically:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constructed failure&lt;/th&gt;
&lt;th&gt;Caught?&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;write_back&lt;/code&gt; without calling &lt;code&gt;validate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;missing required tool + order violation: "step 2 write_back: no preceding validate"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;write_back&lt;/code&gt; after a &lt;em&gt;failing&lt;/em&gt; validate&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;order violation: "'passed' never True"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redundant / unexpected extra tool calls&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;surfaced as diagnostics (redundant count, unexpected tool list)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;write_back&lt;/code&gt; on an invoice that should be refused&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;forbidden-tool violation on the refusal spec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the real traces: the three clean invoices pass with 3 steps each, zero violations; the two broken invoices correctly show 2 steps, &lt;strong&gt;no write_back&lt;/strong&gt;, status &lt;code&gt;flagged&lt;/code&gt;. The evaluator distinguishes "did the right thing" from "did the wrong thing" — which is the only property that makes an evaluator worth running.&lt;/p&gt;




&lt;h2&gt;
  
  
  Silent trajectory regression
&lt;/h2&gt;

&lt;p&gt;There's a sneakier failure than an outright wrong answer: the agent still &lt;em&gt;completes&lt;/em&gt; the task, but its path quietly degrades — more steps, an occasional skipped check, a creeping violation rate — after a prompt tweak or model swap. Outcome-only evaluation misses this completely, because the outcome still looks fine.&lt;/p&gt;

&lt;p&gt;The evaluator reuses a paired-bootstrap regression check (carried over from the retrieval-metric version of this tool) at the trajectory level: compare a baseline set of traces against a candidate set and alarm when completion stays flat but violation rate or step efficiency degrades significantly. In testing, a baseline of 8 good traces against a candidate of 4-bad-plus-4-good fired the alarm (completion −0.50, violations +0.50); two identical runs produced zero movement and correctly stayed silent.&lt;/p&gt;




&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;p&gt;The honest boundary, because it matters: if your agent always runs the same fixed sequence — retrieve, generate, format, every time — scoring the path buys you little; the output already tells you what you need. And if you're still in early prototyping, figuring out what the agent &lt;em&gt;should&lt;/em&gt; do, formalizing trajectory specs is premature.&lt;/p&gt;

&lt;p&gt;Trajectory evaluation earns its place when the path has &lt;em&gt;constraints that can be violated&lt;/em&gt; — like "never write back without a passing validate." My invoice agent has exactly that property, which is why structural checking is worth it here. A different agent might not need it. Knowing which case you're in is part of the judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The design choices worth stealing
&lt;/h2&gt;

&lt;p&gt;Two decisions did more work than the metrics themselves:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The order constraint is enforced in code, not just evaluated.&lt;/strong&gt; The agent's &lt;code&gt;write_back&lt;/code&gt; has a Python-side guard that refuses to commit unless &lt;code&gt;validate&lt;/code&gt; passed — independent of whether the LLM "decided" to follow instructions. You cannot trust an agent to always honor step ordering from a prompt; the load-bearing constraint belongs in the code, and the evaluator then confirms the trace respects it. Defense in depth, not prompt faith.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The eval is configurable, not absolute.&lt;/strong&gt; Calling an unexpected tool doesn't auto-fail — it surfaces as a diagnostic unless you explicitly add the tool to a forbidden list. Different tasks tolerate different slack. The strictness is a property of the spec you write, not baked into the evaluator. That's a feature: it forces you to state what "correct" means for &lt;em&gt;this&lt;/em&gt; task.&lt;/p&gt;




&lt;p&gt;Deterministic agent evaluation isn't the whole story — the LLM-judge layer above it is real, for the dimensions rules can't reach. But it's the cheaper layer, it's the CI-gateable layer, and for enterprise agents that touch systems of record, it's the layer that catches the failures you can least afford. Do it first, and do it well.&lt;/p&gt;

&lt;p&gt;Evaluator (zero deps, deterministic): &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;eval-sanity&lt;/a&gt;&lt;/strong&gt; v0.3&lt;br&gt;
The invoice agent and its traces: &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/onprem-llm-stack/tree/main/payloads/invoice-agent" rel="noopener noreferrer"&gt;onprem-llm-stack/payloads/invoice-agent&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Does a Chinese document parser actually work on Japanese PDFs? I measured it — and the answer is 'it depends on the font path'</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Fri, 12 Jun 2026 11:16:43 +0000</pubDate>
      <link>https://dev.to/elvisyao007/does-a-chinese-document-parser-actually-work-on-japanese-pdfs-i-measured-it-and-the-answer-is-2f5</link>
      <guid>https://dev.to/elvisyao007/does-a-chinese-document-parser-actually-work-on-japanese-pdfs-i-measured-it-and-the-answer-is-2f5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Part 1 of a series measuring Chinese open-source AI tooling on Japanese documents.&lt;br&gt;
Repo + raw results: &lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v1" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v1&lt;/a&gt;&lt;br&gt;
Every number below is from a live run on an RTX 5090 / RTX-class workstation. Sample sizes are small and stated explicitly — treat this as an initial signal, not a verdict.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;RAGFlow's DeepDoc is one of the better-known open-source document parsers to come out of the Chinese AI ecosystem. It does OCR, table-structure recognition (TSR), and document-layout recognition (DLR), and it's RAGFlow's default PDF parser. The English and Japanese dev communities mostly haven't measured it on &lt;strong&gt;Japanese&lt;/strong&gt; documents — which is exactly the gap I sit in: I can read the Chinese tooling, and I can test it on the Japanese enterprise document types that actually matter here.&lt;/p&gt;

&lt;p&gt;So I ran it. The interesting part isn't a thumbs-up or thumbs-down. It's that the answer splits cleanly by &lt;strong&gt;which internal path a given PDF takes&lt;/strong&gt; — and one of those paths systematically corrupts Japanese era names.&lt;/p&gt;

&lt;p&gt;Here's the honest version, limits attached.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trap I almost fell into
&lt;/h2&gt;

&lt;p&gt;My first-pass observation was alarming and simple: DeepDoc was misreading 令 as 今. In Japanese that's not a random glyph error — 令 is the first character of 令和 (Reiwa), the current imperial era. A parser that turns 令 into 今 corrupts the date on every government report, invoice, and contract that uses the era-name calendar. That's a potential dealbreaker for Japanese enterprise documents, where dates carry legal weight.&lt;/p&gt;

&lt;p&gt;If I'd stopped there, I'd have published "Chinese parser breaks Japanese era names" — and I'd have been wrong, or at least sloppy. Because when I quantified it, the error wasn't a property of DeepDoc on Japanese text. It was a property of &lt;strong&gt;one code path&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual finding: it's a font-path problem, not a language problem
&lt;/h2&gt;

&lt;p&gt;DeepDoc (via the PdfParser API) routes text two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Embedded-font PDFs&lt;/strong&gt; — most government reports, anything exported from Word/LaTeX — go through native text extraction (pdfplumber under the hood). On these, the 令→今 error rate was &lt;strong&gt;0%&lt;/strong&gt;. The text is read, not recognized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form-font / scanned PDFs&lt;/strong&gt; — where there's no extractable text layer — fall back to the &lt;strong&gt;OCR&lt;/strong&gt; path. On these, the era-name corruption ran to roughly &lt;strong&gt;100%&lt;/strong&gt; on the affected pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the precise claim is: &lt;em&gt;DeepDoc's OCR fallback systematically misreads 令 as 今 on Japanese form-font and scanned pages; its native-text path does not.&lt;/em&gt; "It breaks Japanese" was too broad. "Its OCR path breaks era names, and lots of real enterprise documents hit the OCR path" is the true, and more useful, statement.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuwxcawhqinucr7dfvnig.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuwxcawhqinucr7dfvnig.png" alt=" " width="800" height="324"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This distinction matters operationally. If your corpus is clean digital government PDFs, this specific failure won't touch you. If your corpus is scanned invoices and tax forms — which is a huge fraction of real Japanese back-office documents — you're on the path that fails.&lt;/p&gt;




&lt;h2&gt;
  
  
  But does any of this reach the thing you actually care about — retrieval?
&lt;/h2&gt;

&lt;p&gt;Parsing quality is a means. What an enterprise RAG system cares about is whether the right chunk comes back. So I didn't stop at "the parse looks good/bad." I measured the downstream delta: same documents, same questions, two ingestion pipelines.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline A (baseline):&lt;/strong&gt; plain text extraction (pdfplumber) → chunk → retrieve&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline B (DeepDoc):&lt;/strong&gt; DeepDoc structured parse → chunk → retrieve&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a 20-question Japanese golden set built from the sample documents:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline&lt;/th&gt;
&lt;th&gt;hit@5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A — plain text&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B — DeepDoc&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;DeepDoc's layout understanding netted +15% hit@5.&lt;/strong&gt; The layout separation produces cleaner chunks, and on these documents that win outweighed the OCR errors. The net effect was positive.&lt;/p&gt;

&lt;p&gt;Now the limits, because they're load-bearing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;This is BM25 (lexical) retrieval.&lt;/strong&gt; The dense-retrieval comparison is Phase 3, not done yet. Do not read "+15%" as "DeepDoc improves retrieval in general" — read it as "on lexical retrieval, on these docs, layout-aware chunking helped."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The golden set has a 100% oracle ceiling.&lt;/strong&gt; Every relevant doc is reachable in the top-5 for all 20 questions — meaning the set may be on the easy side, and the 75-vs-90 gap could be amplified or distorted by that. A harder set (oracle &amp;lt; 100%) is Phase 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 questions, 5 documents.&lt;/strong&gt; This is a signal, not a settled number.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'm reporting the +15% with all three caveats firmly attached. The honest takeaway is directional: layout-aware parsing tends to help retrieval &lt;em&gt;enough to matter&lt;/em&gt;, and the precise magnitude needs a harder test.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where DeepDoc is genuinely weak: tables and forms
&lt;/h2&gt;

&lt;p&gt;The flip side, and the part DeepDoc's marketing wouldn't lead with. TSR — table-structure recognition — is one of its headline features. On a Japanese tax form's table, exact-match cell accuracy was &lt;strong&gt;30% (20 cells checked)&lt;/strong&gt;. That's low, on the feature it's supposed to be best at.&lt;/p&gt;

&lt;p&gt;And form PDFs were worse. On the e-Tax-style form sample, DeepDoc extracted essentially &lt;strong&gt;one chunk&lt;/strong&gt; from the whole document — the structure collapsed.&lt;/p&gt;

&lt;p&gt;Put together, the weak spot is specific and it's exactly the wrong one for this market: &lt;strong&gt;form-type documents — invoices, 請求書, tax filings — are the bulk of Japanese back-office paperwork, and that's where DeepDoc struggles most&lt;/strong&gt; (both the OCR era-name corruption and the table/form collapse live here).&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer, as a matrix instead of a verdict
&lt;/h2&gt;

&lt;p&gt;"Should a Japanese company use DeepDoc?" has no yes/no answer. It has a font-path-and-doctype answer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document type&lt;/th&gt;
&lt;th&gt;DeepDoc behavior&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Embedded-font PDF (gov reports, Word exports)&lt;/td&gt;
&lt;td&gt;OCR error 0%; +15% hit@5 from layout&lt;/td&gt;
&lt;td&gt;native-text path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form-font / scanned PDF&lt;/td&gt;
&lt;td&gt;令→今 era-name corruption ~100%&lt;/td&gt;
&lt;td&gt;OCR fallback path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table-heavy documents&lt;/td&gt;
&lt;td&gt;TSR exact-match only ~30%&lt;/td&gt;
&lt;td&gt;headline feature underperforms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form documents (e-Tax style)&lt;/td&gt;
&lt;td&gt;near-total failure, ~1 chunk extracted&lt;/td&gt;
&lt;td&gt;structure collapses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That matrix is the deliverable. Not "good" or "bad" — &lt;em&gt;good here, broken there, and here's the line.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpwrbkg36smi4qnf0wsi2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpwrbkg36smi4qnf0wsi2.png" alt=" " width="799" height="448"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I bothered, and what's next
&lt;/h2&gt;

&lt;p&gt;Most tooling reviews test on clean English PDFs and report a single score. The failure modes that actually bite enterprises live in the specifics: a particular font path, a particular document type, a particular language's calendar. You only see them if you run the real tool on the real document types in the real language — and then trace the error all the way to retrieval, where it either matters or doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 3&lt;/strong&gt; (next in the series): dense-retrieval comparison, a harder golden set with oracle &amp;lt; 100% to pressure-test the +15%, and — the natural next question — the same Japanese documents through &lt;strong&gt;MinerU&lt;/strong&gt; and &lt;strong&gt;PaddleOCR&lt;/strong&gt;, the other major Chinese parsers, to see whether the font-path failure is DeepDoc-specific or ecosystem-wide.&lt;/p&gt;

&lt;p&gt;Raw parses, the golden set, the OCR error counts, and the retrieval results are all in the repo:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v1" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/deepdoc-eval-v1&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Companion tooling: &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;eval-sanity&lt;/a&gt;&lt;/strong&gt; (the sanity gate that confirmed the retrieval metric was trustworthy before I reported the delta) and &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm" rel="noopener noreferrer"&gt;eval-driven-llm&lt;/a&gt;&lt;/strong&gt; (the eval harness this runs on).&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>japan</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>My local-LLM benchmark gave every model a perfect score. That was the most useful failure of the project.</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Thu, 11 Jun 2026 13:08:11 +0000</pubDate>
      <link>https://dev.to/elvisyao007/my-local-llm-benchmark-gave-every-model-a-perfect-score-that-was-the-most-useful-failure-of-the-2054</link>
      <guid>https://dev.to/elvisyao007/my-local-llm-benchmark-gave-every-model-a-perfect-score-that-was-the-most-useful-failure-of-the-2054</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Repo + raw results: &lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/model-selection-v1" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/model-selection-v1&lt;/a&gt;&lt;br&gt;
Every number below is from a live run on an RTX 5090 (32 GB), Ollama, four models. The v1→v2 history is in the commit log — you can verify the failure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I set out to answer a narrow, practical question: &lt;strong&gt;which local LLM should a Japanese company actually run on-prem?&lt;/strong&gt; Four candidates, a fixed independent judge, three dimensions — quality, latency, VRAM.&lt;/p&gt;

&lt;p&gt;The first run came back with every model scoring near-perfect. Faithfulness 1.0000 across the board. Hit rate 0.90–1.00. Judge-agreement κ = 1.0.&lt;/p&gt;

&lt;p&gt;It looked like a clean result. It was actually a broken benchmark — and figuring out &lt;em&gt;why&lt;/em&gt; taught me more than any leaderboard number would have.&lt;/p&gt;




&lt;h2&gt;
  
  
  The number that's too good to be true
&lt;/h2&gt;

&lt;p&gt;Here's v1, 20 questions, four models:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Params&lt;/th&gt;
&lt;th&gt;Faithfulness&lt;/th&gt;
&lt;th&gt;Hit rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;elyza-jp-8b&lt;/td&gt;
&lt;td&gt;8.0B&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma4-31b&lt;/td&gt;
&lt;td&gt;31.3B&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nemotron-nano-9b-jp&lt;/td&gt;
&lt;td&gt;8.9B&lt;/td&gt;
&lt;td&gt;0.9792&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;swallow-8b&lt;/td&gt;
&lt;td&gt;8.0B&lt;/td&gt;
&lt;td&gt;1.0000&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An 8B model and a 31B model scoring identically should set off an alarm. Model capacity that different, collapsing to the same score, almost always means the &lt;em&gt;test&lt;/em&gt; isn't resolving the difference — not that the difference is gone.&lt;/p&gt;

&lt;p&gt;The discriminability breakdown made it undeniable: &lt;strong&gt;90% of questions were answered correctly by every model, 10% by some, and 0% by none.&lt;/strong&gt; A benchmark where nine out of ten questions can't tell your candidates apart isn't measuring the candidates. It's measuring whether the questions are easy. They were.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F60rra2tq11y3e30avl5b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F60rra2tq11y3e30avl5b.png" alt=" " width="799" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the κ = 1.0 that looked like a perfect, reassuring judge agreement? When two judges both assign full marks to nearly everything, perfect agreement is the &lt;em&gt;trivial&lt;/em&gt; solution, not a strong signal. Zero variance makes the statistic meaningless. A κ of 1.0 here wasn't "the judges agree" — it was "there was nothing to disagree about."&lt;/p&gt;

&lt;p&gt;A benchmark that gives everyone full marks is informationally equivalent to no benchmark at all. You can't make a selection decision on it, because it contains no signal about which model to select.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this is the interesting part, not the embarrassing part
&lt;/h2&gt;

&lt;p&gt;The temptation is to quietly fix the questions and publish only the clean v2 table. I'm doing the opposite — keeping v1 in the repo, with an ADR documenting the failure — because the failure &lt;em&gt;is&lt;/em&gt; the methodology content.&lt;/p&gt;

&lt;p&gt;Anyone can run four models through a question set and print a table. The thing that's actually hard, and actually rare, is recognizing that your own measurement is broken when the numbers look great. Most published "local LLM comparisons" never check discriminability at all. They show you a table of high scores and call it a benchmark. If every model on that table scores 90%+, you're looking at the easiness of the questions, not the quality of the models.&lt;/p&gt;

&lt;p&gt;So the real deliverable here isn't "model X won." It's a protocol: &lt;strong&gt;a model-selection benchmark is only valid if it can resolve the models it compares — and you have to test that explicitly before you trust a single score.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Building discriminability back in
&lt;/h2&gt;

&lt;p&gt;v2 replaced the question set with 45 items deliberately designed to be hard enough to separate the field:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;multi-step reasoning&lt;/strong&gt; rather than single-fact lookup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Japanese nuance&lt;/strong&gt; — keigo (honorifics), specialized terminology, deliberately ambiguous phrasing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;boundary facts&lt;/strong&gt; — specific dates and figures that are easy to hallucinate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The target wasn't "make it hard for its own sake." It was a specific distribution: the strongest model should still miss some. v1 was the wrong shape (90/10/0). v2 landed at &lt;strong&gt;29% answered by all, 51% by some, 20% by none.&lt;/strong&gt; That 20% nobody gets is the part that gives the benchmark resolution at the top end.&lt;/p&gt;

&lt;p&gt;Here's v2:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjx4prwh3h6zh1w7mkhyb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjx4prwh3h6zh1w7mkhyb.png" alt=" " width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hit-rate spread went from 0.10 (v1) to 0.22 (v2). The models actually separate now. And the judge agreement that mattered: &lt;strong&gt;κ dropped from 1.0 to 0.920.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That drop is an &lt;em&gt;improvement&lt;/em&gt;. v1's κ = 1.0 was a zero-variance artifact. v2's κ = 0.920 is a real agreement number computed over real disagreement — it's the first version where the judge-reliability statistic actually means anything. If you see a benchmark reporting perfect judge agreement, ask whether there was any variance for the judges to agree about.&lt;/p&gt;




&lt;h2&gt;
  
  
  The finding worth flagging (with the caveat attached)
&lt;/h2&gt;

&lt;p&gt;The thing that made me look twice: &lt;strong&gt;nemotron-nano-9b-jp (8.9B) tied gemma4-31b (31.3B) on hit rate&lt;/strong&gt; — 0.622 each — while using roughly half the VRAM (~11 GB vs ~20 GB) and running about 2.6× faster (190 vs 71 tokens/s, warm).&lt;/p&gt;

&lt;p&gt;If that holds, it's the whole point of doing selection by &lt;em&gt;constraint&lt;/em&gt; instead of by raw capability. The biggest model is not automatically the right deployment choice. Under a VRAM ceiling, a latency target, or a throughput requirement, a 9B Japanese-sovereign model that matches a 31B on the task is the better call — and you'd never see that from a "which model is strongest" framing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest caveat, up front:&lt;/strong&gt; this is 45 questions. The nemotron-vs-gemma4 tie is an observation on this set, not a settled result. It needs a larger sample to confirm, and I'm reporting it as a lead to chase, not a conclusion to act on. The point of the protocol is precisely that you don't get to claim a result the sample can't support.&lt;/p&gt;




&lt;h2&gt;
  
  
  Judge setup, for the skeptics
&lt;/h2&gt;

&lt;p&gt;Because the first question a careful reader asks is "who graded this, and did anything grade itself":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Primary judge: qwen3:32b — and it is not a contestant.&lt;/strong&gt; It's a Chinese model; by my own deployment/content separation rule it doesn't belong in the Japanese on-prem default lineup, so it sits out the race and judges instead. That sidesteps self-preference bias: no contestant grades its own homework or a same-family sibling's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-validation: gemma4:31b re-judged a 20-question subset&lt;/strong&gt; to check the primary judge's reliability (the κ = 0.920 above). gemma4 &lt;em&gt;is&lt;/em&gt; a contestant, so it's used only to validate the judge protocol — never to score itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two models can't co-reside on 32 GB (qwen3:32b ~29 GB, gemma4:31b ~19 GB), so the whole thing runs two-pass: generate all answers, evict, load judge, score all answers. Resumable, cached per model.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd hand to anyone benchmarking models for selection
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check discriminability before you trust any score.&lt;/strong&gt; If most questions are answered correctly by every candidate, your benchmark is measuring question difficulty, not model quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A perfect score is a red flag, not a green one.&lt;/strong&gt; Especially when models of very different size tie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perfect judge agreement (κ=1.0) on a low-variance set is meaningless.&lt;/strong&gt; A slightly lower κ over real disagreement is worth more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select by constraint, not by raw capability.&lt;/strong&gt; "Strongest" and "right for this deployment" are different questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the failed version.&lt;/strong&gt; The path from a broken benchmark to a working one is the part nobody can fake.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full protocol, the v1 failure, the v2 fix, and every raw judged output:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/model-selection-v1" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/eval-driven-llm/tree/main/reports/model-selection-v1&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffpucj7fxlqs8fisuteia.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffpucj7fxlqs8fisuteia.png" alt=" " width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The companion tooling — a zero-dependency library that audits whether a retrieval metric can be trusted in the first place — is at &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;eval-sanity&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>japan</category>
    </item>
    <item>
      <title>I built a self-hosted LLM stack that grades itself — audit trail, per-user auth, and a built-in acceptance test</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Thu, 11 Jun 2026 06:39:24 +0000</pubDate>
      <link>https://dev.to/elvisyao007/i-built-a-self-hosted-llm-stack-that-grades-itself-audit-trail-per-user-auth-and-a-built-in-2hl8</link>
      <guid>https://dev.to/elvisyao007/i-built-a-self-hosted-llm-stack-that-grades-itself-audit-trail-per-user-auth-and-a-built-in-2hl8</guid>
      <description>&lt;p&gt;canonical_url: &lt;a href="https://dev.to/elvisyao007/REPLACE-AFTER-PUBLISH"&gt;https://dev.to/elvisyao007/REPLACE-AFTER-PUBLISH&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Repo: &lt;a href="https://github.com/elvisyao007/onprem-llm-stack" rel="noopener noreferrer"&gt;https://github.com/elvisyao007/onprem-llm-stack&lt;/a&gt; (Apache-2.0)&lt;br&gt;
Runs fully on-prem. No data — including the audit log — leaves the box.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most "deploy your own ChatGPT" tutorials stop at the moment the container answers a question in a browser. That's the easy 20%. The hard 80% is everything an enterprise actually asks before it puts the thing in front of users: &lt;em&gt;Who can call it? What did they ask? And how do I know it's good enough to ship — objectively, not by vibes?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The reason this matters isn't theoretical. Across 2026 enterprise surveys, roughly &lt;strong&gt;88% of AI pilots never reach production&lt;/strong&gt;, and the most-cited blocker isn't model quality — it's the absence of an evaluation/acceptance bar and the governance around access and audit. A demo that runs is not a production signal.&lt;/p&gt;

&lt;p&gt;So I built a stack where the demo isn't the deliverable. The deliverable is three things a tutorial skips:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data never leaves the box&lt;/strong&gt; — including the audit log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user access control with attributable audit&lt;/strong&gt; — you can answer "who tried to call a model they weren't allowed to."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A built-in acceptance test&lt;/strong&gt; — one command, and the stack grades &lt;em&gt;itself&lt;/em&gt; with an independent judge and gives you a PASS/FAIL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The boring part (compose + a gateway + a web UI) is the part everyone already has. This post is about the three parts they don't, and the three bugs I only found because I actually tried to run them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faxyu0flkl7n6nln2ly67.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faxyu0flkl7n6nln2ly67.png" alt=" " width="799" height="644"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing exotic in the wiring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inference&lt;/strong&gt;: Ollama in the dev profile (the host already runs it), vLLM in the prod profile. The gateway hides which one is behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway&lt;/strong&gt;: LiteLLM — one place for keys, budgets, model routing, and audit callbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI&lt;/strong&gt;: Open WebUI.&lt;/li&gt;
&lt;li&gt;Two compose profiles, every image pinned to an exact version (no &lt;code&gt;latest&lt;/code&gt; — air-gapped reproducibility is a precondition, not a nice-to-have).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting design choice is what sits at the center: &lt;strong&gt;the evaluation methodology is the backbone; the retriever, the model, the framework are all swappable payload.&lt;/strong&gt; Everything in the stack is config except the question "is this good enough," which is the one thing you can't outsource to a model version bump.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #1: the access check that exists but never runs
&lt;/h2&gt;

&lt;p&gt;The first real feature is per-user virtual keys: &lt;code&gt;alice&lt;/code&gt; may call &lt;code&gt;qwen3-32b&lt;/code&gt;, &lt;code&gt;bob&lt;/code&gt; may call &lt;code&gt;gemma4-31b&lt;/code&gt;, and crossing that line should return a 403.&lt;/p&gt;

&lt;p&gt;LiteLLM (v1.88.1) ships a function called &lt;code&gt;can_key_call_model&lt;/code&gt;. It does exactly what the name says. The problem: on the custom-auth path, it's never invoked from &lt;code&gt;common_checks&lt;/code&gt;. So with a custom authenticator wired in, a key authorized for &lt;em&gt;any&lt;/em&gt; model could call &lt;em&gt;every&lt;/em&gt; model. The guardrail was in the codebase and silently bypassed.&lt;/p&gt;

&lt;p&gt;The fix wasn't to monkey-patch the routing layer. It was to enforce access at the only point where I had both the authenticated identity &lt;em&gt;and&lt;/em&gt; the requested model in hand: read the model out of the raw request body inside the auth hook, check it against the key's allow-list, and raise 403 &lt;strong&gt;before&lt;/strong&gt; returning the auth object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alice → qwen3-32b   → 200 OK
alice → gemma4-31b  → 403 model_access_denied
bob   → gemma4-31b  → 200 OK
bob   → qwen3-32b   → 403 model_access_denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson I'd hand to anyone wiring custom auth into a gateway: &lt;strong&gt;a function existing in the library is not the same as that function running on your code path.&lt;/strong&gt; Verify the denial, don't assume the helper fires.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #2: "someone broke the rules, but we don't know who"
&lt;/h2&gt;

&lt;p&gt;With denials working, I checked the audit log. The successful requests were fine — user, model, token counts, latency. The &lt;em&gt;denied&lt;/em&gt; requests were recorded as &lt;code&gt;user_id='unknown'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the worst possible failure for a security audit. "An unauthorized attempt happened and we can't attribute it" is exactly the line you don't want in front of an enterprise security reviewer. And it's backwards from how audit value actually works: &lt;strong&gt;who &lt;em&gt;tried&lt;/em&gt; to cross a boundary is more important to log than who used the system normally.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The root cause was a sequencing problem. LiteLLM calls the failure callback &lt;em&gt;after&lt;/em&gt; the custom auth raises — and by then the request metadata is empty, so the callback has no identity to attribute the row to.&lt;/p&gt;

&lt;p&gt;The fix mirrors Bug #1: write the audit record at the one moment the context exists — inside the auth hook, &lt;em&gt;before&lt;/em&gt; raising the 403 — with the correct user, key label, model, and denial reason. Then I tag the exception so the downstream failure callback sees it's already been logged and skips it, instead of writing a second &lt;code&gt;unknown&lt;/code&gt; row.&lt;/p&gt;

&lt;p&gt;One detail I left deliberately: genuinely invalid keys (keys that don't exist in the system at all) still log as &lt;code&gt;unknown&lt;/code&gt;. That's honest — there's no identity to attribute. The audit distinguishes "a known user attempted something they weren't allowed to" from "an unidentifiable caller hit the door." Those are different events and the log should say so.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual differentiator: the stack grades itself
&lt;/h2&gt;

&lt;p&gt;Here's the part no compose tutorial has. After you bring the stack up, you run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make smoke-eval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and it runs a small, fully offline acceptance test: ~15 neutral factual questions, asked through the gateway to the model under test, then &lt;strong&gt;scored by a different model acting as judge&lt;/strong&gt; — and prints a PASS/FAIL against a threshold.&lt;/p&gt;

&lt;p&gt;Two principles, both non-negotiable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The judge is never the generator.&lt;/strong&gt; Default generator is &lt;code&gt;qwen3-32b&lt;/code&gt;; default judge is &lt;code&gt;gemma4-31b&lt;/code&gt; — different model families, so nothing grades its own homework. The summary JSON literally carries &lt;code&gt;judge_independent: true&lt;/code&gt;, and the report states it in plain text. A self-graded eval is worth nothing; if you remember one thing from this section, make it that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The golden set contains zero real data.&lt;/strong&gt; It's neutral technical/general-knowledge questions. An acceptance test that ships with customer data would contradict the entire "nothing leaves the box" premise — including, especially, the test itself.&lt;/p&gt;

&lt;p&gt;My run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;smoke-eval  →  PASS  11/15 (73.3%)   threshold 70%
generator: qwen3-32b   judge: gemma4-31b (independent)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;73.3%, not 100% — and that's the point.&lt;/strong&gt; An acceptance test that returns a perfect score on first run is a test that isn't testing anything: either the questions are trivial or the judge is lenient. Four failing questions means the bar has resolution. The number you can trust is the one that can come back red.&lt;/p&gt;

&lt;p&gt;This is the line between a demo and a production system. The enterprise blocker isn't "can it answer" — it's "by what objective standard is it good enough to ship." The stack answers that in the first minute, on the customer's own hardware, with a judge that never phones home.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #3: two big models, one 32 GB GPU
&lt;/h2&gt;

&lt;p&gt;Running the eval surfaced the hardware reality. &lt;code&gt;qwen3:32b&lt;/code&gt; needs ~29 GB; &lt;code&gt;gemma4:31b&lt;/code&gt; needs ~19 GB. Generator + judge = 48 GB on a card that holds 32. They cannot co-reside.&lt;/p&gt;

&lt;p&gt;The fix is a two-pass design: generate &lt;strong&gt;all&lt;/strong&gt; answers first, evict the generator (&lt;code&gt;keep_alive=0&lt;/code&gt;), then load the judge and score &lt;strong&gt;all&lt;/strong&gt; answers in a second pass. The naive structure — generate one, judge one — would thrash the GPU, swapping a 20–30 GB model in and out on every single question. Batching the passes turns dozens of model loads into exactly two.&lt;/p&gt;

&lt;p&gt;There was a second, subtler trap. Both models are "thinking" models — they spend output tokens on chain-of-thought before the answer. With a tight judge token budget, the CoT exhausts the allowance and you get &lt;em&gt;empty&lt;/em&gt; content back, which the judge then can't score. The fix was to pass &lt;code&gt;think:false&lt;/code&gt; through the gateway and raise the judge's token ceiling. You don't see this one unless you actually run the loop end to end on real hardware; it never shows up in a notebook.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I deliberately left out
&lt;/h2&gt;

&lt;p&gt;v0.1 ships auth, audit, and the acceptance test. It does &lt;strong&gt;not&lt;/strong&gt; ship PII content guardrails, SSO/LDAP, Langfuse-style observability, Kubernetes, or multi-GPU serving. Those are on the roadmap, written down as roadmap — not quietly absent. Scope discipline is the whole game for a solo build: a small thing that actually survives enterprise reality beats a big thing that's 80% stubs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this exists
&lt;/h2&gt;

&lt;p&gt;The three bugs above share a shape: &lt;strong&gt;the capability looked present, and only running it proved whether it was real.&lt;/strong&gt; The access check existed but didn't fire. The audit logged, but not the events that mattered. The eval would run, but thrash the GPU and feed the judge empty answers. None of these are visible from the README of the tool you're integrating — they're visible from the failing run.&lt;/p&gt;

&lt;p&gt;That's the difference I'm trying to build into everything here: not a system that runs, but a system that survives dirty data, multiple users, data that can't leave the building, and an objective, repeatable definition of "good enough to ship."&lt;/p&gt;

&lt;p&gt;The deployment stack is one repo. The full evaluation methodology lives in two companions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm" rel="noopener noreferrer"&gt;eval-driven-llm&lt;/a&gt;&lt;/strong&gt; — the eval-first reference system (frozen golden sets, pinned independent judge, deterministic retrieval metrics).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;eval-sanity&lt;/a&gt;&lt;/strong&gt; — a zero-dependency tool that audits whether your retrieval metric can be trusted in the first place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack: &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/onprem-llm-stack" rel="noopener noreferrer"&gt;onprem-llm-stack&lt;/a&gt;&lt;/strong&gt;. Clone it, bring it up, run &lt;code&gt;make smoke-eval&lt;/code&gt;, and watch it grade itself.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>selfhosted</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your RAG dashboard can hide a failing retriever: detecting silent regression</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Mon, 08 Jun 2026 16:44:58 +0000</pubDate>
      <link>https://dev.to/elvisyao007/your-rag-dashboard-can-hide-a-failing-retriever-detecting-silent-regression-pj6</link>
      <guid>https://dev.to/elvisyao007/your-rag-dashboard-can-hide-a-failing-retriever-detecting-silent-regression-pj6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This is a follow-up to an earlier post where I found that my context-recall&lt;br&gt;
metric &lt;em&gt;over-reported&lt;/em&gt; retrieval failure (it flagged 33/100 answers that were&lt;br&gt;
actually fine). This post is about the opposite and more dangerous failure: a&lt;br&gt;
metric that &lt;em&gt;under-reports&lt;/em&gt;. Retrieval quietly gets worse, your generation&lt;br&gt;
metrics stay green, and the dashboard shows nothing. I packaged the detector&lt;br&gt;
into &lt;code&gt;eval-sanity&lt;/code&gt; v0.2.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;Here is a pattern that shows up repeatedly in production RAG postmortems. A&lt;br&gt;
system ships with a healthy offline eval — say faithfulness around 0.9. Weeks&lt;br&gt;
later, users start reporting that some fraction of answers miss a key fact. The&lt;br&gt;
team checks the dashboard: faithfulness is still ~0.9. Nothing looks wrong.&lt;/p&gt;

&lt;p&gt;What actually happened: the retriever degraded — a re-index, an embedding model&lt;br&gt;
swap, a chunking change — and started missing relevant documents on a subset of&lt;br&gt;
queries. But the generator kept producing fluent, internally-consistent answers&lt;br&gt;
from whatever partial context it received. Faithfulness measures "is the answer&lt;br&gt;
grounded in the retrieved context," not "was the right context retrieved." So&lt;br&gt;
faithfulness stayed high while retrieval silently fell off.&lt;/p&gt;

&lt;p&gt;If your dashboard tracks only generation-stage metrics, this regression is&lt;br&gt;
invisible. That's the trap: the two metrics move independently, and the healthy&lt;br&gt;
one masks the broken one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why a single eval run can't catch it
&lt;/h2&gt;

&lt;p&gt;You can't see this in one snapshot. A faithfulness of 0.9 looks fine in&lt;br&gt;
isolation. The signal only exists in the &lt;em&gt;comparison&lt;/em&gt; between two runs — a&lt;br&gt;
baseline and a current — where you ask: did retrieval drop while generation&lt;br&gt;
held steady? That specific divergence is the fingerprint of a silent&lt;br&gt;
regression.&lt;/p&gt;

&lt;p&gt;But naive comparison creates a different problem: noise. Eval scores wobble&lt;br&gt;
between runs from judge variance and sampling. If you alarm on "current is&lt;br&gt;
lower than baseline," you'll fire constantly on noise, and an alarm that cries&lt;br&gt;
wolf gets ignored by the second week. So the detection has to separate real&lt;br&gt;
movement from jitter.&lt;/p&gt;
&lt;h2&gt;
  
  
  What eval-sanity v0.2 does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvooptkgykhls3hyb1app.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvooptkgykhls3hyb1app.png" alt=" " width="800" height="594"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;eval-sanity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;detect_regression&lt;/code&gt; takes two eval runs — the retrieved/relevant doc IDs you&lt;br&gt;
already have, plus your generation scores (faithfulness or similar) passed in —&lt;br&gt;
and reports which of four states you're in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;silent regression&lt;/strong&gt; (alarm): retrieval dropped significantly, generation
did not move&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;visible regression&lt;/strong&gt;: both dropped — your dashboard already shows this, no
alarm needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;generation-only&lt;/strong&gt;: generation moved, retrieval held&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stable / noise&lt;/strong&gt;: nothing moved beyond jitter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "significantly" is the important part. Every delta goes through a &lt;strong&gt;paired&lt;br&gt;
bootstrap&lt;/strong&gt; (10k resamples, fixed seed) and a &lt;strong&gt;95% confidence interval&lt;/strong&gt;. A&lt;br&gt;
change counts only if its CI excludes zero. This is what keeps it from firing&lt;br&gt;
on noise. It runs in a fraction of a second, with zero dependencies and no model&lt;br&gt;
calls — it's pure deterministic math on the IDs and scores you already have.&lt;/p&gt;
&lt;h2&gt;
  
  
  A worked example
&lt;/h2&gt;

&lt;p&gt;Here's a synthetic case that makes the divergence concrete (numbers from the&lt;br&gt;
package's demo, not a real client system):&lt;/p&gt;

&lt;p&gt;A baseline run with recall@5 = 0.95 and faithfulness = 0.90. A current run where&lt;br&gt;
retrieval has degraded to recall@5 = 0.667, but faithfulness is unchanged at&lt;br&gt;
0.90. The detector reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;recall@5     0.95 → 0.667   CI [-0.417, -0.150]   significant drop
faithfulness 0.90 → 0.90    CI [-0.005, +0.005]   unchanged

*** ALARM *** SILENT REGRESSION
Retrieval dropped while generation held steady — your dashboard won't show this.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the control case — a current run where only 2 of 60 queries flip, pure&lt;br&gt;
jitter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;recall@5     CI [-0.083, +0.000]   includes zero → flat
No significant change; within noise.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same machinery, no false alarm. That control case matters more than the alarm&lt;br&gt;
case: a regression detector you can't trust to stay quiet is worse than no&lt;br&gt;
detector at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to wire it in
&lt;/h2&gt;

&lt;p&gt;The point isn't to run this once. It's to run it on every meaningful change —&lt;br&gt;
a re-index, an embedder swap, a chunking tweak — comparing against your last&lt;br&gt;
known-good baseline, before the change reaches users. It's a regression gate,&lt;br&gt;
the retrieval-stage equivalent of a test that fails CI when generation metrics&lt;br&gt;
alone would have stayed green.&lt;/p&gt;

&lt;p&gt;A complete RAG eval program needs at least one retrieval-stage signal alongside&lt;br&gt;
the generation-stage ones, precisely so the healthy metric can't hide the&lt;br&gt;
broken one. &lt;code&gt;eval-sanity&lt;/code&gt; is a small, dependency-free way to make that&lt;br&gt;
retrieval-stage check a regression gate rather than a number nobody compares&lt;br&gt;
across runs.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;github.com/elvisyao007/eval-sanity&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The detection logic, the bootstrap implementation, and the full test suite&lt;br&gt;
(covering each of the four states plus the noise-rejection case) are in the&lt;br&gt;
repo. All example numbers are from the package's own deterministic demo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>evaluation</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a tiny tool to catch the metric trap from my last post</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Mon, 08 Jun 2026 15:16:40 +0000</pubDate>
      <link>https://dev.to/elvisyao007/i-built-a-tiny-tool-to-catch-the-metric-trap-from-my-last-post-55ed</link>
      <guid>https://dev.to/elvisyao007/i-built-a-tiny-tool-to-catch-the-metric-trap-from-my-last-post-55ed</guid>
      <description>&lt;p&gt;In my last post I found that 33/100 "grounded-but-wrong" answers in my RAG&lt;br&gt;
eval were a measurement artifact — not real failures. The culprit: proportion&lt;br&gt;
recall with a relevant-doc-count denominator silently breaks on multi-answer&lt;br&gt;
datasets when k is small.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8h2oncvulmoxfcgdh569.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8h2oncvulmoxfcgdh569.png" alt=" " width="799" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I packaged the diagnostic into a standalone tool: &lt;strong&gt;eval-sanity&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;eval-sanity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes the retrieved and relevant doc IDs you already have and tells you&lt;br&gt;
whether your recall metric is &lt;em&gt;structurally capable&lt;/em&gt; of saying what you think&lt;br&gt;
it says — before you trust the number on your dashboard.&lt;/p&gt;

&lt;p&gt;What it checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;oracle ceiling&lt;/strong&gt;: the best any retriever could score at your k&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;threshold reachability&lt;/strong&gt;: how many queries can never clear your threshold,
regardless of retrieval quality&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hit@k vs proportion divergence&lt;/strong&gt;: where the two metrics disagree&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero dependencies. No models. No judge calls. Pure deterministic math.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://github.com/elvisyao007/eval-sanity" rel="noopener noreferrer"&gt;github.com/elvisyao007/eval-sanity&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The motivation story is in the &lt;a href="[https://dev.to/elvisyao007/the-33-grounded-but-wrong-answers-were-a-metric-artifact-how-id-based-context-recall-lies-on-ghg]"&gt;blog post that found the artifact&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>evaluation</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The 33 'grounded-but-wrong' answers were a metric artifact: how ID-based context recall lies on multi-answer datasets</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Mon, 08 Jun 2026 11:46:41 +0000</pubDate>
      <link>https://dev.to/elvisyao007/the-33-grounded-but-wrong-answers-were-a-metric-artifact-how-id-based-context-recall-lies-on-ghg</link>
      <guid>https://dev.to/elvisyao007/the-33-grounded-but-wrong-answers-were-a-metric-artifact-how-id-based-context-recall-lies-on-ghg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction note:&lt;/strong&gt; This post corrects a claim I made in two earlier posts. I previously reported "33/100 grounded-but-wrong" answers in my JQaRA RAG eval and framed them as a retrieval/generation failure worth fixing with hybrid search. After decomposing the numbers, &lt;strong&gt;zero of those 33 were real failures&lt;/strong&gt; — all 33 are an artifact of how I measured context recall. This post shows exactly how the metric misled me, because the failure mode is one a lot of people are exposed to without knowing it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;My pipeline used an &lt;strong&gt;ID-based context recall&lt;/strong&gt;: &lt;code&gt;|retrieved ∩ relevant_doc_ids| / |relevant_doc_ids|&lt;/code&gt;. This is a real, widely-used variant (it matches RAGAS's &lt;code&gt;NonLLMContextRecall&lt;/code&gt; / &lt;code&gt;IDBasedContextRecall&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;I flagged answers as &lt;strong&gt;grounded-but-wrong&lt;/strong&gt; when &lt;code&gt;faithfulness ≥ 0.8 AND context_recall &amp;lt; 0.5&lt;/code&gt;. 33/100 queries got flagged.&lt;/li&gt;
&lt;li&gt;When I checked &lt;strong&gt;hit@5&lt;/strong&gt; (did at least one relevant doc make it into the top-5 context?), it was &lt;strong&gt;98/100&lt;/strong&gt;. Retrieval was not failing.&lt;/li&gt;
&lt;li&gt;The 33 flagged queries had a mean of &lt;strong&gt;16 relevant documents each&lt;/strong&gt;; 28 of 33 had more than 10. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feejukqjq577js06gzeyg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feejukqjq577js06gzeyg.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;k=5&lt;/code&gt;, the maximum possible ID-based recall is &lt;code&gt;5/16 ≈ 0.31&lt;/code&gt; — &lt;strong&gt;below the 0.5 threshold even for a perfect retriever.&lt;/strong&gt; The threshold was unreachable by construction.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6vsxhg98e0tjzrd943t8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6vsxhg98e0tjzrd943t8.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The only 2 genuine retrieval misses (hit@5 = 0) scored faithfulness = 0.0 and were correctly &lt;em&gt;not&lt;/em&gt; flagged as grounded-but-wrong. The pipeline worked; the metric definition didn't fit the dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson isn't "RAGAS is broken." It's that &lt;strong&gt;a recall metric whose denominator is the relevant-document count silently breaks when the dataset has many relevant docs per query and your &lt;code&gt;k&lt;/code&gt; is small&lt;/strong&gt; — and that combination is easy to walk into.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I claimed earlier
&lt;/h2&gt;

&lt;p&gt;In two earlier posts I reported a JQaRA evaluation of a local RAG stack (ruri-v3 retriever, qwen3:32b generator, gemma4:31b judge). One headline number was &lt;strong&gt;33/100 grounded-but-wrong&lt;/strong&gt;: answers the judge rated highly faithful to their retrieved context, yet whose retrieved context appeared to be missing the relevant material. I read that as "the model is confidently using incomplete context," and I lined up a hybrid (BM25 + dense) experiment to fix the retrieval side.&lt;/p&gt;

&lt;p&gt;That story was wrong. Here's how I found out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate that saved the experiment
&lt;/h2&gt;

&lt;p&gt;Before running hybrid, I computed a ceiling: on a fixed 100-candidate reranking dataset like JQaRA, context recall can't exceed "how often the relevant docs are even in the candidate set." The gap looked large (+0.20), so the gate said "continue."&lt;/p&gt;

&lt;p&gt;But the rank distribution was suspicious. Among queries where a relevant doc was in the candidate set, the dense retriever already ranked it at &lt;strong&gt;p50 = 1, p90 = 2&lt;/strong&gt;. If relevant docs are almost always at the very top, where is a +0.20 recall gap coming from?&lt;/p&gt;

&lt;p&gt;So instead of running hybrid, I decomposed the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decomposition
&lt;/h2&gt;

&lt;p&gt;Two numbers ended the experiment before it started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;hit@5 = 98/100.&lt;/strong&gt; For 98 of 100 queries, at least one relevant document was in the top-5 context handed to the generator. Retrieval was essentially doing its job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mean relevant docs among the 33 flagged queries = 16.0&lt;/strong&gt;, with 28 of 33 above 10 relevant docs.&lt;/p&gt;

&lt;p&gt;Now the metric definition collides with the dataset. ID-based context recall is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;context_recall = |retrieved_doc_ids ∩ relevant_doc_ids| / |relevant_doc_ids|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;k=5&lt;/code&gt; and 16 relevant docs, the &lt;em&gt;best achievable&lt;/em&gt; value is &lt;code&gt;5/16 ≈ 0.31&lt;/code&gt;. The grounded-but-wrong flag fires when context_recall &amp;lt; 0.5. &lt;strong&gt;A perfect retriever scores 0.31 here and gets flagged anyway.&lt;/strong&gt; The 0.5 threshold isn't measuring retrieval quality on these queries — it's measuring "does this query have more than ~10 relevant docs," which on JQaRA it usually does.&lt;/p&gt;

&lt;p&gt;Swap in hit@5 (≥1 relevant doc retrieved) as the recall signal and &lt;strong&gt;grounded-but-wrong drops from 33 to 0&lt;/strong&gt;. The 2 queries that genuinely retrieved nothing relevant scored faithfulness 0.0 — the judge caught them, and they were never in the 33. The pipeline was working the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to walk into
&lt;/h2&gt;

&lt;p&gt;This isn't a RAGAS bug. ID-based / non-LLM context recall is a legitimate, documented metric, and on a single-answer dataset (one gold doc per query) the denominator is 1 and none of this happens. The trap is the &lt;strong&gt;interaction&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Denominator = relevant-doc count&lt;/strong&gt; (not "claims in the reference answer," which is RAGAS's default LLM-based variant)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many relevant docs per query&lt;/strong&gt; (JQaRA averages well above 10)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small k&lt;/strong&gt; (I used 5)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fixed threshold&lt;/strong&gt; (0.5) applied uniformly across queries with wildly different denominators&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each choice is individually reasonable. Together they manufacture a "failure" rate that tracks dataset structure, not system quality. If you picked an ID-based recall because it's deterministic and cheap (I did — no judge calls, fully reproducible), this is exactly the blind spot you inherit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match the metric to the dataset's answer multiplicity.&lt;/strong&gt; For multi-answer sets, a denominator that can exceed &lt;code&gt;k&lt;/code&gt; makes proportion-style recall uninterpretable. Use hit@k for "did we get anything relevant," and reserve proportion recall for when &lt;code&gt;k ≥ typical relevant-doc count&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make thresholds relative, not absolute.&lt;/strong&gt; &lt;code&gt;context_recall &amp;lt; 0.5&lt;/code&gt; means something different when the ceiling is 1.0 vs. 0.31. Normalize against the achievable ceiling, or threshold on hit@k instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanity-check any "failure" cohort against an oracle.&lt;/strong&gt; If a perfect retriever would also be flagged, the flag is about your metric, not your system. This single check would have caught it before I wrote the first post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The correction
&lt;/h2&gt;

&lt;p&gt;I've added update notes to the two earlier posts pointing here. To be precise about what changed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqxery4xhybgxarae40wd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqxery4xhybgxarae40wd.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hybrid experiment is archived, not run — its motivation no longer exists. I'd rather publish that than run an experiment to make a flawed number look better.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All numbers are recomputed directly from the eval output JSON; the analysis script and decision log are in the repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>evaluation</category>
      <category>llm</category>
      <category>retrieval</category>
    </item>
    <item>
      <title>faithfulness spread = 0.000: what self-grading RAG eval actually looks like</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Sun, 07 Jun 2026 18:22:53 +0000</pubDate>
      <link>https://dev.to/elvisyao007/faithfulness-spread-0000-what-self-grading-rag-eval-actually-looks-like-35mj</link>
      <guid>https://dev.to/elvisyao007/faithfulness-spread-0000-what-self-grading-rag-eval-actually-looks-like-35mj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (2026-06):&lt;/strong&gt; The grounded-but-wrong counts in this post (48/100&lt;br&gt;
self-eval, 33/100 independent judge) are affected by a metric-definition&lt;br&gt;
issue I found later — see &lt;a href="[https://dev.to/elvisyao007/the-33-grounded-but-wrong-answers-were-a-metric-artifact-how-id-based-context-recall-lies-on-ghg]"&gt;blog-03&lt;/a&gt; for the full analysis.&lt;br&gt;
Short version: the 0.5 threshold on ID-based context recall is structurally&lt;br&gt;
unreachable on multi-answer queries with k=5, so those absolute counts reflect&lt;br&gt;
dataset structure more than system quality. The self-eval vs independent-judge&lt;br&gt;
&lt;em&gt;methodology&lt;/em&gt; point still stands; only the absolute numbers need this caveat.&lt;br&gt;
Original text unchanged below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;description: "I ran my RAG eval twice — once with the same model grading itself, once with an independent judge from a different family. Here's what changed, and why spread = 0.000 is the tell."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/elvisyao007"&gt;Last post&lt;/a&gt; I claimed something specific: faithfulness scored 0.67, but an independent judge found 33 of 100 answers were grounded in context and still factually wrong.&lt;/p&gt;

&lt;p&gt;A fair question: why trust that judge?&lt;/p&gt;

&lt;p&gt;I have a concrete answer, because I ran the eval twice. The first run used the same model for both generation and judging — self-grading. The second run used a completely different model family as the judge. Here are the numbers from both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The before and after
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz80cett7nope4mb2f85y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz80cett7nope4mb2f85y.png" alt=" " width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqne7iblbr3jmau4oe4x8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqne7iblbr3jmau4oe4x8.png" alt=" " width="800" height="472"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Self-judge (qwenj, same model)&lt;/th&gt;
&lt;th&gt;Independent judge (gemma4:31b)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;faithfulness mean&lt;/td&gt;
&lt;td&gt;0.7751&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.6662&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;faithfulness spread&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.0500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grounded-but-wrong&lt;/td&gt;
&lt;td&gt;48 / 100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;33 / 100&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the spread row. The self-judge returned a spread of exactly 0.0000 — not "near zero," literally zero. Every query returned an identical faithfulness distribution. The judge was not reading the answers. It was rubber-stamping.&lt;/p&gt;

&lt;p&gt;The independent judge returned a spread of 0.05. Small, but non-zero: the judge was actually discriminating between better and worse answers.&lt;/p&gt;

&lt;p&gt;Everything else follows from that single difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why spread = 0.000 is the tell
&lt;/h2&gt;

&lt;p&gt;A judge that is genuinely evaluating will find some answers more faithful than others — it will disagree with itself across queries. A judge that has collapsed into rubber-stamping gives the same score to everything, because it has stopped reading. The variance goes flat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-zero spread is necessary but not sufficient&lt;/strong&gt; evidence of a good judge. A random judge also has spread. The spread check rules out the worst case — the complete collapse of judgment — not all cases. The gold standard is still human-label agreement on a sampled subset. But zero spread is an immediate red flag that something is wrong.&lt;/p&gt;

&lt;p&gt;The self-judge gave faithfulness 0.7751. That number is almost certainly inflated. When the same model generates an answer and then evaluates it, it tends to recognize its own phrasing and reward it. The technical term is self-enhancement bias — a documented effect that scales with model capability and persists even when authorship is hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  What inflated faithfulness does downstream
&lt;/h2&gt;

&lt;p&gt;Faithfulness inflation doesn't just change one number. It cascades.&lt;/p&gt;

&lt;p&gt;The self-judge scored more answers as "faithful" (inflated 0.7751 vs 0.6662). A larger faithful pool means more opportunities to be grounded-but-wrong. That's why the self-judge found 48 grounded-but-wrong answers while the independent judge found 33: the self-judge was counting answers as "grounded" that the independent judge correctly did not. False positives in faithfulness create false positives in grounded-but-wrong.&lt;/p&gt;

&lt;p&gt;The independent judge, being more accurate about faithfulness, shrank both numbers toward reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I built the independent judge
&lt;/h2&gt;

&lt;p&gt;Three things that matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-family split.&lt;/strong&gt; My generator is &lt;code&gt;qwen3:32b&lt;/code&gt; (Qwen, Alibaba). My judge is &lt;code&gt;gemma4:31b&lt;/code&gt; (Gemma, Google). Different model, different family, different training lineage. Self-preference bias leaks across a model family, not just an exact checkpoint — using a different Qwen checkpoint as the judge would still be suspect. The key is the family boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ground-truth anchor.&lt;/strong&gt; Self-preference bites hardest on subjective tasks where there's no right answer to compare against. JQaRA ships gold answers. My correctness check asks the judge to compare the model's answer against the gold answer — not to issue a free-floating opinion. Anchoring on a reference shrinks the surface where bias can hide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The on-prem cost.&lt;/strong&gt; On a single RTX 5090 with 32 GB VRAM, &lt;code&gt;qwen3:32b&lt;/code&gt; (20 GB) and &lt;code&gt;gemma4:31b&lt;/code&gt; (19 GB) can't both be resident at the same time. I had to build a two-pass architecture: all generation first, then explicit VRAM unload, then all judging. This also required routing around the OpenAI-compat endpoint — thinking-capable models exhaust &lt;code&gt;max_tokens&lt;/code&gt; with reasoning tokens before emitting content, so I used Ollama's native &lt;code&gt;/api/chat&lt;/code&gt; with &lt;code&gt;think=false&lt;/code&gt;. None of this is hard, but it's the operational reality of doing this properly on-prem, and it's the kind of friction that makes most people default to self-judging in a single pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being honest about the limits
&lt;/h2&gt;

&lt;p&gt;Non-zero spread rules out rubber-stamping. It doesn't prove the judge is calibrated. For that, you need to hand-label a sample — grade 30–50 answers yourself and measure how often the judge agrees. I haven't published that calibration for this run yet. The spread check is a fast sanity gate, not the finish line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to gate RAG eval on
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An independent judge — different family, not just different checkpoint.&lt;/strong&gt; Self-judging numbers are theater.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ground truth where it exists.&lt;/strong&gt; A reference answer reduces the bias surface more than any prompting trick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spread as a sanity check.&lt;/strong&gt; Report it alongside the mean. Zero spread = stop, something is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human-label calibration on a sample&lt;/strong&gt; before you trust the judge in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The self-judging run gave a clean-looking 0.77 faithfulness with zero spread. The independent run gave 0.67 with 0.05 spread, and found 15 fewer grounded-but-wrong answers. The real system was worse than the self-judge claimed and better-characterized than the inflated number suggested. The 0.67 is more credible precisely because it's lower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;The full run — both phases, infrastructure fixes, raw scores — is here: &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm" rel="noopener noreferrer"&gt;github.com/elvisyao007/eval-driven-llm&lt;/a&gt;&lt;/strong&gt;. Next I'm going after &lt;code&gt;context_recall = 0.41&lt;/code&gt; with hybrid retrieval, judged by the same independent setup. Following the build in public.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>My RAG's faithfulness was 0.67. 1 in 3 answers were still wrong.</title>
      <dc:creator>elvisyao007</dc:creator>
      <pubDate>Sun, 07 Jun 2026 17:02:51 +0000</pubDate>
      <link>https://dev.to/elvisyao007/my-rags-faithfulness-was-067-1-in-3-answers-were-still-wrong-31f3</link>
      <guid>https://dev.to/elvisyao007/my-rags-faithfulness-was-067-1-in-3-answers-were-still-wrong-31f3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (2026-06):&lt;/strong&gt; A later analysis showed the "33/100 grounded-but-wrong"&lt;br&gt;
figure in this post is a &lt;strong&gt;metric artifact, not a real failure&lt;/strong&gt;. My ID-based&lt;br&gt;
context recall used the relevant-document count as its denominator; on JQaRA&lt;br&gt;
(~16 relevant docs/query average) with k=5 and a 0.5 threshold, even a perfect&lt;br&gt;
retriever scores below 0.5 and gets flagged. hit@5 was actually 98/100.&lt;br&gt;
Full breakdown: &lt;a href="[https://dev.to/elvisyao007/the-33-grounded-but-wrong-answers-were-a-metric-artifact-how-id-based-context-recall-lies-on-ghg]"&gt;What "grounded-but-wrong" actually meant — and why I was&lt;br&gt;
measuring it wrong&lt;/a&gt;. Original text below is unchanged.&lt;/p&gt;
&lt;h2&gt;
  
  
  description: "An on-prem JQaRA eval. Reranking nudged P@1 but the system was still wrong a third of the time. Why faithfulness alone is a trap, and what to gate on instead."
&lt;/h2&gt;
&lt;/blockquote&gt;

&lt;p&gt;I built a small Japanese RAG system, ran it entirely on my own hardware (RTX 5090, Ollama), and evaluated it with an &lt;strong&gt;independent judge model&lt;/strong&gt; instead of letting the generator grade its own homework.&lt;/p&gt;

&lt;p&gt;Two things surprised me, and they're connected:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Adding a reranker — the move everyone reaches for first — barely moved the needle.&lt;/li&gt;
&lt;li&gt;My faithfulness score looked acceptable (0.67), yet &lt;strong&gt;33 out of 100 answers were grounded in the retrieved context and still factually wrong&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post is about why those two facts are the same story, and why a faithfulness gate alone would have shipped a system that's wrong a third of the time without ever flagging it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reranking improved P@1 by +1.3 points but &lt;em&gt;lowered&lt;/em&gt; Recall@10.&lt;/strong&gt; It reorders what retrieval already found; it can't retrieve what retrieval missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real bottleneck was recall&lt;/strong&gt; (&lt;code&gt;context_recall = 0.41&lt;/code&gt;): the evidence needed to answer often wasn't retrieved at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;faithfulness = 0.67&lt;/code&gt; is a trap.&lt;/strong&gt; Faithfulness measures whether an answer is consistent with the retrieved context — &lt;em&gt;not&lt;/em&gt; whether it's correct. An answer grounded in wrong-but-retrieved context scores as faithful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An independent correctness judge found 33/100 "grounded-but-wrong" answers&lt;/strong&gt; — confidently wrong, fully grounded, invisible to faithfulness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lesson:&lt;/strong&gt; faithfulness is necessary, not sufficient. Gate on answer-correctness + context_recall, and stop reaching for a reranker when recall is your problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The setup (so you can trust the numbers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Benchmark&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://huggingface.co/datasets/hotchpotch/JQaRA" rel="noopener noreferrer"&gt;JQaRA&lt;/a&gt; (じゃくら) — Japanese QA-for-retrieval, built on the JAQKET quiz set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval eval&lt;/td&gt;
&lt;td&gt;1,667 queries, deterministic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation eval&lt;/td&gt;
&lt;td&gt;100 queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generator&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3:32b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Judge&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gemma4:31b&lt;/code&gt; — &lt;strong&gt;a different model from the generator&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware&lt;/td&gt;
&lt;td&gt;single RTX 5090, on-prem, Ollama&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzwq1mjj56q3c2anhol4r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzwq1mjj56q3c2anhol4r.png" alt=" " width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The judge being a &lt;em&gt;different&lt;/em&gt; model matters, and I'll come back to why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 1: the obvious move — add a reranker
&lt;/h2&gt;

&lt;p&gt;The standard RAG upgrade path: dense retrieval is your first stage, a cross-encoder reranker is your second. So I added one and re-ran retrieval.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Dense&lt;/th&gt;
&lt;th&gt;Dense + rerank&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;P@1&lt;/td&gt;
&lt;td&gt;0.8308&lt;/td&gt;
&lt;td&gt;0.8440&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.0132&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recall@10&lt;/td&gt;
&lt;td&gt;0.5738&lt;/td&gt;
&lt;td&gt;0.5634&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−0.0104&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa9tsovscvj2y4dbzs99l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa9tsovscvj2y4dbzs99l.png" alt=" " width="800" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read that carefully. The reranker did exactly what a reranker does: it &lt;strong&gt;sharpened the top of the list&lt;/strong&gt; (P@1 up — the single best document lands at rank 1 more often) while &lt;strong&gt;slightly demoting some relevant docs out of the top 10&lt;/strong&gt; (Recall@10 down). That's a precision-for-recall trade, not a free win.&lt;/p&gt;

&lt;p&gt;And here's the thing that should give you pause: if your generator reads more than the top result — top-5, top-10 — that recall drop can &lt;em&gt;hurt&lt;/em&gt; downstream answers even as P@1 improves. The metric you celebrate isn't the metric that feeds your generator.&lt;/p&gt;

&lt;p&gt;The deeper problem: &lt;strong&gt;a reranker reorders the candidate set. It cannot conjure a document that dense retrieval never surfaced.&lt;/strong&gt; Which brings us to the number that actually mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: the metric I trusted too much
&lt;/h2&gt;

&lt;p&gt;I moved to generation eval expecting faithfulness to be the headline. It came back at &lt;strong&gt;0.6662&lt;/strong&gt;. Mediocre, but the kind of number you squint at and think "okay-ish, ship the next iteration."&lt;/p&gt;

&lt;p&gt;That instinct is the trap.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;What it actually tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;faithfulness&lt;/td&gt;
&lt;td&gt;0.6662&lt;/td&gt;
&lt;td&gt;"Looks okay" — and is dangerously incomplete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;faithfulness spread&lt;/td&gt;
&lt;td&gt;0.0500&lt;/td&gt;
&lt;td&gt;Non-zero → the judge is discriminating, not rubber-stamping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;context_recall&lt;/td&gt;
&lt;td&gt;0.4062&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The real bottleneck — evidence often wasn't retrieved&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grounded-but-wrong&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;33 / 100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The failures faithfulness structurally cannot see&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fptk8lav9uvgtqzngdfwj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fptk8lav9uvgtqzngdfwj.png" alt=" " width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faithfulness measures consistency with the retrieved context, not correctness against ground truth.&lt;/strong&gt; An answer that faithfully reports a wrong-but-retrieved passage is, by definition, &lt;em&gt;faithful&lt;/em&gt;. So a grounded-but-wrong answer doesn't lower your faithfulness score — it sits in the "good" portion of it. Optimize for faithfulness and you are partly optimizing toward confident, well-grounded, wrong answers.&lt;/p&gt;

&lt;p&gt;To catch this you need a separate question: &lt;em&gt;is the answer actually correct?&lt;/em&gt; I ran that as an independent correctness check against JQaRA's gold answers. The essence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Not "is the answer supported by the context?" (faithfulness)
# But "is the answer correct vs the gold answer?" (correctness)

judge(question, model_answer, gold_answer) -&amp;gt; {correct | incorrect}
grounded_but_wrong = faithful(answer) AND NOT correct(answer)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;33 of 100 answers were faithful and wrong at the same time.&lt;/strong&gt; A faithfulness gate would have waved every one of them through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happened: recall was the leak
&lt;/h2&gt;

&lt;p&gt;The three numbers line up into one causal chain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;context_recall = 0.41&lt;/code&gt; → for most queries, the passage that actually answers the question &lt;strong&gt;wasn't in the retrieved context&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The generator answers anyway, grounding itself in whatever &lt;em&gt;was&lt;/em&gt; retrieved — confidently, fluently.&lt;/li&gt;
&lt;li&gt;That answer is faithful (grounded in retrieved text) and wrong (the retrieved text didn't contain the answer). → &lt;strong&gt;grounded-but-wrong&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;context_recall&lt;/code&gt; is the leading indicator, &lt;code&gt;grounded-but-wrong&lt;/code&gt; is the lagging confirmation, and &lt;code&gt;faithfulness&lt;/code&gt; is the misleading number in the middle that papers over both.&lt;/p&gt;

&lt;p&gt;And now Act 1 and Act 2 close into the same loop: &lt;strong&gt;I reached for a reranker, but reranking optimizes the wrong stage when recall is your bottleneck.&lt;/strong&gt; No amount of reordering fixes a document that was never retrieved. The right lever was upstream — chunking, embedding model, hybrid (lexical + dense) retrieval, query expansion — not a cross-encoder polishing a list that's missing the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on judge independence (why the spread matters)
&lt;/h2&gt;

&lt;p&gt;If you let a model grade its own outputs, it tends to like them — LLM-as-judge has a well-documented self-preference bias, and a self-judging setup often produces near-1.0 scores with almost no variance. That near-zero spread is the tell.&lt;/p&gt;

&lt;p&gt;My judge (&lt;code&gt;gemma4:31b&lt;/code&gt;) is a different model from the generator (&lt;code&gt;qwen3:32b&lt;/code&gt;), and the faithfulness spread came back at &lt;strong&gt;0.05 — non-zero&lt;/strong&gt;. Small, but it's the proof that the judge is actually discriminating between good and bad answers rather than rubber-stamping. If you take one process habit from this post, take this one: &lt;strong&gt;never let the model that wrote the answer be the model that scores it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually gate a production RAG on
&lt;/h2&gt;

&lt;p&gt;Most "RAG eval" stops at faithfulness because it's the easiest to compute. That's exactly why it's the wrong place to stop. The gate I'd ship behind:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Answer-correctness vs ground truth&lt;/strong&gt; — the metric that actually catches grounded-but-wrong. Non-negotiable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;context_recall&lt;/strong&gt; — your leading indicator. If this is low, fix retrieval before you touch the generator or reach for a reranker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;faithfulness&lt;/strong&gt; — keep it, but only as a hallucination guard &lt;em&gt;on top of&lt;/em&gt; correctness, never as a stand-in for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An independent judge&lt;/strong&gt; — different model, and watch the score variance to confirm it isn't rubber-stamping.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A demo proves the happy path works. A system you'd put in front of a business has to know — and &lt;em&gt;prove with numbers&lt;/em&gt; — how often it's confidently wrong. The gap between those two is exactly this eval discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Code, the eval harness, and the raw run are here: &lt;strong&gt;&lt;a href="https://github.com/elvisyao007/eval-driven-llm" rel="noopener noreferrer"&gt;github.com/elvisyao007/eval-driven-llm&lt;/a&gt;&lt;/strong&gt;. Next I'm going after that &lt;code&gt;context_recall = 0.41&lt;/code&gt; — hybrid retrieval and chunking experiments, measured the same way. Following the build in public.&lt;/p&gt;

&lt;p&gt;If you run RAG eval and &lt;em&gt;only&lt;/em&gt; look at faithfulness, go check your grounded-but-wrong rate. I'd bet it's not zero.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
