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>
This commit is contained in:
@@ -38,6 +38,10 @@ var supportedExts = map[string]bool{
|
||||
".py": true,
|
||||
".sh": true,
|
||||
".go": true,
|
||||
".json": true,
|
||||
".yaml": true,
|
||||
".yml": true,
|
||||
".toml": true,
|
||||
}
|
||||
|
||||
var addfileCmd = &cobra.Command{
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var benchCmd = &cobra.Command{
|
||||
Use: "bench <fixture.json>",
|
||||
Short: "Benchmark search quality against a query fixture",
|
||||
Long: `Run a fixture of queries with known-relevant documents against each
|
||||
search backend (fts, vec, hybrid, rerank) and report precision@k, recall
|
||||
and MRR per backend. Use this to baseline search quality before ranking
|
||||
changes and to measure their effect. See docs/bench-example.json for the
|
||||
fixture format.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runBench,
|
||||
}
|
||||
|
||||
func init() {
|
||||
benchCmd.Flags().Int("top", 0, "override result count per query (k)")
|
||||
benchCmd.Flags().String("backends", "fts,vec,hybrid,rerank", "comma-separated backends to run")
|
||||
rootCmd.AddCommand(benchCmd)
|
||||
}
|
||||
|
||||
type benchSelector struct {
|
||||
DocumentID int64 `json:"document_id"`
|
||||
SourcePath string `json:"source_path"`
|
||||
TitleContains string `json:"title_contains"`
|
||||
}
|
||||
|
||||
type benchQuery struct {
|
||||
ID string `json:"id"`
|
||||
Query string `json:"query"`
|
||||
Tags []string `json:"tags"`
|
||||
DocType string `json:"doc_type"`
|
||||
Top int `json:"top"`
|
||||
Relevant []benchSelector `json:"relevant"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type benchFixture struct {
|
||||
Description string `json:"description"`
|
||||
Top int `json:"top"`
|
||||
Queries []benchQuery `json:"queries"`
|
||||
}
|
||||
|
||||
type benchDoc struct {
|
||||
DocumentID int64
|
||||
Title string
|
||||
SourcePath string
|
||||
}
|
||||
|
||||
type queryResult struct {
|
||||
ID string `json:"id"`
|
||||
Precision float64 `json:"precision"`
|
||||
Recall float64 `json:"recall"`
|
||||
MRR float64 `json:"mrr"`
|
||||
LatencyMS float64 `json:"latency_ms"`
|
||||
Returned int `json:"returned"`
|
||||
}
|
||||
|
||||
type backendResult struct {
|
||||
Precision float64 `json:"precision"`
|
||||
Recall float64 `json:"recall"`
|
||||
MRR float64 `json:"mrr"`
|
||||
AvgLatencyMS float64 `json:"avg_latency_ms"`
|
||||
Queries []queryResult `json:"queries"`
|
||||
}
|
||||
|
||||
func loadFixture(path string) (*benchFixture, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read fixture: %w", err)
|
||||
}
|
||||
var fx benchFixture
|
||||
if err := json.Unmarshal(data, &fx); err != nil {
|
||||
return nil, fmt.Errorf("invalid fixture JSON: %w", err)
|
||||
}
|
||||
if len(fx.Queries) == 0 {
|
||||
return nil, fmt.Errorf("fixture has no queries")
|
||||
}
|
||||
for i, q := range fx.Queries {
|
||||
if q.Query == "" {
|
||||
return nil, fmt.Errorf("query %d: missing query text", i)
|
||||
}
|
||||
if len(q.Relevant) == 0 {
|
||||
return nil, fmt.Errorf("query %q: no relevant selectors", q.Query)
|
||||
}
|
||||
for j, sel := range q.Relevant {
|
||||
set := 0
|
||||
if sel.DocumentID != 0 {
|
||||
set++
|
||||
}
|
||||
if sel.SourcePath != "" {
|
||||
set++
|
||||
}
|
||||
if sel.TitleContains != "" {
|
||||
set++
|
||||
}
|
||||
if set != 1 {
|
||||
return nil, fmt.Errorf("query %q selector %d: exactly one of document_id, source_path, title_contains must be set", q.Query, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &fx, nil
|
||||
}
|
||||
|
||||
func (s benchSelector) matches(doc benchDoc) bool {
|
||||
switch {
|
||||
case s.DocumentID != 0:
|
||||
return doc.DocumentID == s.DocumentID
|
||||
case s.SourcePath != "":
|
||||
return doc.SourcePath == s.SourcePath
|
||||
case s.TitleContains != "":
|
||||
return strings.Contains(strings.ToLower(doc.Title), strings.ToLower(s.TitleContains))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dedupeByDocument collapses ranked chunks into ranked documents, keeping the
|
||||
// best (first) position for each document.
|
||||
func dedupeByDocument(docs []benchDoc) []benchDoc {
|
||||
seen := map[int64]bool{}
|
||||
var out []benchDoc
|
||||
for _, d := range docs {
|
||||
if seen[d.DocumentID] {
|
||||
continue
|
||||
}
|
||||
seen[d.DocumentID] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scoreQuery computes document-level precision, recall and MRR for one
|
||||
// query's ranked document list against the relevant-document selectors.
|
||||
func scoreQuery(ranked []benchDoc, relevant []benchSelector) (precision, recall, mrr float64) {
|
||||
if len(ranked) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
matchedDocs := 0
|
||||
firstMatch := 0
|
||||
selectorHit := make([]bool, len(relevant))
|
||||
for i, doc := range ranked {
|
||||
docMatched := false
|
||||
for j, sel := range relevant {
|
||||
if sel.matches(doc) {
|
||||
docMatched = true
|
||||
selectorHit[j] = true
|
||||
}
|
||||
}
|
||||
if docMatched {
|
||||
matchedDocs++
|
||||
if firstMatch == 0 {
|
||||
firstMatch = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectorsMatched := 0
|
||||
for _, hit := range selectorHit {
|
||||
if hit {
|
||||
selectorsMatched++
|
||||
}
|
||||
}
|
||||
|
||||
precision = float64(matchedDocs) / float64(len(ranked))
|
||||
recall = float64(selectorsMatched) / float64(len(relevant))
|
||||
if firstMatch > 0 {
|
||||
mrr = 1.0 / float64(firstMatch)
|
||||
}
|
||||
return precision, recall, mrr
|
||||
}
|
||||
|
||||
// rerankAvailable probes engine status for a loaded reranker.
|
||||
func rerankAvailable(client *api.Client) bool {
|
||||
resp, err := client.Get("/api/v1/status")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var status struct {
|
||||
Rerank struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Loaded bool `json:"loaded"`
|
||||
} `json:"rerank"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &status); err != nil {
|
||||
return false
|
||||
}
|
||||
return status.Rerank.Enabled && status.Rerank.Loaded
|
||||
}
|
||||
|
||||
func benchSearch(client *api.Client, q benchQuery, backend string, top int) ([]benchDoc, float64, error) {
|
||||
body := map[string]interface{}{
|
||||
"query": q.Query,
|
||||
"top": top,
|
||||
}
|
||||
if len(q.Tags) > 0 {
|
||||
body["tags"] = q.Tags
|
||||
}
|
||||
if q.DocType != "" {
|
||||
body["doc_type"] = q.DocType
|
||||
}
|
||||
switch backend {
|
||||
case "fts":
|
||||
body["fts_only"] = true
|
||||
case "vec":
|
||||
body["vec_only"] = true
|
||||
case "hybrid":
|
||||
body["rerank"] = false
|
||||
case "rerank":
|
||||
body["rerank"] = true
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Post("/api/v1/search", body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := api.CheckError(resp); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var result struct {
|
||||
Results []struct {
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Title string `json:"title"`
|
||||
SourcePath string `json:"source_path"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &result); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
latency := float64(time.Since(start).Microseconds()) / 1000.0
|
||||
|
||||
var docs []benchDoc
|
||||
for _, r := range result.Results {
|
||||
docs = append(docs, benchDoc{DocumentID: r.DocumentID, Title: r.Title, SourcePath: r.SourcePath})
|
||||
}
|
||||
return dedupeByDocument(docs), latency, nil
|
||||
}
|
||||
|
||||
func runBench(cmd *cobra.Command, args []string) error {
|
||||
topFlag, _ := cmd.Flags().GetInt("top")
|
||||
backendsFlag, _ := cmd.Flags().GetString("backends")
|
||||
|
||||
fx, err := loadFixture(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var backends []string
|
||||
for _, b := range strings.Split(backendsFlag, ",") {
|
||||
b = strings.TrimSpace(b)
|
||||
if b == "" {
|
||||
continue
|
||||
}
|
||||
switch b {
|
||||
case "fts", "vec", "hybrid", "rerank":
|
||||
backends = append(backends, b)
|
||||
default:
|
||||
return fmt.Errorf("unknown backend %q (valid: fts, vec, hybrid, rerank)", b)
|
||||
}
|
||||
}
|
||||
if len(backends) == 0 {
|
||||
return fmt.Errorf("no backends selected")
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
|
||||
rerankSkipped := false
|
||||
if contains(backends, "rerank") && !rerankAvailable(client) {
|
||||
backends = remove(backends, "rerank")
|
||||
rerankSkipped = true
|
||||
if len(backends) == 0 {
|
||||
return fmt.Errorf("reranking is not available on this engine (requires engine with reranker enabled)")
|
||||
}
|
||||
}
|
||||
|
||||
results := map[string]*backendResult{}
|
||||
for _, backend := range backends {
|
||||
br := &backendResult{}
|
||||
for _, q := range fx.Queries {
|
||||
top := 10
|
||||
if fx.Top > 0 {
|
||||
top = fx.Top
|
||||
}
|
||||
if q.Top > 0 {
|
||||
top = q.Top
|
||||
}
|
||||
if topFlag > 0 {
|
||||
top = topFlag
|
||||
}
|
||||
|
||||
ranked, latency, err := benchSearch(client, q, backend, top)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backend %s, query %q: %w", backend, q.Query, err)
|
||||
}
|
||||
p, r, m := scoreQuery(ranked, q.Relevant)
|
||||
id := q.ID
|
||||
if id == "" {
|
||||
id = q.Query
|
||||
}
|
||||
br.Queries = append(br.Queries, queryResult{
|
||||
ID: id, Precision: p, Recall: r, MRR: m,
|
||||
LatencyMS: latency, Returned: len(ranked),
|
||||
})
|
||||
}
|
||||
n := float64(len(br.Queries))
|
||||
for _, qr := range br.Queries {
|
||||
br.Precision += qr.Precision / n
|
||||
br.Recall += qr.Recall / n
|
||||
br.MRR += qr.MRR / n
|
||||
br.AvgLatencyMS += qr.LatencyMS / n
|
||||
}
|
||||
results[backend] = br
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
output.PrintJSON(map[string]interface{}{
|
||||
"description": fx.Description,
|
||||
"query_count": len(fx.Queries),
|
||||
"backends": results,
|
||||
"rerank_skipped": rerankSkipped,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if fx.Description != "" {
|
||||
fmt.Printf("%s (%d queries)\n\n", fx.Description, len(fx.Queries))
|
||||
}
|
||||
headers := []string{"Backend", "Precision", "Recall", "MRR", "Avg ms"}
|
||||
var rows [][]string
|
||||
for _, backend := range backends {
|
||||
br := results[backend]
|
||||
rows = append(rows, []string{
|
||||
backend,
|
||||
fmt.Sprintf("%.3f", br.Precision),
|
||||
fmt.Sprintf("%.3f", br.Recall),
|
||||
fmt.Sprintf("%.3f", br.MRR),
|
||||
fmt.Sprintf("%.0f", br.AvgLatencyMS),
|
||||
})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
if rerankSkipped {
|
||||
fmt.Println("\nrerank: n/a (reranker not enabled on this engine)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(ss []string, s string) bool {
|
||||
for _, v := range ss {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func remove(ss []string, s string) []string {
|
||||
var out []string
|
||||
for _, v := range ss {
|
||||
if v != s {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+73
-4
@@ -23,6 +23,8 @@ func init() {
|
||||
searchCmd.Flags().Bool("fts-only", false, "use full-text search only")
|
||||
searchCmd.Flags().Bool("vec-only", false, "use vector search only")
|
||||
searchCmd.Flags().Float64("threshold", 0, "minimum score threshold")
|
||||
searchCmd.Flags().Bool("explain", false, "include per-result score breakdown")
|
||||
searchCmd.Flags().Bool("no-rerank", false, "skip cross-encoder reranking (lower latency)")
|
||||
rootCmd.AddCommand(searchCmd)
|
||||
}
|
||||
|
||||
@@ -33,16 +35,18 @@ func runSearch(cmd *cobra.Command, args []string) error {
|
||||
ftsOnly, _ := cmd.Flags().GetBool("fts-only")
|
||||
vecOnly, _ := cmd.Flags().GetBool("vec-only")
|
||||
threshold, _ := cmd.Flags().GetFloat64("threshold")
|
||||
explain, _ := cmd.Flags().GetBool("explain")
|
||||
noRerank, _ := cmd.Flags().GetBool("no-rerank")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": args[0],
|
||||
"top": top,
|
||||
}
|
||||
if tags != "" {
|
||||
body["tags"] = tags
|
||||
body["tags"] = splitTags(tags)
|
||||
}
|
||||
if docType != "" {
|
||||
body["type"] = docType
|
||||
body["doc_type"] = docType
|
||||
}
|
||||
if ftsOnly {
|
||||
body["fts_only"] = true
|
||||
@@ -53,6 +57,12 @@ func runSearch(cmd *cobra.Command, args []string) error {
|
||||
if threshold > 0 {
|
||||
body["threshold"] = threshold
|
||||
}
|
||||
if explain {
|
||||
body["explain"] = true
|
||||
}
|
||||
if noRerank {
|
||||
body["rerank"] = false
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
resp, err := client.Post("/api/v1/search", body)
|
||||
@@ -66,13 +76,17 @@ func runSearch(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Reranked bool `json:"reranked"`
|
||||
Results []struct {
|
||||
Score float64 `json:"score"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
Title string `json:"title"`
|
||||
DocType string `json:"doc_type"`
|
||||
Tags []string `json:"tags"`
|
||||
TagContexts map[string]string `json:"tag_contexts"`
|
||||
ChunkMetadata map[string]interface{} `json:"chunk_metadata"`
|
||||
Text string `json:"text"`
|
||||
Explain map[string]interface{} `json:"explain"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
@@ -94,13 +108,17 @@ func runSearch(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if result.Reranked {
|
||||
fmt.Println("(reranked)")
|
||||
}
|
||||
|
||||
for i, r := range result.Results {
|
||||
snippet := r.Text
|
||||
if len(snippet) > 200 {
|
||||
snippet = snippet[:200] + "..."
|
||||
}
|
||||
|
||||
fmt.Printf("\n%d. [%.4f] %s\n", i+1, r.Score, r.Title)
|
||||
fmt.Printf("\n%d. [%.4f] %s (doc:%d)\n", i+1, r.Score, r.Title, r.DocumentID)
|
||||
|
||||
location := ""
|
||||
if page, ok := r.ChunkMetadata["page"]; ok && page != nil {
|
||||
@@ -123,12 +141,63 @@ func runSearch(cmd *cobra.Command, args []string) error {
|
||||
if len(r.Tags) > 0 {
|
||||
fmt.Printf(" Tags: %s\n", joinStrings(r.Tags))
|
||||
}
|
||||
for _, tag := range r.Tags {
|
||||
if desc, ok := r.TagContexts[tag]; ok && desc != "" {
|
||||
fmt.Printf(" Context: %s — %s\n", tag, desc)
|
||||
}
|
||||
}
|
||||
if r.Explain != nil {
|
||||
fmt.Printf(" Score: %s\n", formatExplain(r.Explain))
|
||||
}
|
||||
fmt.Printf(" %s\n", snippet)
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatExplain renders the engine's explain breakdown as one line, e.g.
|
||||
// "fts score=4.213 rank=2 (rrf 0.016129) | vec score=0.512 rank=1 (rrf 0.016393) | bonus 0.05 | final 0.082522"
|
||||
func formatExplain(e map[string]interface{}) string {
|
||||
num := func(key string) (float64, bool) {
|
||||
v, ok := e[key].(float64)
|
||||
return v, ok
|
||||
}
|
||||
var parts []string
|
||||
for _, arm := range []string{"fts", "vec"} {
|
||||
score, hasScore := num(arm + "_score")
|
||||
if !hasScore {
|
||||
continue
|
||||
}
|
||||
part := fmt.Sprintf("%s score=%.4f", arm, score)
|
||||
if rank, ok := num(arm + "_rank"); ok {
|
||||
part += fmt.Sprintf(" rank=%d", int(rank))
|
||||
}
|
||||
if rrf, ok := num("rrf_" + arm); ok {
|
||||
part += fmt.Sprintf(" (rrf %.6f)", rrf)
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
if bonus, ok := num("bonus"); ok && bonus > 0 {
|
||||
parts = append(parts, fmt.Sprintf("bonus %.2f", bonus))
|
||||
}
|
||||
for _, key := range []string{"pre_rerank_rank", "rerank_score", "blend_weight"} {
|
||||
if v, ok := num(key); ok {
|
||||
parts = append(parts, fmt.Sprintf("%s %.4f", key, v))
|
||||
}
|
||||
}
|
||||
if final, ok := num("final_score"); ok {
|
||||
parts = append(parts, fmt.Sprintf("final %.6f", final))
|
||||
}
|
||||
result := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
result += " | "
|
||||
}
|
||||
result += p
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinStrings(ss []string) string {
|
||||
result := ""
|
||||
for i, s := range ss {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var tagDescribeCmd = &cobra.Command{
|
||||
Use: "tag-describe <tag> [description]",
|
||||
Short: "Set a one-line context description on a tag",
|
||||
Long: `Attach a short description to a tag (e.g. "Lab operations runbooks").
|
||||
Descriptions are returned as tag_contexts with every search result on a
|
||||
document carrying the tag, helping consumers judge relevance.
|
||||
|
||||
Omit the description (or pass "") to clear it.`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: runTagDescribe,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(tagDescribeCmd)
|
||||
}
|
||||
|
||||
func runTagDescribe(cmd *cobra.Command, args []string) error {
|
||||
description := ""
|
||||
if len(args) == 2 {
|
||||
description = args[1]
|
||||
}
|
||||
|
||||
body := map[string]interface{}{"description": description}
|
||||
|
||||
client := api.NewClient()
|
||||
resp, err := client.Put("/api/v1/tags/"+args[0]+"/description", body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := api.CheckError(resp); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
if description == "" {
|
||||
fmt.Printf("Description cleared for tag %q\n", args[0])
|
||||
} else {
|
||||
fmt.Printf("Tag %q: %s\n", args[0], description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+5
-4
@@ -41,8 +41,9 @@ func runTags(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
var tags []struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &tags); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
@@ -53,10 +54,10 @@ func runTags(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := []string{"TAG", "COUNT"}
|
||||
headers := []string{"TAG", "COUNT", "DESCRIPTION"}
|
||||
var rows [][]string
|
||||
for _, t := range tags {
|
||||
rows = append(rows, []string{t.Name, fmt.Sprintf("%d", t.Count)})
|
||||
rows = append(rows, []string{t.Name, fmt.Sprintf("%d", t.Count), t.Description})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user