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>
68 lines
1.7 KiB
Go
68 lines
1.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestSearchCmd_SendsDocTypeAndTagsList(t *testing.T) {
|
|
var captured map[string]interface{}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/search" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
if err := json.Unmarshal(body, &captured); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"query":"q","results":[],"total_matches":0,"returned":0}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
var stdout bytes.Buffer
|
|
rootCmd.SetOut(&stdout)
|
|
rootCmd.SetArgs([]string{
|
|
"search", "oil level",
|
|
"--engine", server.URL,
|
|
"--format", "json",
|
|
"--type", "pdf",
|
|
"--tags", "manuals, ops,",
|
|
})
|
|
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("search command failed: %v", err)
|
|
}
|
|
|
|
if captured == nil {
|
|
t.Fatal("no request captured by test server")
|
|
}
|
|
if got := captured["doc_type"]; got != "pdf" {
|
|
t.Errorf("expected doc_type=pdf in body, got %v (full body: %v)", got, captured)
|
|
}
|
|
if _, present := captured["type"]; present {
|
|
t.Error("body must not contain legacy 'type' key")
|
|
}
|
|
tags, ok := captured["tags"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected tags to be a JSON array, got %T (%v)", captured["tags"], captured["tags"])
|
|
}
|
|
want := []interface{}{"manuals", "ops"}
|
|
if !reflect.DeepEqual(tags, want) {
|
|
t.Errorf("expected tags %v, got %v", want, tags)
|
|
}
|
|
}
|
|
|
|
func TestSplitTags(t *testing.T) {
|
|
got := splitTags(" a ,b,, c ")
|
|
want := []string{"a", "b", "c"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("splitTags: expected %v, got %v", want, got)
|
|
}
|
|
}
|