65ee0f9c61
Search decoded the vector blob into a []float32 and JSON-unmarshalled the meta map for every row, then sorted all N and threw away everything past topK. Meta only ever matters for a survivor, and the sort answered a question a bounded heap answers cheaper. The scan still visits every row — that is what picks the winners. What it no longer does is allocate for a row it is about to discard. dotBlob reads the vector out of its stored bytes, so scoring costs nothing; a row is copied and its meta unmarshalled only once it has entered the topK. At 10000 rows and topK 10: 70.6ms to 26.8ms, 58MB to 17.5MB, 240k allocs to 60k. Recall is unchanged where it is measured. recall+onnx scores 22/32 with recall@1 70.4% and recall@3 85.2%, identical to before. TestMemoryStoreSearchMatchesNaive pins the ranking against the full-sort implementation it replaced, and TestDotBlobMatchesDot pins bit-identical scores, which the 0.008 gate margin demands. One behaviour did move: ties. sort.Slice is not stable, so equal scores were ordered arbitrarily; the heap now keeps the earliest. Under the real embedder an exact tie is a duplicate vector and nothing moved. Under the hash embedder the eval's floor uses, everything ties at 0 and that run's recall@3 went 74.1% to 81.5% — a number that measures tie order, not retrieval. recall@1 and false recall, the two the eval asserts, are unchanged on both runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
108 lines
3.5 KiB
Go
108 lines
3.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand"
|
|
"sort"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/memory"
|
|
)
|
|
|
|
// naiveSearch is the implementation Search replaced: score every row into a
|
|
// slice, sort the whole slice, truncate. It stays in the test file as the
|
|
// reference the bounded-heap version is judged against, because "recall must
|
|
// not change" is a claim about output, not about the code that produces it.
|
|
func naiveSearch(t *testing.T, m *MemoryStore, vec []float32, topK int) []memory.Result {
|
|
t.Helper()
|
|
rows, err := m.db.QueryContext(context.Background(),
|
|
`SELECT id, vec FROM memory_vectors WHERE id NOT LIKE ? ESCAPE '\'`,
|
|
escapeLike(memory.NonRecallPrefix)+"%")
|
|
if err != nil {
|
|
t.Fatalf("naive scan: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []memory.Result
|
|
for rows.Next() {
|
|
var id string
|
|
var blob []byte
|
|
if err := rows.Scan(&id, &blob); err != nil {
|
|
t.Fatalf("naive row: %v", err)
|
|
}
|
|
out = append(out, memory.Result{ID: id, Score: dot(vec, decodeVec(blob))})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("naive rows: %v", err)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
|
if topK < len(out) {
|
|
out = out[:topK]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestMemoryStoreSearchMatchesNaive is the constraint on V-643: the bounded
|
|
// heap must return exactly what a full scan and sort returned. Distinct random
|
|
// vectors, so no two scores tie and the ranking is total — a mismatch here is
|
|
// arithmetic or heap logic, not a tie-break difference.
|
|
func TestMemoryStoreSearchMatchesNaive(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := newMemTestStore(t).VectorMemory()
|
|
|
|
rng := rand.New(rand.NewSource(7))
|
|
const rows, dim = 500, 64
|
|
for i := 0; i < rows; i++ {
|
|
if err := m.Insert(ctx, fmt.Sprintf("n%d", i), randUnitVec(rng, dim), map[string]string{
|
|
"text": fmt.Sprintf("note %d", i),
|
|
}); err != nil {
|
|
t.Fatalf("Insert %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
for _, topK := range []int{1, 3, 10, 50, rows, rows + 100} {
|
|
q := randUnitVec(rng, dim)
|
|
got, err := m.Search(ctx, q, topK)
|
|
if err != nil {
|
|
t.Fatalf("Search topK=%d: %v", topK, err)
|
|
}
|
|
want := naiveSearch(t, m, q, topK)
|
|
if len(got) != len(want) {
|
|
t.Fatalf("topK=%d: got %d results, naive returned %d", topK, len(got), len(want))
|
|
}
|
|
for i := range want {
|
|
if got[i].ID != want[i].ID {
|
|
t.Errorf("topK=%d rank %d: got %q, naive says %q", topK, i, got[i].ID, want[i].ID)
|
|
}
|
|
if got[i].Score != want[i].Score {
|
|
t.Errorf("topK=%d rank %d (%s): score %v, naive says %v",
|
|
topK, i, got[i].ID, got[i].Score, want[i].Score)
|
|
}
|
|
}
|
|
if len(got) > 0 && got[0].Meta["text"] == "" {
|
|
t.Errorf("topK=%d: survivor %s has no meta — it was never unmarshalled", topK, got[0].ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDotBlobMatchesDot pins the claim in dotBlob's doc comment: reading the
|
|
// vector out of its stored bytes is bit-identical to decoding it first. Scores
|
|
// feed a gate with a 0.008 margin, so "close enough" is not the bar.
|
|
func TestDotBlobMatchesDot(t *testing.T) {
|
|
rng := rand.New(rand.NewSource(11))
|
|
for i := 0; i < 200; i++ {
|
|
a := randUnitVec(rng, 384)
|
|
b := randUnitVec(rng, 384)
|
|
if got, want := dotBlob(a, encodeVec(b)), dot(a, b); got != want {
|
|
t.Fatalf("dotBlob = %v, dot = %v", got, want)
|
|
}
|
|
}
|
|
// Length mismatch is 0 in both, and so is an empty vector.
|
|
if got := dotBlob([]float32{1, 0}, encodeVec([]float32{1, 0, 0})); got != 0 {
|
|
t.Errorf("mismatched lengths scored %v, want 0", got)
|
|
}
|
|
if got := dotBlob(nil, nil); got != 0 {
|
|
t.Errorf("empty scored %v, want 0", got)
|
|
}
|
|
}
|