Files
llm-testbench/internal/tests/ai_vectorsearch.go
T
lukaszraczylo 9d1d4d89e9 fix(catalog): unify negation guard and harden round-2 evaluators
A shared NoUnnegatedMention primitive replaces four per-category
implementations, with a bidirectional clause-scoped window and an
adjectival cue set. The docker layer fixture drops WORKDIR, which
creates a real layer and made the pinned answer wrong. The replica
failover test now forces a JSON verdict so a negated answer cannot
score. Remaining exact-answer tests migrate to ExactToken. Ordering
prompts shuffle their candidate lists so echoing scores zero.
2026-08-17 03:50:57 +01:00

469 lines
20 KiB
Go

package tests
import (
"fmt"
"strings"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
func registerAIVectorSearchTests(r *testkit.Registry) {
r.Register(vecCosineVsDotTest())
r.Register(vecRecallAtKTest())
r.Register(vecHNSWEfSearchTradeoffTest())
r.Register(vecPQMemoryMathTest())
r.Register(vecPreVsPostFilteringTest())
r.Register(vecRRFFusionTest())
r.Register(vecDistanceToSimilarityTest())
r.Register(vecNearDuplicateThresholdTest())
r.Register(vecIndexBuildQueryTradeoffTest())
r.Register(vecEmbeddingDimensionTradeoffTest())
}
// aiRRFScore computes the standard Reciprocal Rank Fusion score for one
// document: the sum of 1/(k+rank) over every ranked list the document
// appears in, using 1-indexed ranks. A document absent from a list
// contributes nothing for that list (omit its rank from ranks).
func aiRRFScore(k int, ranks ...int) float64 {
var sum float64
for _, rank := range ranks {
sum += 1 / float64(k+rank)
}
return sum
}
// aiUnitDistanceToCosineSimilarity converts a Euclidean distance d between
// two unit-length (L2-normalized) vectors to their cosine similarity, using
// ||a-b||^2 = |a|^2 + |b|^2 - 2*cos_sim = 2 - 2*cos_sim for |a|=|b|=1, so
// cos_sim = 1 - d^2/2.
func aiUnitDistanceToCosineSimilarity(d float64) float64 {
return 1 - d*d/2
}
// vecCosineVsDotTest: confirm that for two already-unit-normalized vectors,
// the dot product equals their cosine similarity.
//
// ground truth: cosine similarity is dot(a,b) / (|a|*|b|). a = [0.6, 0.8]
// has |a| = sqrt(0.36+0.64) = sqrt(1) = 1, and b = [0.8, 0.6] has the same
// magnitude by symmetry. With both magnitudes exactly 1, the denominator is
// 1, so dot(a,b) and cosine similarity are the same value. Answer: yes.
func vecCosineVsDotTest() testkit.Test {
prompt := `Two vectors have already been L2-normalized to unit length
(magnitude exactly 1): a = [0.6, 0.8] and b = [0.8, 0.6].
Confirm the magnitudes: |a| = sqrt(0.6^2 + 0.8^2) = sqrt(0.36 + 0.64) =
sqrt(1.0) = 1, and by the same arithmetic |b| = sqrt(0.8^2 + 0.6^2) = 1.
Cosine similarity is defined as dot(a,b) / (|a| * |b|). Since |a| and |b|
are both exactly 1 for this pair, is the plain dot product dot(a,b)
mathematically equivalent to the cosine similarity, with no further
division needed? Respond with only one word: yes or no.`
return testkit.Test{
ID: "vec-cosine-vs-dot",
Category: "ai",
Subcategory: "vector-search",
Description: "Confirm that for two unit-normalized vectors, the plain dot product equals the cosine similarity.",
Prompt: prompt,
Eval: eval.ExactToken("yes"),
}
}
// vecRecallAtKRelevant and vecRecallAtKRetrieved are the inline
// relevant-item set and ranked top-5 retrieval list for vecRecallAtKTest -
// a fresh fixture (V-prefixed ids, recall@5 not @6) distinct from
// paper-recall-at-k in whitepapers_vector.go.
var (
vecRecallAtKRelevant = []string{"V3", "V8", "V11", "V14", "V20"}
vecRecallAtKRetrieved = []string{"V8", "V1", "V14", "V6", "V9"}
)
// vecRecallAtKWant is derived by calling wpRecallAtK (already defined in
// whitepapers_compute.go for the same recall@k metric), not hardcoded.
//
// ground truth: of the 5 relevant items (V3, V8, V11, V14, V20), exactly 2
// (V8 and V14) appear in the top-5 retrieved list; recall@5 = 2/5 = 0.4.
// ai_vectorsearch_test.go independently recomputes this with a plain set
// intersection.
var vecRecallAtKWant = wpRecallAtK(vecRecallAtKRelevant, vecRecallAtKRetrieved)
// vecRecallAtKTest: compute recall@k from a fresh relevant-item set and
// ranked top-k retrieval list, distinct from the whitepapers recall@k
// fixture.
func vecRecallAtKTest() testkit.Test {
// C14: the relevant/retrieved lists are interpolated from
// vecRecallAtKRelevant/vecRecallAtKRetrieved (the vec-rrf-fusion
// pattern) rather than hand-typed, so the displayed lists can never
// drift from what vecRecallAtKWant is actually computed from.
prompt := `Recall@k is a retrieval-quality metric: for one query, it is
the fraction of that query's truly relevant items that appear anywhere
within the system's top k retrieved results, regardless of exact rank.
recall@k = |relevant intersect top-k| / |relevant|.
A query's truly relevant vectors are: ` + strings.Join(vecRecallAtKRelevant, ", ") + `.
A vector search system's ranked top-5 results for that query are, in rank
order: ` + strings.Join(vecRecallAtKRetrieved, ", ") + `.
Compute recall@5 for this query. Respond with only the number, rounded to
4 decimal places, with no commas, units, or other text (for example:
0.4000).`
return testkit.Test{
ID: "vec-recall-at-k",
Category: "ai",
Subcategory: "vector-search",
Description: "Compute recall@5 from a fresh relevant-item set and ranked top-5 retrieval list.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[float64], vecRecallAtKWant, 0.0005),
}
}
// vecHNSWEfSearchTradeoffTest: name the shared direction recall and query
// latency move in when efSearch increases.
//
// ground truth: increasing efSearch (candidates explored per query) at
// fixed efConstruction and M makes the search examine more candidate nodes
// before stopping. That means more distance computations per query
// (latency up) and a lower chance of missing a true nearest neighbor
// (recall up) - both move in the same direction: up.
func vecHNSWEfSearchTradeoffTest() testkit.Test {
prompt := `In an HNSW approximate nearest-neighbor index, efSearch
controls how many candidate nodes the query-time search explores before
stopping; efConstruction and M are fixed, index-build-time parameters that
do not change per query.
If you increase efSearch, keeping efConstruction and M fixed, query recall
and query latency move in the same direction as each other. Do they both
go up, or both go down? Respond with only one word: up or down.`
return testkit.Test{
ID: "vec-hnsw-efsearch-tradeoff",
Category: "ai",
Subcategory: "vector-search",
Description: "State that increasing HNSW's efSearch moves recall and query latency in the same direction (up).",
Prompt: prompt,
Eval: eval.ExactToken("up"),
}
}
// vecPQMemoryMathTest: derive total compressed index memory from PQ
// parameters distinct from the whitepapers PQ-compression-ratio fixture
// (different m, nbits, dimension, and a total-bytes framing rather than a
// ratio).
//
// vecPQMemoryMathM, vecPQMemoryMathNBits, and vecPQMemoryMathVectorCount are
// the PQ parameters for vecPQMemoryMathTest, interpolated into the prompt
// (C14) so the displayed parameters can never drift from what
// vecPQMemoryMathWant is actually computed from.
const (
vecPQMemoryMathM = 16
vecPQMemoryMathNBits = 4
vecPQMemoryMathVectorCount = 1000000
)
// ground truth: codeBytes = m*nbits/8 = 16*4/8 = 8 bytes per compressed
// vector (via wpQuantizedCodeBytes, already defined in
// whitepapers_compute.go). Total for 1,000,000 vectors = 8 * 1,000,000 =
// 8,000,000 bytes. ai_vectorsearch_test.go independently recomputes this
// with plain arithmetic.
var vecPQMemoryMathWant = wpQuantizedCodeBytes(vecPQMemoryMathM, vecPQMemoryMathNBits) * vecPQMemoryMathVectorCount
func vecPQMemoryMathTest() testkit.Test {
prompt := fmt.Sprintf(`Product quantization (PQ) compresses each vector by splitting
it into m equal-length subvectors and quantizing each one independently
against its own codebook, storing only each subvector's nbits-bit centroid
index. Concatenating the m per-subvector codes gives the full compressed
representation, with no wasted bits (assume m*nbits is a multiple of 8).
An index uses m = %d subvectors and nbits = %d bits per subvector.
What is the total memory, in bytes, needed to store the compressed
representations of %d vectors indexed with these PQ parameters?
Respond with only the integer number of bytes, with no commas, units, or
other text (for example: 12345678).`, vecPQMemoryMathM, vecPQMemoryMathNBits, vecPQMemoryMathVectorCount)
return testkit.Test{
ID: "vec-pq-memory-math",
Category: "ai",
Subcategory: "vector-search",
Description: "Derive total compressed-index memory in bytes for 1M vectors from PQ parameters m=16, nbits=4.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[int], vecPQMemoryMathWant, 0),
}
}
// vecPreVsPostFilteringTest: choose pre-filtering over post-filtering when
// a metadata filter is highly selective relative to the requested result
// count.
//
// ground truth: the filter matches only 0.5% of 10 million vectors (about
// 50,000 candidates). Post-filtering runs ANN search first and discards
// non-matching results afterward; with such low selectivity, a top-k ANN
// search is very likely to return few or zero matching candidates unless k
// is made enormous, so it cannot reliably return 10 matching results.
// Pre-filtering (restricting the searched candidate set to only
// filter-matching vectors before or during the ANN search) is required to
// reliably surface 10 results that satisfy the filter.
func vecPreVsPostFilteringTest() testkit.Test {
prompt := `An ANN vector index holds 10,000,000 vectors. A metadata
filter (e.g. tenant_id = X) matches only 0.5% of those vectors. A query
needs the top 10 nearest-neighbor results that also satisfy this filter.
Should the filter be applied before/during the ANN search so only
filter-matching vectors are ever considered (pre-filtering), or should the
ANN search run first and non-matching results be discarded afterward
(post-filtering), to reliably return 10 matching results? Respond with
only one word: pre or post.`
return testkit.Test{
ID: "vec-pre-vs-post-filtering",
Category: "ai",
Subcategory: "vector-search",
Description: "Choose pre-filtering over post-filtering ANN search when the metadata filter is highly selective (0.5%).",
Prompt: prompt,
Eval: eval.ExactToken("pre"),
}
}
// vecRRFFusionKeywordRanks and vecRRFFusionVectorRanks are the inline,
// 1-indexed rank lists for vecRRFFusionTest, also used to build the
// prompt's rank-list text via strings.Join so the displayed lists and the
// computed ground truth can never drift apart. D5 sits at rank 3 in the
// keyword list and rank 7 in the vector list.
var (
vecRRFFusionKeywordRanks = []string{"D2", "D9", "D5", "D1", "D7", "D3", "D6", "D4"}
vecRRFFusionVectorRanks = []string{"D9", "D1", "D3", "D2", "D8", "D6", "D5", "D4"}
)
// vecRRFFusionK is the RRF constant used in vecRRFFusionTest's prompt,
// stated explicitly so the answer is computable rather than memorized.
const vecRRFFusionK = 60
// vecRRFFusionDoc is the document vecRRFFusionTest asks the model to score.
const vecRRFFusionDoc = "D5"
// aiRankOf returns id's 1-indexed position in ranked, or 0 if id is
// absent (matching aiRRFScore's convention that an absent document
// contributes nothing for that list).
func aiRankOf(id string, ranked []string) int {
for i, v := range ranked {
if v == id {
return i + 1
}
}
return 0
}
// vecRRFFusionWant is derived by calling aiRRFScore with vecRRFFusionDoc's
// actual ranks in both lists, looked up via aiRankOf rather than
// hardcoded.
//
// ground truth: RRF(d) = sum over each list of 1/(k + rank_list(d)). D5 is
// rank 3 in the keyword list and rank 7 in the vector list, so RRF(D5) =
// 1/(60+3) + 1/(60+7) = 1/63 + 1/67 ~= 0.030798. Rounded to 4dp: 0.0308.
// ai_vectorsearch_test.go independently recomputes this with plain
// arithmetic.
var vecRRFFusionWant = round4dp(aiRRFScore(
vecRRFFusionK,
aiRankOf(vecRRFFusionDoc, vecRRFFusionKeywordRanks),
aiRankOf(vecRRFFusionDoc, vecRRFFusionVectorRanks),
))
func vecRRFFusionTest() testkit.Test {
prompt := `Reciprocal Rank Fusion (RRF) combines multiple ranked result
lists into one fused score per document. For a document d, its RRF score is
the sum, over every ranked list it appears in, of 1/(k + rank), where rank
is the document's 1-indexed position in that list and k is a fixed
constant. A document missing from a list contributes nothing for that list.
Use k = 60.
Keyword search results, ranked 1st to 8th: ` + strings.Join(vecRRFFusionKeywordRanks, ", ") + `
Vector search results, ranked 1st to 8th: ` + strings.Join(vecRRFFusionVectorRanks, ", ") + `
Using the formula above with k = 60, compute the RRF score for document ` + vecRRFFusionDoc + `.
Respond with only the number, rounded to 4 decimal places.`
return testkit.Test{
ID: "vec-rrf-fusion",
Category: "ai",
Subcategory: "vector-search",
Description: "Compute a document's Reciprocal Rank Fusion score (k=60) from its ranks in two inline result lists.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[float64], vecRRFFusionWant, 0.0005),
}
}
// vecDistanceToSimilarityD is the Euclidean distance for
// vecDistanceToSimilarityTest, interpolated into the prompt (C14) so the
// displayed value can never drift from what vecDistanceToSimilarityWant is
// actually computed from.
const vecDistanceToSimilarityD = 0.6
// vecDistanceToSimilarityWant is derived by calling
// aiUnitDistanceToCosineSimilarity, not hardcoded.
//
// ground truth: for unit-length vectors, ||a-b||^2 = 2 - 2*cos_sim, so
// cos_sim = 1 - d^2/2. With d = 0.6, d^2 = 0.36, cos_sim = 1 - 0.18 = 0.82.
// ai_vectorsearch_test.go independently recomputes this with plain
// arithmetic.
var vecDistanceToSimilarityWant = round4dp(aiUnitDistanceToCosineSimilarity(vecDistanceToSimilarityD))
func vecDistanceToSimilarityTest() testkit.Test {
prompt := fmt.Sprintf(`Two vectors are both unit-length (L2-normalized to magnitude
1). For unit-length vectors, squared Euclidean distance relates to cosine
similarity by: ||a-b||^2 = |a|^2 + |b|^2 - 2*cos_sim(a,b) = 2 - 2*cos_sim(a,b),
since |a|=|b|=1. Rearranging: cos_sim(a,b) = 1 - (||a-b||^2) / 2.
The Euclidean distance between this pair is %v.
Using the formula above, compute cos_sim(a,b). Respond with only the
number, rounded to 4 decimal places, with no commas, units, or other text
(for example: 0.8200).`, vecDistanceToSimilarityD)
return testkit.Test{
ID: "vec-distance-to-similarity",
Category: "ai",
Subcategory: "vector-search",
Description: "Convert a Euclidean distance between unit vectors to cosine similarity using the stated formula.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[float64], vecDistanceToSimilarityWant, 0.0005),
}
}
// vecNearDuplicateA and vecNearDuplicateB are the inline 4-dim vectors for
// vecNearDuplicateThresholdTest, distinct from py-cosine's 8-dim fixture in
// python.go.
var (
vecNearDuplicateA = []float64{1, 2, 2, 1}
vecNearDuplicateB = []float64{2, 2, 1, 1}
)
// vecNearDuplicateThreshold is the near-duplicate cosine similarity cutoff
// stated in vecNearDuplicateThresholdTest's prompt.
const vecNearDuplicateThreshold = 0.97
// vecNearDuplicateThresholdWant is derived by calling cosineSimilarity
// (mathutil.go), not hardcoded.
//
// ground truth: dot(a,b) = 1*2+2*2+2*1+1*1 = 9; |a|^2 = 1+4+4+1 = 10;
// |b|^2 = 4+4+1+1 = 10; cosine similarity = 9/sqrt(10*10) = 9/10 = 0.9.
// 0.9 - 0.97 = -0.07. ai_vectorsearch_test.go independently recomputes
// this with plain arithmetic.
var vecNearDuplicateThresholdWant = round4dp(cosineSimilarity(vecNearDuplicateA, vecNearDuplicateB) - vecNearDuplicateThreshold)
// aiFormatIntVector renders a []float64 of whole numbers as "[1, 2, 2, 1]"
// prompt text, used to interpolate a fixture vector directly from the Go
// slice it is graded against (C14) rather than a hand-typed duplicate.
func aiFormatIntVector(v []float64) string {
parts := make([]string, len(v))
for i, x := range v {
parts[i] = fmt.Sprintf("%d", int(x))
}
return "[" + strings.Join(parts, ", ") + "]"
}
func vecNearDuplicateThresholdTest() testkit.Test {
prompt := fmt.Sprintf(`A vector index deduplicates near-identical items: a candidate
pair is treated as a near-duplicate only when its cosine similarity is at
or above %v.
Candidate pair: a = %s, b = %s.
Compute the cosine similarity of a and b, then subtract the %v threshold
from it (similarity minus threshold). Respond with only the resulting
number, rounded to 4 decimal places, with no commas, units, or other text
(it may be negative; for example: -0.0700).`,
vecNearDuplicateThreshold,
aiFormatIntVector(vecNearDuplicateA), aiFormatIntVector(vecNearDuplicateB),
vecNearDuplicateThreshold)
return testkit.Test{
ID: "vec-near-duplicate-threshold",
Category: "ai",
Subcategory: "vector-search",
Description: "Compute cosine similarity of a 4-dim vector pair and its signed distance from the 0.97 near-duplicate threshold.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[float64], vecNearDuplicateThresholdWant, 0.0005),
}
}
// vecIndexBuildQueryTradeoffTest: pick the index type each stated
// constraint forces, between HNSW (slower build, faster/more accurate
// query) and IVF-Flat with few clusters (fast build, coarser query).
//
// ground truth: scenario_a's constraint - 50M vectors must be indexed in
// under 10 minutes, with query recall/latency explicitly not critical -
// rules out HNSW's comparatively slow graph-construction build and forces
// IVF-Flat's fast single-pass clustering build. scenario_b's constraint -
// query recall is critical, build time is unconstrained - forces HNSW,
// since its graph gives materially better recall at query time than a
// coarse IVF-Flat partition.
func vecIndexBuildQueryTradeoffTest() testkit.Test {
prompt := `Two ANN index types are available:
- hnsw: builds a navigable graph; build time is comparatively slow, but
queries are fast and highly accurate (high recall).
- ivf-flat: clusters vectors into a small number of buckets via k-means;
build time is comparatively fast, but queries are coarser (lower recall)
than a well-tuned HNSW graph.
scenario_a: "You must index 50,000,000 vectors in under 10 minutes on
commodity hardware. Query recall and query latency are not critical for
this use case."
scenario_b: "Query recall is critical for this use case and build time is
not constrained at all."
For each scenario, which index type must you choose? Respond with only a
JSON object: {"scenario_a":"hnsw"|"ivf-flat","scenario_b":"hnsw"|"ivf-flat"}`
evaluator := eval.Mean(
eval.JSONField("scenario_a", "ivf-flat"),
eval.JSONField("scenario_b", "hnsw"),
)
return testkit.Test{
ID: "vec-index-build-query-tradeoff",
Category: "ai",
Subcategory: "vector-search",
Description: "Pick HNSW vs IVF-Flat for a build-time-constrained scenario and a recall-critical scenario.",
Prompt: prompt,
Eval: evaluator,
}
}
// vecEmbeddingDimensionTradeoffTest: pick the embedding dimension that fits
// a fixed, uncompressed float32 storage budget.
//
// ground truth: bytes per vector = dims * 4 (float32). 128-dim: 128*4 = 512
// bytes/vector, total for 100,000,000 vectors = 51,200,000,000 bytes =
// 51.2 GB (using decimal GB = 1e9 bytes), which fits the 60 GB budget.
// 768-dim: 768*4 = 3072 bytes/vector, total = 307,200,000,000 bytes =
// 307.2 GB, which does not fit.
func vecEmbeddingDimensionTradeoffTest() testkit.Test {
prompt := `You must store float32 (4 bytes per dimension), uncompressed
embeddings for 100,000,000 vectors, with a hard storage budget of 60 GB
(1 GB = 1,000,000,000 bytes). No quantization or compression is available.
Two embedding models are candidates:
- option 128: produces 128-dimensional embeddings.
- option 768: produces 768-dimensional embeddings.
Which option's total uncompressed storage fits within the 60 GB budget?
Respond with only a JSON object: {"choice":128|768}`
return testkit.Test{
ID: "vec-embedding-dimension-tradeoff",
Category: "ai",
Subcategory: "vector-search",
Description: "Pick the embedding dimension (128 vs 768) whose uncompressed float32 storage fits a fixed 60 GB budget for 100M vectors.",
Prompt: prompt,
// C7: choice is a number (mirrors rag-chunk-size-tradeoff's
// chunk_size_tokens), not a quoted string - the prompt's own
// template shows it unquoted.
Eval: eval.JSONField("choice", 128),
}
}