fix(search): keep vector-only hybrid corpora queryable
This commit is contained in:
@@ -147,6 +147,42 @@ type unknownCountBackend struct{ warmStoreBackend }
|
||||
|
||||
func (b *unknownCountBackend) DocCount() (int, bool) { return 12345, false }
|
||||
|
||||
// TestGatherSymbolCandidates_VectorOnlyHybridUsesBackend proves that the corpus
|
||||
// gate recognizes a populated vector channel even when its text side is the
|
||||
// deliberately empty NullBackend. The concept query shares no substring with
|
||||
// the symbol name, so the candidate can only arrive through semantic search.
|
||||
func TestGatherSymbolCandidates_VectorOnlyHybridUsesBackend(t *testing.T) {
|
||||
g := graph.New()
|
||||
n := &graph.Node{
|
||||
ID: "app/snapshot.go::SnapshotCoordinator",
|
||||
Name: "SnapshotCoordinator",
|
||||
Kind: graph.KindType,
|
||||
RepoPrefix: "app",
|
||||
}
|
||||
g.AddNode(n)
|
||||
|
||||
vector := search.NewVector(2)
|
||||
vector.Add(n.ID, []float32{1, 0})
|
||||
backend := search.NewSwappable(search.NewNull())
|
||||
backend.ReplaceHybridVector(vector, &fakeEmbedder{queryVec: []float32{1, 0}})
|
||||
defer backend.Close()
|
||||
|
||||
engine := NewEngine(g)
|
||||
engine.SetSearch(backend)
|
||||
got := engine.GatherSymbolCandidates(
|
||||
"durable state persistence",
|
||||
5,
|
||||
QueryOptions{SkipInnerRerank: true},
|
||||
nil,
|
||||
)
|
||||
if len(got) != 1 || got[0].Node.ID != n.ID {
|
||||
t.Fatalf("vector-only hybrid was bypassed (substring fallback?); got %#v", got)
|
||||
}
|
||||
if got[0].TextRank != -1 || got[0].VectorRank != 0 {
|
||||
t.Fatalf("vector-only hit must preserve channel ranks; got %#v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatherSymbolCandidates_EmptyBackendStillFallsBack: no delta
|
||||
// count AND no doc count keeps the substring fallback for genuinely
|
||||
// empty in-process backends.
|
||||
|
||||
@@ -220,8 +220,23 @@ func (h *HybridBackend) dechunkVectorIDs(rawIDs []string, want int) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Count returns the text backend document count.
|
||||
func (h *HybridBackend) Count() int { return h.text.Count() }
|
||||
// Count reports the corpus visible to hybrid retrieval. A positive text count
|
||||
// remains authoritative; vector-only hybrids fall back to their vector count.
|
||||
// The channel counts are alternatives, not additive views of the same symbols.
|
||||
func (h *HybridBackend) Count() int {
|
||||
if h == nil {
|
||||
return 0
|
||||
}
|
||||
if h.text != nil {
|
||||
if count := h.text.Count(); count > 0 {
|
||||
return count
|
||||
}
|
||||
}
|
||||
if h.vector != nil {
|
||||
return h.vector.Count()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Close releases resources owned by the hybrid. The embedding provider and a
|
||||
// delegated vector searcher are externally owned; VectorBackend.Close only
|
||||
|
||||
@@ -7,6 +7,33 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type hybridCountTextBackend struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (*hybridCountTextBackend) Add(string, ...string) {}
|
||||
func (*hybridCountTextBackend) Remove(string) {}
|
||||
func (*hybridCountTextBackend) Search(string, int) []SearchResult { return nil }
|
||||
func (b *hybridCountTextBackend) Count() int { return b.count }
|
||||
func (*hybridCountTextBackend) Close() {}
|
||||
|
||||
func TestHybridCountUsesVectorWhenTextIsEmpty(t *testing.T) {
|
||||
vector := NewVector(2)
|
||||
vector.Add("semantic-only", []float32{1, 0})
|
||||
|
||||
hybrid := NewHybrid(NewNull(), vector, nil)
|
||||
require.Equal(t, 1, hybrid.Count(), "a populated vector channel is a searchable corpus")
|
||||
|
||||
textAuthoritative := NewHybrid(&hybridCountTextBackend{count: 3}, vector, nil)
|
||||
assert.Equal(t, 3, textAuthoritative.Count(), "text and vector counts describe the same corpus and must not be summed")
|
||||
|
||||
empty := NewHybrid(NewNull(), nil, nil)
|
||||
assert.Zero(t, empty.Count(), "an empty hybrid must report no corpus")
|
||||
|
||||
var nilHybrid *HybridBackend
|
||||
assert.Zero(t, nilHybrid.Count(), "a nil hybrid must report no corpus")
|
||||
}
|
||||
|
||||
func TestAlphaFuse_EqualWeights(t *testing.T) {
|
||||
textResults := []SearchResult{
|
||||
{ID: "a", Score: 10},
|
||||
|
||||
@@ -5,12 +5,13 @@ package search
|
||||
// Remove and Close are no-ops, Search returns no hits, and Count
|
||||
// always reports zero.
|
||||
//
|
||||
// Reporting an empty corpus is the point. The query Engine gates its
|
||||
// ranked path on the backend having something to answer with
|
||||
// (Engine.backendHasCorpus) and otherwise falls through to its own
|
||||
// substring scan over the graph, which needs no text index at all.
|
||||
// NullBackend therefore routes such a store onto exactly the path an
|
||||
// Engine with no search backend at all already takes in production —
|
||||
// Reporting an empty text corpus is the point. When used on its own, the query
|
||||
// Engine gates its ranked path on the backend having something to answer with
|
||||
// (Engine.backendHasCorpus) and otherwise falls through to its own substring
|
||||
// scan over the graph, which needs no text index at all. When NullBackend is the
|
||||
// text side of a HybridBackend, the hybrid's vector count can still establish a
|
||||
// searchable corpus. A bare NullBackend therefore routes such a store onto
|
||||
// exactly the path an Engine with no search backend at all takes in production —
|
||||
// see the Engine built by pkg/gortex.New, which wires query.NewEngine
|
||||
// and never calls SetSearch.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user