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

293 lines
10 KiB
Go

package query
import (
"context"
"errors"
"testing"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/search"
"github.com/zzet/gortex/internal/search/rerank"
)
// fakeEmbedder is a minimal embedding.Provider for the refinement
// tests. Embed returns the configured query vector; the batch / dim /
// close methods are present only to satisfy the interface.
type fakeEmbedder struct {
queryVec []float32
err error
}
func (f *fakeEmbedder) Embed(_ context.Context, _ string) ([]float32, error) {
if f.err != nil {
return nil, f.err
}
return f.queryVec, nil
}
func (f *fakeEmbedder) EmbedBatch(_ context.Context, texts []string) ([][]float32, error) {
if f.err != nil {
return nil, f.err
}
out := make([][]float32, len(texts))
for i := range out {
out[i] = f.queryVec
}
return out, nil
}
func (f *fakeEmbedder) Dimensions() int { return len(f.queryVec) }
func (f *fakeEmbedder) Close() error { return nil }
// fakeVectorSearcher returns stored vectors from a fixed map and tracks
// the IDs GetEmbeddings was asked for, so a test can assert the stage
// only fetched the bounded head window.
type fakeVectorSearcher struct {
vecs map[string][]float32
askedFor []string
getCalled bool
returnNone bool // simulate "store has no vectors at all"
}
func (f *fakeVectorSearcher) UpsertEmbedding(string, []float32) error { return nil }
func (f *fakeVectorSearcher) BulkUpsertEmbeddings([]graph.VectorItem) error { return nil }
func (f *fakeVectorSearcher) BuildVectorIndex(int) error { return nil }
func (f *fakeVectorSearcher) SimilarTo([]float32, int) ([]graph.VectorHit, error) {
return nil, nil
}
func (f *fakeVectorSearcher) GetEmbeddings(ids []string) map[string][]float32 {
f.getCalled = true
f.askedFor = append(f.askedFor, ids...)
if f.returnNone {
return map[string][]float32{}
}
out := make(map[string][]float32, len(ids))
for _, id := range ids {
if v, ok := f.vecs[id]; ok {
out[id] = v
}
}
return out
}
// cand builds a candidate at a given incoming rerank position.
func cand(id string, textRank int) *rerank.Candidate {
return &rerank.Candidate{
Node: &graph.Node{ID: id, Name: id},
TextRank: textRank,
VectorRank: -1,
}
}
func ids(cands []*rerank.Candidate) []string {
out := make([]string, len(cands))
for i, c := range cands {
out[i] = c.Node.ID
}
return out
}
// TestRefineByCosine_ReordersByCosine asserts the stage reorders the
// head by exact cosine: the candidate whose stored vector points most
// nearly the same direction as the query embedding rises to the top,
// even though it started last in the rerank order.
func TestRefineByCosine_ReordersByCosine(t *testing.T) {
query := []float32{1, 0, 0}
// "c" is the closest to the query direction, then "b", then "a";
// the incoming rerank order is the reverse (a, b, c), so a correct
// cosine refinement must invert it.
vs := &fakeVectorSearcher{vecs: map[string][]float32{
"a": {0, 1, 0}, // orthogonal — cosine 0
"b": {1, 1, 0}, // 45° — cosine ~0.707
"c": {10, 0.1, 0}, // almost parallel — cosine ~1
}}
emb := &fakeEmbedder{queryVec: query}
in := []*rerank.Candidate{cand("a", 0), cand("b", 1), cand("c", 2)}
out := refineByCosine("q", in, emb, vs, 10)
got := ids(out)
want := []string{"c", "b", "a"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("cosine refinement order = %v, want %v", got, want)
}
}
}
// TestRefineByCosine_NoopWhenVectorsAbsent asserts the stage is a strict
// no-op (order preserved) when the store has no vectors for the
// candidates — the regression-safety contract.
func TestRefineByCosine_NoopWhenVectorsAbsent(t *testing.T) {
emb := &fakeEmbedder{queryVec: []float32{1, 0, 0}}
t.Run("store returns empty", func(t *testing.T) {
vs := &fakeVectorSearcher{returnNone: true}
in := []*rerank.Candidate{cand("a", 0), cand("b", 1), cand("c", 2)}
out := refineByCosine("q", in, emb, vs, 10)
if got := ids(out); got[0] != "a" || got[1] != "b" || got[2] != "c" {
t.Fatalf("expected order preserved, got %v", got)
}
})
t.Run("no matching ids", func(t *testing.T) {
vs := &fakeVectorSearcher{vecs: map[string][]float32{"zzz": {1, 0, 0}}}
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
out := refineByCosine("q", in, emb, vs, 10)
if got := ids(out); got[0] != "a" || got[1] != "b" {
t.Fatalf("expected order preserved, got %v", got)
}
})
t.Run("nil vector searcher", func(t *testing.T) {
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
out := refineByCosine("q", in, emb, nil, 10)
if got := ids(out); got[0] != "a" || got[1] != "b" {
t.Fatalf("expected order preserved, got %v", got)
}
})
t.Run("nil embedder", func(t *testing.T) {
vs := &fakeVectorSearcher{vecs: map[string][]float32{"a": {1, 0, 0}}}
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
out := refineByCosine("q", in, nil, vs, 10)
if got := ids(out); got[0] != "a" || got[1] != "b" {
t.Fatalf("expected order preserved, got %v", got)
}
})
}
// TestRefineByCosine_NoopWhenQueryEmbedFails asserts a query embed
// failure leaves the order untouched rather than erroring out.
func TestRefineByCosine_NoopWhenQueryEmbedFails(t *testing.T) {
vs := &fakeVectorSearcher{vecs: map[string][]float32{
"a": {1, 0, 0}, "b": {0, 1, 0},
}}
emb := &fakeEmbedder{err: errors.New("embed boom")}
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
out := refineByCosine("q", in, emb, vs, 10)
if got := ids(out); got[0] != "a" || got[1] != "b" {
t.Fatalf("expected order preserved on embed failure, got %v", got)
}
}
// TestRefineByCosine_UnscoredCandidatesKeepTailOrder asserts that a
// candidate with no stored vector is never promoted above a scored one
// and that unscored candidates keep their relative incoming order.
func TestRefineByCosine_UnscoredCandidatesKeepTailOrder(t *testing.T) {
query := []float32{1, 0, 0}
// Only "b" and "d" have stored vectors; "a" and "c" do not. "d" is
// closer to the query than "b". The scored pair must sort to the
// front by cosine (d, b); the unscored pair must follow in their
// incoming order (a, c).
vs := &fakeVectorSearcher{vecs: map[string][]float32{
"b": {1, 1, 0}, // ~0.707
"d": {1, 0.05, 0}, // ~1.0
}}
emb := &fakeEmbedder{queryVec: query}
in := []*rerank.Candidate{cand("a", 0), cand("b", 1), cand("c", 2), cand("d", 3)}
out := refineByCosine("q", in, emb, vs, 10)
got := ids(out)
want := []string{"d", "b", "a", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
}
// TestRefineByCosine_OnlyTouchesTopN asserts the stage bounds its work
// to the top-N head: candidates beyond the bound keep their position
// and their vectors are never fetched.
func TestRefineByCosine_OnlyTouchesTopN(t *testing.T) {
query := []float32{1, 0, 0}
vs := &fakeVectorSearcher{vecs: map[string][]float32{
"a": {0, 1, 0}, // orthogonal
"b": {10, 0.1, 0}, // near-parallel
"c": {5, 0, 0}, // parallel but outside the window
}}
emb := &fakeEmbedder{queryVec: query}
in := []*rerank.Candidate{cand("a", 0), cand("b", 1), cand("c", 2)}
// topN = 2 → only "a" and "b" participate; "c" stays put.
out := refineByCosine("q", in, emb, vs, 2)
got := ids(out)
want := []string{"b", "a", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
// The store must only have been asked for the head window IDs.
for _, asked := range vs.askedFor {
if asked == "c" {
t.Fatalf("GetEmbeddings was asked for an out-of-window id %q", asked)
}
}
}
// TestRefineByCosine_NoopBelowTwoCandidates asserts the stage does not
// run (and never even embeds the query) for a trivial candidate set.
func TestRefineByCosine_NoopBelowTwoCandidates(t *testing.T) {
vs := &fakeVectorSearcher{vecs: map[string][]float32{"a": {1, 0, 0}}}
emb := &fakeEmbedder{queryVec: []float32{1, 0, 0}}
in := []*rerank.Candidate{cand("a", 0)}
out := refineByCosine("q", in, emb, vs, 10)
if len(out) != 1 || out[0].Node.ID != "a" {
t.Fatalf("single-candidate set must be returned unchanged")
}
if vs.getCalled {
t.Fatalf("GetEmbeddings must not be called for a sub-2 candidate set")
}
}
// emptyTextBackend is a no-op search.Backend used only to construct a
// HybridBackend in the embedder-unwrap test.
type emptyTextBackend struct{}
func (emptyTextBackend) Add(string, ...string) {}
func (emptyTextBackend) Remove(string) {}
func (emptyTextBackend) Search(string, int) []search.SearchResult { return nil }
func (emptyTextBackend) Count() int { return 0 }
func (emptyTextBackend) Close() {}
// TestBackendEmbedder_UnwrapsSwappable asserts the embedder resolver
// finds the query embedder through the production backend chain
// (Swappable wrapping a HybridBackend) — the wiring the handler relies
// on — and returns nil for a plain text backend that carries none.
func TestBackendEmbedder_UnwrapsSwappable(t *testing.T) {
emb := &fakeEmbedder{queryVec: []float32{1, 0, 0}}
hybrid := search.NewHybrid(emptyTextBackend{}, search.NewVector(3), emb)
sw := search.NewSwappable(hybrid)
if got := backendEmbedder(sw); got != emb {
t.Fatalf("backendEmbedder must unwrap Swappable->Hybrid to the embedder, got %v", got)
}
if got := backendEmbedder(hybrid); got != emb {
t.Fatalf("backendEmbedder must read the embedder off a bare Hybrid, got %v", got)
}
if got := backendEmbedder(search.NewSwappable(emptyTextBackend{})); got != nil {
t.Fatalf("backendEmbedder must return nil for a text-only backend, got %v", got)
}
if got := backendEmbedder(nil); got != nil {
t.Fatalf("backendEmbedder(nil) must be nil, got %v", got)
}
}
// TestEngineRefineByCosine_NoopWhenStoreLacksVectors asserts the engine
// method no-ops cleanly when the underlying graph reader does not
// implement graph.VectorSearcher (the in-memory store) — proving the
// production wiring can never panic on a non-vector backend.
func TestEngineRefineByCosine_NoopWhenStoreLacksVectors(t *testing.T) {
e := NewEngine(graph.New()) // *graph.Graph does NOT implement VectorSearcher
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
out := e.RefineByCosine("q", in, 0)
if got := ids(out); got[0] != "a" || got[1] != "b" {
t.Fatalf("engine refine must be a no-op without a vector store, got %v", got)
}
}