项目文件夹

文件
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

352 行
12 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
package search
import (
"context"
"sort"
"time"
"github.com/zzet/gortex/internal/embedding"
"github.com/zzet/gortex/internal/search/rerank"
)
// HybridBackend combines text search (BM25/Bleve) with vector search (HNSW)
// using Reciprocal Rank Fusion (RRF) for result ranking.
//
// When autoAlpha is true (the default), Search() classifies the query as
// identifier-shaped or natural-language and applies an α-weighted fusion
// instead of even-weight RRF: identifier queries lean toward BM25 (small
// α) where exact-token matches are most reliable, NL queries balance both
// channels (larger α) so semantic similarity catches synonymous wording.
// Set autoAlpha=false via SetAutoAlpha to fall back to the original
// equal-weight RRF — useful for tests pinning the legacy ranking.
type HybridBackend struct {
text Backend
vector *VectorBackend
embedder embedding.Provider
k int // RRF constant (default 60)
autoAlpha bool
}
// NewHybrid creates a hybrid search backend with auto-α enabled.
func NewHybrid(text Backend, vector *VectorBackend, embedder embedding.Provider) *HybridBackend {
return &HybridBackend{
text: text,
vector: vector,
embedder: embedder,
k: 60,
autoAlpha: true,
}
}
// SetAutoAlpha toggles auto-α fusion. When false, Search() reverts to
// the original equal-weight RRF.
func (h *HybridBackend) SetAutoAlpha(on bool) { h.autoAlpha = on }
// AutoAlpha reports whether auto-α fusion is active.
func (h *HybridBackend) AutoAlpha() bool { return h.autoAlpha }
// Add indexes a symbol in both text and vector backends.
func (h *HybridBackend) Add(id string, fields ...string) {
h.text.Add(id, fields...)
}
// AddVector adds a vector for a symbol to the vector backend.
func (h *HybridBackend) AddVector(id string, vector []float32) {
h.vector.Add(id, vector)
}
// Remove removes a symbol from the text backend.
func (h *HybridBackend) Remove(id string) {
h.text.Remove(id)
// Note: coder/hnsw doesn't support removal. The vector index
// is rebuilt on full re-index. Stale vectors are harmless —
// they won't match graph nodes and will be filtered out.
}
// Search runs both text and vector search, fuses results with RRF
// (equal weight) when autoAlpha is off, or α-weighted RRF when on.
// Auto-α leans toward BM25 for identifier queries (where exact-token
// matches are the most reliable signal) and balances both channels
// for natural-language queries (where semantic similarity catches
// synonymous wording).
func (h *HybridBackend) Search(query string, limit int) []SearchResult {
textResults, vecIDs, _ := h.searchChannels(query, limit)
if len(vecIDs) == 0 {
if len(textResults) > limit {
return textResults[:limit]
}
return textResults
}
if h.autoAlpha {
return alphaFuse(textResults, vecIDs, rerank.AlphaFor(query), h.k, limit)
}
return rrfFuse(textResults, vecIDs, h.k, limit)
}
// SearchChannels returns the raw per-channel results — BM25 ranks
// (with scores) and the parallel vector-search ID list — without
// RRF fusion. The rerank pipeline calls this so each channel can
// contribute as a separate Signal instead of being collapsed into a
// single RRF score upstream of the rerank.
func (h *HybridBackend) SearchChannels(query string, limit int) (textResults []SearchResult, vectorIDs []string) {
textResults, vectorIDs, _ = h.searchChannels(query, limit)
return textResults, vectorIDs
}
// ChannelTimings carries per-phase wall-clock numbers from one
// SearchChannelsTimed call. Zero fields = phase didn't run (e.g.
// VectorSearchMS=0 when the vector index is empty).
type ChannelTimings struct {
TextMS int64
EmbedMS int64
VectorSearchMS int64
}
// VectorChannelOnly returns the vector-channel IDs (embedder + ANN
// search) WITHOUT re-running the text BM25 path. Used by the engine
// when the text channel has already been satisfied via the bundle
// path — the bundle returns Nodes + edges + scores already, so
// re-running text Search would double-pay the FTS cost. Returns
// nil and a zero ChannelTimings when the vector index is empty.
func (h *HybridBackend) VectorChannelOnly(query string, limit int) ([]string, ChannelTimings) {
var stats ChannelTimings
if h == nil || h.vector == nil || h.vector.Count() == 0 {
return nil, stats
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
embedStart := time.Now()
queryVec, err := h.embedder.Embed(ctx, query)
stats.EmbedMS = time.Since(embedStart).Milliseconds()
if err != nil || queryVec == nil {
return nil, stats
}
fetch := limit * 2
if h.vector.HasChunks() {
fetch = limit * 8
}
vecStart := time.Now()
rawVecIDs := h.vector.Search(queryVec, fetch)
stats.VectorSearchMS = time.Since(vecStart).Milliseconds()
return h.dechunkVectorIDs(rawVecIDs, limit*2), stats
}
// SearchChannelsTimed is SearchChannels with a per-phase timing
// breakdown so callers can prove which sub-step (text BM25 vs
// vector embed vs vector ANN) actually cost wall-clock time.
// Used by the MCP search_symbols handler's debug-log
// instrumentation; production callers that don't care just use
// SearchChannels.
func (h *HybridBackend) SearchChannelsTimed(query string, limit int) ([]SearchResult, []string, ChannelTimings) {
return h.searchChannels(query, limit)
}
// SearchSymbolBundles forwards to the text backend's bundle path when
// it implements SymbolBundleSearcherBackend. The vector channel does
// not participate — its IDs ride out through SearchChannels/Timed as
// before and the engine merges them with the bundle set. Returns nil
// when the text backend has no bundle support (no-op for the
// fallback path).
//
// HybridBackend wires both channels together in production, so the
// engine's bundle-detection step type-asserts on the outer
// HybridBackend through Swappable; this is what makes the bundle
// path available when the daemon's search is the BM25 + vector
// stack instead of a bare SymbolSearcherBackend.
func (h *HybridBackend) SearchSymbolBundles(query string, limit int) []SymbolBundle {
if h == nil || h.text == nil {
return nil
}
if bs, ok := h.text.(SymbolBundleSearcherBackend); ok {
return bs.SearchSymbolBundles(query, limit)
}
return nil
}
func (h *HybridBackend) searchChannels(query string, limit int) ([]SearchResult, []string, ChannelTimings) {
var stats ChannelTimings
tStart := time.Now()
textResults := h.text.Search(query, limit*2)
stats.TextMS = time.Since(tStart).Milliseconds()
var vecIDs []string
if h.vector.Count() > 0 {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
embedStart := time.Now()
queryVec, err := h.embedder.Embed(ctx, query)
stats.EmbedMS = time.Since(embedStart).Milliseconds()
if err == nil && queryVec != nil {
// When symbols are sub-chunked, one symbol owns several
// vectors, so a fixed top-k under-counts distinct symbols.
// Over-fetch, then de-chunk down to limit*2 distinct symbols.
fetch := limit * 2
if h.vector.HasChunks() {
fetch = limit * 8
}
vecStart := time.Now()
rawVecIDs := h.vector.Search(queryVec, fetch)
stats.VectorSearchMS = time.Since(vecStart).Milliseconds()
vecIDs = h.dechunkVectorIDs(rawVecIDs, limit*2)
}
}
return textResults, vecIDs, stats
}
// dechunkVectorIDs maps raw vector-search hits — which may be synthetic
// chunk IDs — back to their parent symbol IDs, drops duplicates so a
// symbol appears once, and truncates to want results. Rank order is
// preserved: the first (best-ranked) chunk hit fixes the symbol's
// position. When no symbol is chunked this is a cheap copy + truncate.
func (h *HybridBackend) dechunkVectorIDs(rawIDs []string, want int) []string {
out := make([]string, 0, len(rawIDs))
seen := make(map[string]struct{}, len(rawIDs))
for _, raw := range rawIDs {
symbolID, _ := h.vector.ResolveChunk(raw)
if _, dup := seen[symbolID]; dup {
continue
}
seen[symbolID] = struct{}{}
out = append(out, symbolID)
if len(out) >= want {
break
}
}
return out
}
// Count returns the text backend document count.
func (h *HybridBackend) Count() int { return h.text.Count() }
// Close releases resources.
func (h *HybridBackend) Close() {
h.text.Close()
}
// TextBackend returns the underlying text search backend.
func (h *HybridBackend) TextBackend() Backend { return h.text }
// VectorBackend returns the underlying vector search backend.
func (h *HybridBackend) VectorIndex() *VectorBackend { return h.vector }
// Embedder returns the embedding provider.
func (h *HybridBackend) Embedder() embedding.Provider { return h.embedder }
// SizeBytes returns the sum of text and vector backend sizes.
func (h *HybridBackend) SizeBytes() uint64 {
return BackendSize(h.text) + h.vector.SizeBytes()
}
// TextSizeBytes returns just the text backend's size — used by the
// daemon status report to split "search" from "vectors" visually.
func (h *HybridBackend) TextSizeBytes() uint64 { return BackendSize(h.text) }
// VectorSizeBytes returns just the vector backend's size.
func (h *HybridBackend) VectorSizeBytes() uint64 { return h.vector.SizeBytes() }
// alphaFuse combines text and vector results with an α-weighted blend
// of their reciprocal-rank contributions. Higher α gives the vector
// channel more weight (good for natural-language queries where
// semantic similarity catches synonyms); lower α gives BM25 more
// weight (good for identifier queries where exact-token matches are
// the most reliable signal).
//
// Formula:
//
// score(doc) = (1-α) × 1/(k+rank_text+1) + α × 1/(k+rank_vector+1)
//
// α=0 reduces to text-only; α=1 reduces to vector-only; α=0.5 is
// equivalent to rrfFuse with each channel halved (so absolute scores
// differ from rrfFuse but the relative ordering is the same).
func alphaFuse(textResults []SearchResult, vecIDs []string, alpha float64, k, limit int) []SearchResult {
if alpha < 0 {
alpha = 0
}
if alpha > 1 {
alpha = 1
}
textWeight := 1.0 - alpha
vecWeight := alpha
scores := make(map[string]float64)
for rank, r := range textResults {
scores[r.ID] += textWeight / float64(k+rank+1)
}
for rank, id := range vecIDs {
scores[id] += vecWeight / float64(k+rank+1)
}
type scored struct {
id string
score float64
}
results := make([]scored, 0, len(scores))
for id, score := range scores {
results = append(results, scored{id: id, score: score})
}
sort.Slice(results, func(i, j int) bool {
if results[i].score != results[j].score {
return results[i].score > results[j].score
}
// Stable secondary key: id ascending so identical-score
// runs ship in a deterministic order across calls.
return results[i].id < results[j].id
})
if len(results) > limit {
results = results[:limit]
}
out := make([]SearchResult, len(results))
for i, r := range results {
out[i] = SearchResult{ID: r.id, Score: r.score}
}
return out
}
// rrfFuse combines text and vector results using Reciprocal Rank Fusion.
// score(doc) = 1/(k+rank_text) + 1/(k+rank_vector)
func rrfFuse(textResults []SearchResult, vecIDs []string, k, limit int) []SearchResult {
scores := make(map[string]float64)
// Text ranks.
for rank, r := range textResults {
scores[r.ID] += 1.0 / float64(k+rank+1)
}
// Vector ranks.
for rank, id := range vecIDs {
scores[id] += 1.0 / float64(k+rank+1)
}
// Sort by combined RRF score.
type scored struct {
id string
score float64
}
var results []scored
for id, score := range scores {
results = append(results, scored{id: id, score: score})
}
sort.Slice(results, func(i, j int) bool {
if results[i].score != results[j].score {
return results[i].score > results[j].score
}
// Stable secondary key: equal-score runs ship in a fixed order.
return results[i].id < results[j].id
})
if len(results) > limit {
results = results[:limit]
}
// Convert back to SearchResult (use RRF score).
out := make([]SearchResult, len(results))
for i, r := range results {
out[i] = SearchResult{
ID: r.id,
Score: r.score,
}
}
return out
}