Files
kb/client/cmd/bench_test.go
steve 6dfc13be1d Add reranking, RRF fusion, bench harness, tag contexts, and data ingestion
Implements five of the six enhancements from docs/kb-enhancements-proposal.htm,
closing the retrieval-quality gap identified in the qmd review.

- Cross-encoder reranking: new kb/reranker.py loads an optional reranking
  model at startup (KB_RERANK_ENABLED, KB_RERANKER_MODEL,
  KB_RERANK_CANDIDATES). Search degrades gracefully to plain hybrid
  retrieval when the model is absent. Exposed via a "rerank" block in
  /status, a rerank flag on search, and --no-rerank in the CLI.
- RRF rank fusion: FTS and vector lists now merge by reciprocal rank
  fusion with a top-rank bonus, replacing the old score blend. Scores are
  comparable across queries.
- Bench harness and explain traces: kb bench runs a query fixture against
  each backend and reports precision@k, recall and MRR. --explain returns a
  per-result score breakdown.
- Tag context descriptions: tags carry an optional one-line description
  (kb tag-describe), returned as tag_contexts with search results. Adds a
  tags.description column migration.
- Structured data ingestion: .json/.yaml/.toml files ingest as text via the
  new "data" doc type, pretty-printing minified JSON before chunking.

Query expansion (proposal item 5) is deliberately left out pending bench
results. Requires engine v3.3.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:51:33 +01:00

144 lines
4.2 KiB
Go

package cmd
import (
"math"
"os"
"path/filepath"
"testing"
)
func writeFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fixture.json")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadFixture_Valid(t *testing.T) {
path := writeFixture(t, `{
"description": "test",
"top": 5,
"queries": [
{"id": "q1", "query": "hello", "relevant": [{"document_id": 7}]}
]
}`)
fx, err := loadFixture(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fx.Top != 5 || len(fx.Queries) != 1 || fx.Queries[0].Relevant[0].DocumentID != 7 {
t.Errorf("fixture parsed incorrectly: %+v", fx)
}
}
func TestLoadFixture_RejectsEmptyRelevant(t *testing.T) {
path := writeFixture(t, `{"queries": [{"query": "hello", "relevant": []}]}`)
if _, err := loadFixture(path); err == nil {
t.Error("expected error for query with no relevant selectors")
}
}
func TestLoadFixture_RejectsMultiFieldSelector(t *testing.T) {
path := writeFixture(t, `{"queries": [
{"query": "hello", "relevant": [{"document_id": 1, "source_path": "/x"}]}
]}`)
if _, err := loadFixture(path); err == nil {
t.Error("expected error for selector with two fields set")
}
}
func TestSelectorMatching(t *testing.T) {
doc := benchDoc{DocumentID: 42, Title: "M38T Owner's Manual", SourcePath: "/data/m38t.pdf"}
cases := []struct {
name string
sel benchSelector
want bool
}{
{"document_id match", benchSelector{DocumentID: 42}, true},
{"document_id miss", benchSelector{DocumentID: 43}, false},
{"source_path match", benchSelector{SourcePath: "/data/m38t.pdf"}, true},
{"source_path miss", benchSelector{SourcePath: "/data/other.pdf"}, false},
{"title_contains case-insensitive", benchSelector{TitleContains: "m38t owner"}, true},
{"title_contains miss", benchSelector{TitleContains: "workshop"}, false},
}
for _, tc := range cases {
if got := tc.sel.matches(doc); got != tc.want {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}
func TestDedupeByDocument(t *testing.T) {
docs := []benchDoc{
{DocumentID: 1, Title: "a"},
{DocumentID: 2, Title: "b"},
{DocumentID: 1, Title: "a-again"},
{DocumentID: 3, Title: "c"},
}
out := dedupeByDocument(docs)
if len(out) != 3 || out[0].DocumentID != 1 || out[1].DocumentID != 2 || out[2].DocumentID != 3 {
t.Errorf("dedupe failed: %+v", out)
}
if out[0].Title != "a" {
t.Errorf("dedupe must keep first (best-ranked) occurrence, got %q", out[0].Title)
}
}
func approxEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
func TestScoreQuery_HandComputed(t *testing.T) {
// Ranked docs: 10, 20, 30, 40. Relevant: 20 and 40.
ranked := []benchDoc{
{DocumentID: 10}, {DocumentID: 20}, {DocumentID: 30}, {DocumentID: 40},
}
relevant := []benchSelector{{DocumentID: 20}, {DocumentID: 40}}
p, r, m := scoreQuery(ranked, relevant)
if !approxEqual(p, 0.5) { // 2 of 4 returned docs are relevant
t.Errorf("precision: got %v, want 0.5", p)
}
if !approxEqual(r, 1.0) { // both relevant docs found
t.Errorf("recall: got %v, want 1.0", r)
}
if !approxEqual(m, 0.5) { // first relevant doc at rank 2
t.Errorf("mrr: got %v, want 0.5", m)
}
}
func TestScoreQuery_NoMatches(t *testing.T) {
ranked := []benchDoc{{DocumentID: 1}}
relevant := []benchSelector{{DocumentID: 99}}
p, r, m := scoreQuery(ranked, relevant)
if p != 0 || r != 0 || m != 0 {
t.Errorf("expected all-zero metrics, got p=%v r=%v mrr=%v", p, r, m)
}
}
func TestScoreQuery_PartialRecall(t *testing.T) {
// Only one of three relevant docs returned, at rank 1.
ranked := []benchDoc{{DocumentID: 5}, {DocumentID: 6}}
relevant := []benchSelector{{DocumentID: 5}, {DocumentID: 7}, {DocumentID: 8}}
p, r, m := scoreQuery(ranked, relevant)
if !approxEqual(p, 0.5) {
t.Errorf("precision: got %v, want 0.5", p)
}
if !approxEqual(r, 1.0/3.0) {
t.Errorf("recall: got %v, want 1/3", r)
}
if !approxEqual(m, 1.0) {
t.Errorf("mrr: got %v, want 1.0", m)
}
}
func TestScoreQuery_EmptyResults(t *testing.T) {
p, r, m := scoreQuery(nil, []benchSelector{{DocumentID: 1}})
if p != 0 || r != 0 || m != 0 {
t.Errorf("expected zeros for empty results, got p=%v r=%v mrr=%v", p, r, m)
}
}