mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
Add a new ai test category with three subcategories: vector-search, llm-integration, and rag. Each subcategory registers 10 tests, for 30 tests total. Vector-search tests cover cosine-vs-dot equivalence, recall@k, HNSW efSearch tradeoffs, product-quantization memory math, ANN pre/post filtering, Reciprocal Rank Fusion, distance-to-similarity conversion, near-duplicate thresholds, and index/embedding-dimension tradeoffs. LLM-integration tests cover OpenAI-compatible field semantics, token budget math for reasoning models, 429 retry/backoff, context-overflow strategy, tool-call handling, role placement, the temperature=0 determinism caveat, SSE stream parsing, embedding batch math, and stop-sequence behavior. RAG tests cover chunk-size tradeoffs, reranker placement, retrieval failure modes, citation grounding, context selection versus stuffing, index staleness, evaluation metric choice, hallucination mitigation ordering, multi-hop decomposition, and pre-assembly deduplication. Every numeric test derives its expected value from an ai-prefixed helper or an existing mathutil/whitepapers helper, and each unit test independently recomputes the same value with plain arithmetic. New files only: ai.go, ai_vectorsearch.go, ai_llmintegration.go, ai_rag.go, and their _test.go siblings, plus a wiring test that calls registerAITests directly. catalog.go and catalog_test.go are untouched; the orchestrator wires registration in at integration.
380 lines
17 KiB
Go
380 lines
17 KiB
Go
package tests
|
|
|
|
import (
|
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
|
)
|
|
|
|
func registerAIRAGTests(r *testkit.Registry) {
|
|
r.Register(ragChunkSizeTradeoffTest())
|
|
r.Register(ragRerankerPlacementTest())
|
|
r.Register(ragRetrievalFailureModeTest())
|
|
r.Register(ragCitationGroundingTest())
|
|
r.Register(ragContextSelectionVsStuffingTest())
|
|
r.Register(ragIndexStalenessTest())
|
|
r.Register(ragEvalMetricChoiceTest())
|
|
r.Register(ragHallucinationMitigationOrderingTest())
|
|
r.Register(ragMultihopDecompositionTest())
|
|
r.Register(ragPreAssemblyDedupTest())
|
|
}
|
|
|
|
// ragChunkSizeTradeoffTest: pick the smallest chunk size that reliably
|
|
// holds a full fact without splitting it, given an explicit token-span
|
|
// constraint.
|
|
//
|
|
// ground truth: facts span up to 450 tokens. The 128-token option is
|
|
// smaller than 450, so it risks splitting a fact across two chunks. Both
|
|
// the 512-token and 2048-token options are large enough to hold a 450-
|
|
// token fact whole, but the larger option dilutes the embedded chunk with
|
|
// more unrelated surrounding text. The smallest viable (non-splitting)
|
|
// option is 512.
|
|
func ragChunkSizeTradeoffTest() testkit.Test {
|
|
prompt := `You are choosing a chunk size for a RAG index over dense
|
|
technical reference documents. A single self-contained fact in these
|
|
documents commonly spans up to 450 tokens; a chunk boundary that falls
|
|
inside a fact's span can leave a retrieved chunk missing part of that
|
|
fact. A chunk much larger than a fact's span dilutes the embedding with
|
|
unrelated surrounding text, lowering retrieval precision for queries about
|
|
that fact.
|
|
|
|
Three chunk-size options are available: 128 tokens, 512 tokens, and 2048
|
|
tokens.
|
|
|
|
Which of these is the smallest option that reliably holds a full 450-token
|
|
fact without ever splitting it across two chunks? Respond with only a
|
|
JSON object: {"chunk_size_tokens":128|512|2048}`
|
|
|
|
return testkit.Test{
|
|
ID: "rag-chunk-size-tradeoff",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Pick the smallest chunk size (512) that reliably holds a 450-token fact whole without excess dilution.",
|
|
Prompt: prompt,
|
|
Eval: eval.JSONField("chunk_size_tokens", 512),
|
|
}
|
|
}
|
|
|
|
// ragRerankerPlacementTest: order the three RAG pipeline stages -
|
|
// retrieve, rerank, generate.
|
|
//
|
|
// ground truth: retrieval must run first to produce a broad candidate set
|
|
// from the index; a reranker (e.g. a cross-encoder) can only reorder
|
|
// candidates that retrieval already surfaced, so it must run second;
|
|
// generation consumes the reranker's narrowed, reordered candidates last.
|
|
func ragRerankerPlacementTest() testkit.Test {
|
|
prompt := `A RAG pipeline has these three stages, listed here in no
|
|
particular order:
|
|
- retrieve: run a broad search (vector and/or keyword) over the index to
|
|
produce an initial candidate set of chunks.
|
|
- rerank: use a more expensive, more accurate model (e.g. a cross-encoder)
|
|
to reorder a candidate set by relevance and narrow it to the best few.
|
|
- generate: have the LLM produce the final answer using the selected
|
|
chunks as context.
|
|
|
|
Give the correct pipeline order as a JSON array of the three stage names,
|
|
e.g. ["a","b","c"]. Respond with only the JSON array.`
|
|
|
|
return testkit.Test{
|
|
ID: "rag-reranker-placement",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Order the RAG pipeline stages: retrieve, rerank, generate.",
|
|
Prompt: prompt,
|
|
Eval: eval.JSONStringArrayEquals([]string{"retrieve", "rerank", "generate"}),
|
|
}
|
|
}
|
|
|
|
// ragRetrievalFailureModeCorpus is the inline single-document corpus for
|
|
// ragRetrievalFailureModeTest, chosen so the query shares no exact term
|
|
// with the relevant document.
|
|
const ragRetrievalFailureModeCorpus = `Document 1: "Regular automobile maintenance, including oil changes and
|
|
brake inspections, prevents most roadside breakdowns."`
|
|
|
|
// ragRetrievalFailureModeTest: identify that a purely keyword/exact-term
|
|
// search fails to retrieve a relevant document when the query uses
|
|
// synonyms with no literal term overlap, while semantic (embedding-based)
|
|
// search would succeed.
|
|
//
|
|
// ground truth: the query "car repair tips" shares zero exact terms with
|
|
// Document 1's text ("automobile", "maintenance", "oil changes", "brake
|
|
// inspections") - "car" vs "automobile" and "repair" vs "maintenance" are
|
|
// synonym pairs, not shared tokens. A keyword/exact-term search over these
|
|
// tokens finds no match and fails to retrieve this relevant document;
|
|
// semantic (embedding-based) search, which captures the synonym
|
|
// relationship, would retrieve it.
|
|
func ragRetrievalFailureModeTest() testkit.Test {
|
|
prompt := `Here is a single-document corpus:
|
|
|
|
` + ragRetrievalFailureModeCorpus + `
|
|
|
|
Query: "car repair tips"
|
|
|
|
Document 1 is genuinely relevant to this query, but one retrieval method
|
|
fails to retrieve it because the query and the document share no exact,
|
|
literal terms (only synonym pairs like "car"/"automobile" and
|
|
"repair"/"maintenance"). Which retrieval method is expected to fail here:
|
|
keyword-based (exact term matching, e.g. BM25) or semantic (embedding-
|
|
based)? Respond with only one word: keyword or semantic.`
|
|
|
|
return testkit.Test{
|
|
ID: "rag-retrieval-failure-mode",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Identify keyword (exact-term) search as the method that fails on a synonym-only query/document pair.",
|
|
Prompt: prompt,
|
|
Eval: eval.Equals("keyword"),
|
|
}
|
|
}
|
|
|
|
// ragCitationGroundingTest: state the core RAG grounding requirement -
|
|
// answers must be traceable to retrieved context, and the system must
|
|
// decline (rather than guess from parametric knowledge) when the context
|
|
// does not contain the answer.
|
|
func ragCitationGroundingTest() testkit.Test {
|
|
prompt := `State the core grounding requirement a RAG system's answer
|
|
generation must follow to avoid hallucination: what must every claim in
|
|
the answer be traceable back to, and what must the system do instead of
|
|
guessing when the retrieved context does not actually contain the
|
|
information needed to answer the question?`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("cite", "citation", "source", "quote", "quoted"),
|
|
eval.ContainsAny("insufficient", "does not contain", "doesn't contain", "cannot answer", "can't answer", "no relevant", "unable to find", "not found in the context", "not present in the context"),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "rag-citation-grounding",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "State that answers must cite retrieved context and the system must decline when context lacks the answer, rather than guessing.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|
|
|
|
// ragContextSelectionVsStuffingTest: choose relevance-filtered ("selective")
|
|
// context assembly over cramming every chunk the budget allows
|
|
// ("stuffed"), given a stated fact that low-relevance chunks degrade
|
|
// answer quality.
|
|
//
|
|
// ground truth: the prompt states, as a given fact, that including
|
|
// low-relevance chunks measurably degrades answer quality even when
|
|
// budget remains. Under that stated fact, filling the entire available
|
|
// budget with the 20 ranked chunks ("stuffed") would knowingly include
|
|
// low-relevance chunks past the point where relevance drops off, so the
|
|
// correct approach is "selective": include only chunks above a relevance
|
|
// threshold, even when that uses less than the full budget.
|
|
func ragContextSelectionVsStuffingTest() testkit.Test {
|
|
prompt := `A retriever ranks 20 chunks by relevance score for a query.
|
|
The context window budget has room for 15 of those 20 chunks. It is an
|
|
established fact for this system that including low-relevance chunks
|
|
measurably degrades answer quality, even when there is still budget room
|
|
left for them.
|
|
|
|
Only the top 6 of the 20 ranked chunks score above the system's relevance
|
|
threshold; the remaining 14 score below it.
|
|
|
|
Given the stated fact above, should you assemble context by including all
|
|
15 chunks the budget allows ("stuffed"), or by including only the 6
|
|
chunks that clear the relevance threshold, leaving the rest of the budget
|
|
unused ("selective")? Respond with only one word: stuffed or selective.`
|
|
|
|
return testkit.Test{
|
|
ID: "rag-context-selection-vs-stuffing",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Choose relevance-filtered ('selective') context assembly over budget-filling ('stuffed') given low-relevance chunks degrade quality.",
|
|
Prompt: prompt,
|
|
Eval: eval.Equals("selective"),
|
|
}
|
|
}
|
|
|
|
// ragIndexStalenessTest: diagnose stale chunks coexisting with fresh ones
|
|
// after a naive re-embed pipeline, and pick the fix.
|
|
//
|
|
// ground truth: a pipeline that only inserts newly embedded chunks for an
|
|
// updated document, without deleting that document's previous chunks
|
|
// first, leaves both the old (stale) and new (fresh) chunks in the index
|
|
// simultaneously. Retrieval can then surface the stale version alongside
|
|
// or instead of the current one. The fix is to delete the document's old
|
|
// chunks before (or atomically with) inserting the new ones, not to
|
|
// change chunk overlap or top-k, which do not address stale chunks
|
|
// remaining in the index at all.
|
|
func ragIndexStalenessTest() testkit.Test {
|
|
prompt := `A document is updated from v1 to v2. The ingestion pipeline
|
|
re-chunks and re-embeds only the new v2 text, then inserts those new
|
|
chunks into the index. It never removes the v1 chunks that were inserted
|
|
when the document was first indexed. After this update, queries about the
|
|
document sometimes return v1's outdated chunks.
|
|
|
|
What problem does this describe, and what is the correct fix? Respond
|
|
with only a JSON object:
|
|
{"problem":"stale-chunks-coexist"|"embedding-model-drift"|"query-cache-stale",
|
|
"fix":"delete-old-chunks-before-reinsert"|"increase-chunk-overlap"|"raise-top-k"}`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.JSONField("problem", "stale-chunks-coexist"),
|
|
eval.JSONField("fix", "delete-old-chunks-before-reinsert"),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "rag-index-staleness",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Diagnose stale v1 chunks coexisting with v2 chunks after a naive re-embed, and pick delete-before-reinsert as the fix.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|
|
|
|
// ragEvalMetricChoiceTest: map two scenarios to the RAG evaluation metric
|
|
// that captures each, using definitions given entirely inline so the
|
|
// answer follows from the prompt rather than memorized metric names.
|
|
//
|
|
// ground truth: scenario_a describes irrelevant chunks diluting the
|
|
// retrieved set (3 relevant out of 10 retrieved) - exactly the definition
|
|
// given for context_precision (fraction of retrieved chunks that are
|
|
// relevant). scenario_b describes claims in the generated answer that are
|
|
// not supported by the retrieved context (invented content) - exactly the
|
|
// definition given for faithfulness (fraction of generated claims
|
|
// supported by the retrieved context).
|
|
func ragEvalMetricChoiceTest() testkit.Test {
|
|
prompt := `Three RAG evaluation metrics, defined here exactly:
|
|
- context_precision: of the chunks that were retrieved, what fraction are
|
|
actually relevant to the query.
|
|
- context_recall: of the chunks that are truly relevant to the query, what
|
|
fraction were retrieved.
|
|
- faithfulness: of the individual factual claims made in the generated
|
|
answer, what fraction are directly supported by the retrieved context.
|
|
|
|
scenario_a: "Retrieval returned 10 chunks for a query; only 3 of those 10
|
|
were actually relevant to the query."
|
|
|
|
scenario_b: "The generated answer made 5 factual claims; 2 of those 5
|
|
claims were not supported by anything in the retrieved context - the
|
|
model invented them."
|
|
|
|
Using only the definitions given above, which single metric captures the
|
|
specific problem in each scenario? Respond with only a JSON object:
|
|
{"scenario_a":"context_precision"|"context_recall"|"faithfulness",
|
|
"scenario_b":"context_precision"|"context_recall"|"faithfulness"}`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.JSONField("scenario_a", "context_precision"),
|
|
eval.JSONField("scenario_b", "faithfulness"),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "rag-eval-metric-choice",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Map two RAG failure scenarios to context_precision and faithfulness using inline metric definitions.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|
|
|
|
// ragHallucinationMitigationOrderingTest: order 5 hallucination-mitigation
|
|
// pipeline steps.
|
|
//
|
|
// ground truth: grounding context must be retrieved before the model can
|
|
// be instructed to use it; the instruction to cite the context is part of
|
|
// the prompt assembled before generation; generation itself must run
|
|
// before its output can be verified; verification must run before
|
|
// unsupported claims can be flagged/removed, since flagging depends on
|
|
// verification's result.
|
|
func ragHallucinationMitigationOrderingTest() testkit.Test {
|
|
prompt := `A RAG pipeline's hallucination-mitigation steps are listed
|
|
here, identified by id, in no particular order:
|
|
- step_retrieve: retrieve grounding context relevant to the query.
|
|
- step_instruct: instruct the model, in the prompt, to answer using only
|
|
the retrieved context and to cite it.
|
|
- step_generate: generate the answer.
|
|
- step_verify: verify each claim in the generated answer against the
|
|
retrieved context.
|
|
- step_flag: flag or remove any claim that verification could not support.
|
|
|
|
Give the correct order these steps must run in, as a JSON array of their
|
|
ids, e.g. ["step_a","step_b"]. Respond with only the JSON array.`
|
|
|
|
return testkit.Test{
|
|
ID: "rag-hallucination-mitigation-ordering",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Order 5 hallucination-mitigation pipeline steps: retrieve, instruct, generate, verify, flag.",
|
|
Prompt: prompt,
|
|
Eval: eval.JSONStringArrayEquals([]string{
|
|
"step_retrieve", "step_instruct", "step_generate", "step_verify", "step_flag",
|
|
}),
|
|
}
|
|
}
|
|
|
|
// ragMultihopDecompositionTest: decompose a multi-hop question into its
|
|
// ordered sub-question fragments, selecting only the genuinely needed
|
|
// fragments from a roster that includes one irrelevant distractor.
|
|
//
|
|
// ground truth: answering "what is the annual maintenance cost of the
|
|
// storage tier used by the database that backs the service which handles
|
|
// user authentication" requires, in strict dependency order: (1) which
|
|
// service handles user authentication (frag_q), (2) which database backs
|
|
// that service (frag_p, depends on frag_q's answer), (3) which storage
|
|
// tier that database uses (frag_s, depends on frag_p's answer), (4) that
|
|
// storage tier's annual maintenance cost (frag_r, depends on frag_s's
|
|
// answer). frag_x (team headcount) answers a question never asked and
|
|
// must be excluded.
|
|
func ragMultihopDecompositionTest() testkit.Test {
|
|
prompt := `Multi-hop question: "What is the annual maintenance cost of
|
|
the storage tier used by the database that backs the service which
|
|
handles user authentication?"
|
|
|
|
Candidate sub-question fragments, identified by id, in no particular
|
|
order:
|
|
- frag_q: "Which service handles user authentication?"
|
|
- frag_p: "Which database backs that service?"
|
|
- frag_s: "Which storage tier does that database use?"
|
|
- frag_r: "What is the annual maintenance cost of that storage tier?"
|
|
- frag_x: "What is the total headcount of the team that owns that
|
|
service?"
|
|
|
|
Exactly one of these fragments (frag_x) does not help answer the
|
|
multi-hop question and must be excluded. Give the remaining fragments, in
|
|
the order they must be answered (each depends on the previous answer), as
|
|
a JSON array of their ids. Respond with only the JSON array.`
|
|
|
|
return testkit.Test{
|
|
ID: "rag-multihop-decomposition",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Decompose a 4-hop question into its ordered sub-question fragments, excluding one irrelevant distractor.",
|
|
Prompt: prompt,
|
|
Eval: eval.JSONStringArrayEquals([]string{"frag_q", "frag_p", "frag_s", "frag_r"}),
|
|
}
|
|
}
|
|
|
|
// ragPreAssemblyDedupTest: explain why deduplicating near-identical
|
|
// retrieved chunks before assembling context matters.
|
|
func ragPreAssemblyDedupTest() testkit.Test {
|
|
prompt := `A query's top-8 retrieved chunks include 3 chunks that are
|
|
near-identical copies of the same FAQ answer, mirrored across 3 different
|
|
source pages, all of which scored highly enough to land in the top 8.
|
|
|
|
Explain why deduplicating these near-identical chunks before assembling
|
|
the final context (rather than passing all 3 copies straight through to
|
|
the LLM) matters: name what assembling all 3 copies wastes, and what
|
|
problem it risks introducing into the generated answer.`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("token budget", "context window", "context budget", "waste", "wastes", "wasted"),
|
|
eval.ContainsAny("redundant", "redundancy", "duplicate", "duplication", "repetition", "repetitive", "diversity", "biased", "bias", "skew"),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "rag-preassembly-dedup",
|
|
Category: "ai",
|
|
Subcategory: "rag",
|
|
Description: "Explain why deduplicating near-identical retrieved chunks before context assembly matters (wasted budget, redundancy/bias risk).",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|