Files
Enterprise-Knowledge-Base/cli/cmd/search/search.go
T
nullkey fc5d16e331 feat(cli): top-level search command (PR-5)
The fourth and final v0.0 command — chunk hybrid retrieval, the demo
headline operation per ADR-3 (only one search command in the tree).

Maps to client.HybridSearch / GET /knowledge-bases/{id}/hybrid-search;
no SDK or server changes.

Flags:
- --kb (required): target knowledge base
- --top-k (default 8): max results
- --vector-threshold / --keyword-threshold: similarity floors
- --no-vector / --no-keyword: disable individual channels (mutually
  exclusive at the validation gate; checked before the SDK client is
  built so flag misuse fails fast)
- --json: emit envelope JSON (otherwise pretty list)

Service interface narrowed to just HybridSearch; tests inject fakes via
Factory.Client closure override.

Pretty rendering is a minimal text indent today; lipgloss tables arrive
in PR-3 of v0.2 (format/ split into presenters/style/tableprinter).
2026-05-07 23:19:46 +08:00

154 lines
5.3 KiB
Go

// Package search implements the top-level `weknora search` command — the
// chunk hybrid retrieval entry point (ADR-3: only one search command).
package search
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// Options is the runtime configuration of one search invocation.
type Options struct {
Query string
KBID string
TopK int
VectorThreshold float64
KeywordThreshold float64
NoVector bool
NoKeyword bool
JSONOut bool
}
// Service is the narrow SDK surface used by runSearch. *sdk.Client satisfies
// it; tests inject fakes via Factory.Client.
type Service interface {
HybridSearch(ctx context.Context, kbID string, params *sdk.SearchParams) ([]*sdk.SearchResult, error)
}
// NewCmdSearch builds `weknora search "<query>" --kb <id>`.
func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: `search "<query>"`,
Short: "Hybrid (vector + keyword) chunk retrieval against a knowledge base",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
// Validate user input BEFORE touching the SDK client. Flag misuse
// should fail fast with the clearer error rather than blocking on
// auth / config when the invocation itself is wrong.
if err := opts.validate(); err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runSearch(c.Context(), opts, cli)
},
}
cmd.Flags().StringVar(&opts.KBID, "kb", "", "Knowledge base ID to search (required)")
cmd.Flags().IntVar(&opts.TopK, "top-k", 8, "Maximum results to return")
cmd.Flags().Float64Var(&opts.VectorThreshold, "vector-threshold", 0, "Vector retrieval similarity floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().Float64Var(&opts.KeywordThreshold, "keyword-threshold", 0, "Keyword retrieval score floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().BoolVar(&opts.NoVector, "no-vector", false, "Disable the vector channel")
cmd.Flags().BoolVar(&opts.NoKeyword, "no-keyword", false, "Disable the keyword channel")
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
cmdutil.MustRequireFlag(cmd, "kb")
return cmd
}
// validate checks the option set before any SDK call. RunE invokes it before
// f.Client() so flag misuse fails fast; runSearch re-invokes it as a safety
// net for direct test calls that bypass RunE.
func (o *Options) validate() error {
if o.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if o.NoVector && o.NoKeyword {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--no-vector and --no-keyword cannot both be set")
}
return nil
}
func runSearch(ctx context.Context, opts *Options, svc Service) error {
if err := opts.validate(); err != nil {
return err
}
if svc == nil {
return cmdutil.NewError(cmdutil.CodeServerError, "search: no SDK client available")
}
params := &sdk.SearchParams{
QueryText: opts.Query,
MatchCount: opts.TopK,
VectorThreshold: opts.VectorThreshold,
KeywordThreshold: opts.KeywordThreshold,
DisableVectorMatch: opts.NoVector,
DisableKeywordsMatch: opts.NoKeyword,
}
results, err := svc.HybridSearch(ctx, opts.KBID, params)
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "hybrid search")
}
// match_count is the server's *primary-match* cap — after that, the
// service appends parent / nearby / relation chunks as context
// enrichment, so the wire response can exceed TopK. CLIs like gh /
// kubectl / aws treat their `--limit`-style flag as a hard return-count
// cap; honor that contract by trimming on the client. Recall isn't
// affected because the server's internal retrieval pool is already
// max(MatchCount*5, 50).
if opts.TopK > 0 && len(results) > opts.TopK {
results = results[:opts.TopK]
}
if opts.JSONOut {
return cmdutil.NewJSONExporter().Write(iostreams.IO.Out, format.Success(results, &format.Meta{
KBID: opts.KBID,
}))
}
return renderHumanResults(results, opts.KBID)
}
// renderHumanResults prints a compact pretty list to stdout.
//
// Lipgloss tables arrive in PR-3; the inline indent helper here is a minimal
// stopgap so search is usable in a terminal without color today.
func renderHumanResults(results []*sdk.SearchResult, kbID string) error {
if len(results) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no results)")
return nil
}
fmt.Fprintf(iostreams.IO.Out, "%d result(s) from kb=%s:\n\n", len(results), kbID)
for i, r := range results {
fmt.Fprintf(iostreams.IO.Out, "[%d] score=%.3f", i+1, r.Score)
if r.KnowledgeID != "" {
fmt.Fprintf(iostreams.IO.Out, " doc=%s", r.KnowledgeID)
}
fmt.Fprintln(iostreams.IO.Out)
fmt.Fprintln(iostreams.IO.Out, indent(strings.TrimSpace(r.Content), " "))
fmt.Fprintln(iostreams.IO.Out)
}
return nil
}
// indent prefixes each line of s with the given prefix.
func indent(s, prefix string) string {
if s == "" {
return ""
}
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Join(lines, "\n")
}