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>
211 lines
5.4 KiB
Go
211 lines
5.4 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/kb-search/kb/internal/api"
|
|
"github.com/kb-search/kb/internal/output"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var searchCmd = &cobra.Command{
|
|
Use: "search <query>",
|
|
Short: "Search the knowledge base",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runSearch,
|
|
}
|
|
|
|
func init() {
|
|
searchCmd.Flags().IntP("top", "n", 10, "number of results to return")
|
|
searchCmd.Flags().String("tags", "", "filter by tags (comma-separated)")
|
|
searchCmd.Flags().String("type", "", "filter by document type")
|
|
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)
|
|
}
|
|
|
|
func runSearch(cmd *cobra.Command, args []string) error {
|
|
top, _ := cmd.Flags().GetInt("top")
|
|
tags, _ := cmd.Flags().GetString("tags")
|
|
docType, _ := cmd.Flags().GetString("type")
|
|
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"] = splitTags(tags)
|
|
}
|
|
if docType != "" {
|
|
body["doc_type"] = docType
|
|
}
|
|
if ftsOnly {
|
|
body["fts_only"] = true
|
|
}
|
|
if vecOnly {
|
|
body["vec_only"] = true
|
|
}
|
|
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)
|
|
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)
|
|
}
|
|
|
|
var result 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"`
|
|
}
|
|
|
|
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 err := api.DecodeJSON(resp, &result); err != nil {
|
|
return fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
if len(result.Results) == 0 {
|
|
fmt.Println("No results found.")
|
|
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 (doc:%d)\n", i+1, r.Score, r.Title, r.DocumentID)
|
|
|
|
location := ""
|
|
if page, ok := r.ChunkMetadata["page"]; ok && page != nil {
|
|
location = fmt.Sprintf("Page %v", page)
|
|
}
|
|
if section, ok := r.ChunkMetadata["section_header"]; ok && section != nil {
|
|
if s, ok := section.(string); ok && s != "" {
|
|
if location != "" {
|
|
location += " / "
|
|
}
|
|
location += s
|
|
}
|
|
}
|
|
if location != "" {
|
|
fmt.Printf(" Location: %s\n", location)
|
|
}
|
|
if r.DocType != "" {
|
|
fmt.Printf(" Type: %s\n", r.DocType)
|
|
}
|
|
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 {
|
|
if i > 0 {
|
|
result += ", "
|
|
}
|
|
result += s
|
|
}
|
|
return result
|
|
}
|