6dfc13be1d
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>
144 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|