fix(search): keep vector-only hybrid corpora queryable

This commit is contained in:
Andrey Kumanyaev
2026-08-14 20:02:42 +02:00
parent f3345706b5
commit 079e6cef76
4 changed files with 87 additions and 8 deletions
+36
View File
@@ -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.
+17 -2
View File
@@ -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
+27
View File
@@ -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},
+7 -6
View File
@@ -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.
//