Files
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

193 lines
5.8 KiB
Go

package query
import (
"context"
"math"
"sort"
"github.com/zzet/gortex/internal/embedding"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/search"
"github.com/zzet/gortex/internal/search/rerank"
)
// defaultCosineTopN bounds how many of the top ranked candidates the
// post-rerank cosine refinement re-scores. The stage embeds the query
// once and fetches this many stored vectors in one batch — keeping the
// bound small (a few dozen) means the refinement is a cheap O(topN)
// pass over an already-ranked head, never a re-rank of the whole pool.
const defaultCosineTopN = 32
// embedderProvider is the optional capability a search backend exposes
// when it carries a query embedder (the HybridBackend). Declared here
// so the query package can recover the embedder from whatever backend
// the engine currently holds without depending on the concrete type.
type embedderProvider interface {
Embedder() embedding.Provider
}
// backendEmbedder extracts the query embedder from a search backend,
// unwrapping one level of Swappable. Returns nil when no embedder is
// reachable — the caller treats that as "vector channel inactive" and
// skips the refinement entirely.
func backendEmbedder(b search.Backend) embedding.Provider {
if b == nil {
return nil
}
if ep, ok := b.(embedderProvider); ok {
if e := ep.Embedder(); e != nil {
return e
}
}
if sw, ok := b.(*search.Swappable); ok {
if ep, ok := sw.Inner().(embedderProvider); ok {
return ep.Embedder()
}
}
return nil
}
// refineByCosine re-orders the top of an already-ranked candidate slice
// by exact cosine similarity between the query embedding and each
// candidate's stored embedding — recovering the precise semantic
// distance the rank-based SemanticSignal throws away.
//
// It is deliberately best-effort and regression-safe: it is a no-op
// (returning cands untouched) whenever the vector channel is inactive,
// the store can't read embeddings back, the embedder is absent, or the
// query fails to embed. Only candidates whose stored vector matches the
// query embedding's dimension participate; a candidate with no stored
// vector keeps its rerank position and is never demoted below one that
// was scored.
//
// Only the top `topN` candidates are touched. The tail below topN keeps
// its rerank order, so the refinement can sharpen the head without
// disturbing the long fallback tail. The relative order of refined
// candidates among themselves is decided purely by cosine; ties fall
// back to the incoming rerank order for determinism.
func refineByCosine(
query string,
cands []*rerank.Candidate,
embedder embedding.Provider,
vectors graph.VectorSearcher,
topN int,
) []*rerank.Candidate {
if embedder == nil || vectors == nil || query == "" || len(cands) < 2 {
return cands
}
if topN <= 0 {
topN = defaultCosineTopN
}
head := topN
if head > len(cands) {
head = len(cands)
}
// Collect the candidate IDs in the head window and pull their
// stored vectors in one batch. An empty result means none of the
// head candidates were embedded — nothing to refine.
ids := make([]string, 0, head)
for _, c := range cands[:head] {
if c != nil && c.Node != nil && c.Node.ID != "" {
ids = append(ids, c.Node.ID)
}
}
if len(ids) == 0 {
return cands
}
stored := vectors.GetEmbeddings(ids)
if len(stored) == 0 {
return cands
}
// Embed the query exactly once. A failure here is not an error to
// the caller — search must still return the rerank order.
qVec, err := embedder.Embed(context.Background(), query)
if err != nil || len(qVec) == 0 {
return cands
}
qNorm := vecNorm(qVec)
if qNorm == 0 {
return cands
}
// Score every head candidate that has a dimension-matched stored
// vector. scored[i] is the cosine similarity (higher = closer) for
// cands[i]; candidates without a usable vector are left unscored
// and keep their incoming order relative to one another.
type scoredCand struct {
cand *rerank.Candidate
cosine float64
scored bool
order int // incoming rerank position, the stable tiebreak
}
window := make([]scoredCand, head)
anyScored := false
for i, c := range cands[:head] {
sc := scoredCand{cand: c, order: i}
if c != nil && c.Node != nil {
if vec, ok := stored[c.Node.ID]; ok && len(vec) == len(qVec) {
if cNorm := vecNorm(vec); cNorm > 0 {
sc.cosine = cosineSimilarity(qVec, vec, qNorm, cNorm)
sc.scored = true
anyScored = true
}
}
}
window[i] = sc
}
if !anyScored {
return cands
}
// Stable sort: scored candidates ahead of unscored ones, scored
// ranked by descending cosine, and every tie (including the whole
// unscored block) broken by the incoming rerank order so the result
// is deterministic and an unscored candidate never leapfrogs a
// scored one.
sort.SliceStable(window, func(a, b int) bool {
wa, wb := window[a], window[b]
if wa.scored != wb.scored {
return wa.scored // scored sorts before unscored
}
if wa.scored && wb.scored && wa.cosine != wb.cosine {
return wa.cosine > wb.cosine
}
return wa.order < wb.order
})
out := make([]*rerank.Candidate, 0, len(cands))
for _, w := range window {
out = append(out, w.cand)
}
out = append(out, cands[head:]...)
return out
}
// vecNorm returns the Euclidean (L2) norm of v as a float64.
func vecNorm(v []float32) float64 {
var sum float64
for _, f := range v {
d := float64(f)
sum += d * d
}
return math.Sqrt(sum)
}
// cosineSimilarity returns cosine_similarity(a, b) in [-1, 1] given
// precomputed norms; higher means more similar. a and b are assumed
// equal length with non-zero norms.
func cosineSimilarity(a, b []float32, aNorm, bNorm float64) float64 {
var dot float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
}
sim := dot / (aNorm * bNorm)
if sim > 1 {
sim = 1
} else if sim < -1 {
sim = -1
}
return sim
}