A RAG (Retrieval-Augmented Generation) pipeline with a hand-built evaluation harness — not just a chatbot demo, but a system for measuring where and why retrieval and generation fail, including adversarial testing and a validated scoring methodology.
TypeScript
0
1 commits
updated Sep 10, 2026
Where does your RAG pipeline actually break?
A RAG (Retrieval-Augmented Generation) pipeline with a hand-built evaluation harness — not just a chatbot demo, but a system for measuring where and why retrieval and generation fail, including adversarial testing and a validated scoring methodology.
Most RAG tutorials stop at "it works on my example query." This project starts there and keeps going: gold-standard evaluation sets, retrieval metrics computed from scratch, hallucination/faithfulness scoring, poisoned- context adversarial tests, and a documented case where the evaluation harness itself was found to have a bug — diagnosed, fixed, and validated.
pgvector — vector similarity search and relational
data (gold Q&A sets, eval runs, experiment logs) in a single databasebge-small via @xenova/transformers, run locallypgvector cosine distance (<=>), HNSW indexopenai/gpt-oss-120b), OpenAI-compatible client,
hand-written rate limiting and backoffpgvector| Strategy | Chunk count | Avg size |
|---|---|---|
| Naive splitter (256 chars) | 95,426 | 37 chars |
| Separator-aware packing (256 target) | 23,509 | 157 chars |
The naive splitter fragmented text into sub-sentence pieces, producing embeddings that captured incomplete ideas and hurt retrieval quality. Separator-aware packing respects natural boundaries (sentences, paragraphs), consistently landing under the target size — a property of boundary-aware splitting, not a bug.
hit@1 / gold-in-window@5, by difficulty:
| Config | Easy | Medium | Compound |
|---|---|---|---|
| Dense only | 0.992 / 0.996 | 0.417 / 1.000 | 1.000 / 1.000 |
| BM25 only | 0.869 / 0.941 | 0.583 / 1.000 | 0.500 / 1.000 |
| Hybrid (RRF, k=60, 1:1) | 0.996 / 0.996 | 0.583 / 1.000 | 0.900 / 1.000 |
| Hybrid (RRF, k=90, dense 2:1) | 0.996 / 1.000 | 0.583 / 1.000 | 1.000 / 1.000 |
Key finding: dense embeddings under-retrieve rare, structurally-templated entries (e.g. chemical elements) at rank 1 — medium-difficulty questions topped out at 41.7% hit@1 on dense alone. Adding BM25 via RRF fusion closed this gap entirely on the lexical side; a dense-biased fusion weighting (2:1) additionally recovered a compound-question regression introduced by naive 1:1 fusion, without sacrificing the medium-tier gain.
The more important finding: gold-in-window@5 = 1.000 across every
retrieval arm, including plain dense, for both medium and compound
questions. The correct chunk was always inside the top-5 context window
actually passed to the generator — meaning the 41.7% hit@1 number, while a
real retrieval-ranking weakness, never once produced an empty context window
for the LLM. hit@1 mattered for measurement, not for what the model actually saw.
Controlled probe, same 22 questions, constant scorer:
| Run | Context | Medium | Compound | Grounded-correct |
|---|---|---|---|---|
| Original run (rescored) | dense | 9/12 | 5/10 | 14/22 |
| Control (re-run, same config) | dense | 11/12 | 6/10 | 17/22 |
| Treatment | tuned hybrid | 11/12 | 7/10 | 18/22 |
Re-running the identical configuration twice produced a +3 swing purely from generator nondeterminism at temperature 0. The hybrid treatment's +1 gain over the control is inside that noise floor.
Decision: hybrid retrieval was not adopted as the generation source. It remains the best-performing retrieval configuration on record, but the measured generation-quality gain did not clear the noise bar established by the control run — a real result is not the same as a number that happens to be higher.
Mid-project, a verdict on one question ("radium") looked like a model hallucination. Investigation traced it to the evaluation harness itself: the answer-matching function required exact phrase order, but the gold answer was a legitimate "X and Y" construction where English allows either order. The fix was scoped narrowly — a relaxed pass that only triggers when the gold answer contains a conjunction, allows phrase-level (not word-level) reordering, and can only ever upgrade a wrong verdict to correct, never the reverse.
The fix was validated, not assumed: a reconstruction script rebuilt the exact context each answer was originally scored against from stored data, recomputed faithfulness fresh, and confirmed byte-identical agreement with the original stored scores across all 292 rows — proving the fix didn't silently change unrelated results.
Result: exactly 2 verdicts changed in the entire dataset, both the same question. The corrected finding reversed the original story — the model had been correctly resisting the poisoned claim all along; the scorer, not the model, had been wrong.
A separate, earlier claim ("a retrieval miss on a Potassium question") was later found to have no corresponding object in the dataset and was retracted from the project's findings once discovered.
ragcheck/
├── server/
│ ├── ingestion/ (chunking, embedding)
│ ├── retrieval/ (pgvector + BM25 + RRF fusion)
│ ├── eval/ (metrics: hit-rate.ts, mrr.ts, faithfulness.ts, rescore.ts)
│ └── db/ (Prisma schema, migrations)
├── client/ (React dashboard for results)
├── data/ (gold Q&A set, raw documents)
└── DECISIONS.md (running log of every architectural decision and why)
1 commits
TypeScript
96.7%
JavaScript
2.2%
Batchfile
1.2%
A RAG (Retrieval-Augmented Generation) pipeline with a hand-built evaluation harness — not just a chatbot demo, but a system for measuring where and why retrieval and generation fail, including adversarial testing and a validated scoring methodology.
TypeScript
0
1 commits
updated Sep 10, 2026
Where does your RAG pipeline actually break?
A RAG (Retrieval-Augmented Generation) pipeline with a hand-built evaluation harness — not just a chatbot demo, but a system for measuring where and why retrieval and generation fail, including adversarial testing and a validated scoring methodology.
Most RAG tutorials stop at "it works on my example query." This project starts there and keeps going: gold-standard evaluation sets, retrieval metrics computed from scratch, hallucination/faithfulness scoring, poisoned- context adversarial tests, and a documented case where the evaluation harness itself was found to have a bug — diagnosed, fixed, and validated.
pgvector — vector similarity search and relational
data (gold Q&A sets, eval runs, experiment logs) in a single databasebge-small via @xenova/transformers, run locallypgvector cosine distance (<=>), HNSW indexopenai/gpt-oss-120b), OpenAI-compatible client,
hand-written rate limiting and backoffpgvector| Strategy | Chunk count | Avg size |
|---|---|---|
| Naive splitter (256 chars) | 95,426 | 37 chars |
| Separator-aware packing (256 target) | 23,509 | 157 chars |
The naive splitter fragmented text into sub-sentence pieces, producing embeddings that captured incomplete ideas and hurt retrieval quality. Separator-aware packing respects natural boundaries (sentences, paragraphs), consistently landing under the target size — a property of boundary-aware splitting, not a bug.
hit@1 / gold-in-window@5, by difficulty:
| Config | Easy | Medium | Compound |
|---|---|---|---|
| Dense only | 0.992 / 0.996 | 0.417 / 1.000 | 1.000 / 1.000 |
| BM25 only | 0.869 / 0.941 | 0.583 / 1.000 | 0.500 / 1.000 |
| Hybrid (RRF, k=60, 1:1) | 0.996 / 0.996 | 0.583 / 1.000 | 0.900 / 1.000 |
| Hybrid (RRF, k=90, dense 2:1) | 0.996 / 1.000 | 0.583 / 1.000 | 1.000 / 1.000 |
Key finding: dense embeddings under-retrieve rare, structurally-templated entries (e.g. chemical elements) at rank 1 — medium-difficulty questions topped out at 41.7% hit@1 on dense alone. Adding BM25 via RRF fusion closed this gap entirely on the lexical side; a dense-biased fusion weighting (2:1) additionally recovered a compound-question regression introduced by naive 1:1 fusion, without sacrificing the medium-tier gain.
The more important finding: gold-in-window@5 = 1.000 across every
retrieval arm, including plain dense, for both medium and compound
questions. The correct chunk was always inside the top-5 context window
actually passed to the generator — meaning the 41.7% hit@1 number, while a
real retrieval-ranking weakness, never once produced an empty context window
for the LLM. hit@1 mattered for measurement, not for what the model actually saw.
Controlled probe, same 22 questions, constant scorer:
| Run | Context | Medium | Compound | Grounded-correct |
|---|---|---|---|---|
| Original run (rescored) | dense | 9/12 | 5/10 | 14/22 |
| Control (re-run, same config) | dense | 11/12 | 6/10 | 17/22 |
| Treatment | tuned hybrid | 11/12 | 7/10 | 18/22 |
Re-running the identical configuration twice produced a +3 swing purely from generator nondeterminism at temperature 0. The hybrid treatment's +1 gain over the control is inside that noise floor.
Decision: hybrid retrieval was not adopted as the generation source. It remains the best-performing retrieval configuration on record, but the measured generation-quality gain did not clear the noise bar established by the control run — a real result is not the same as a number that happens to be higher.
Mid-project, a verdict on one question ("radium") looked like a model hallucination. Investigation traced it to the evaluation harness itself: the answer-matching function required exact phrase order, but the gold answer was a legitimate "X and Y" construction where English allows either order. The fix was scoped narrowly — a relaxed pass that only triggers when the gold answer contains a conjunction, allows phrase-level (not word-level) reordering, and can only ever upgrade a wrong verdict to correct, never the reverse.
The fix was validated, not assumed: a reconstruction script rebuilt the exact context each answer was originally scored against from stored data, recomputed faithfulness fresh, and confirmed byte-identical agreement with the original stored scores across all 292 rows — proving the fix didn't silently change unrelated results.
Result: exactly 2 verdicts changed in the entire dataset, both the same question. The corrected finding reversed the original story — the model had been correctly resisting the poisoned claim all along; the scorer, not the model, had been wrong.
A separate, earlier claim ("a retrieval miss on a Potassium question") was later found to have no corresponding object in the dataset and was retracted from the project's findings once discovered.
ragcheck/
├── server/
│ ├── ingestion/ (chunking, embedding)
│ ├── retrieval/ (pgvector + BM25 + RRF fusion)
│ ├── eval/ (metrics: hit-rate.ts, mrr.ts, faithfulness.ts, rescore.ts)
│ └── db/ (Prisma schema, migrations)
├── client/ (React dashboard for results)
├── data/ (gold Q&A set, raw documents)
└── DECISIONS.md (running log of every architectural decision and why)
1 commits
TypeScript
96.7%
JavaScript
2.2%
Batchfile
1.2%