f02f3b55b6
Speaker profiles share the vector table with notes and facts. The doc comment said reading them through Catalog is what keeps recall from ranking a voiceprint. It is not. Catalog controls how speaker code reads its own rows and says nothing about Search, which scanned every row. What actually hid them was cosine returning 0 on a width mismatch, so a 192-dim ECAPA row scored 0 against a 384-dim query. Some x-vector exports are 384-dim, and one of those would have surfaced speaker:kami as a recall hit carrying the name of a person. Both backends now skip the prefix in Search, and the prefix is one constant in internal/memory so the store layer can filter on it without importing internal/speaker. Two more differences between the backends closed here. ByPrefix on the in-memory store returned the stored metadata map by reference, so a caller editing a returned Record edited the row, while the persistent one unmarshals fresh. And the append to upsert change in Insert is a fix in its own right, not only a speaker concern: any re-indexed id used to leave a second stale copy searchable. Found in review of #74.
142 lines
4.0 KiB
Go
142 lines
4.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/memory"
|
|
)
|
|
|
|
func newMemTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "mem_test.db")
|
|
st, err := Open(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
return st
|
|
}
|
|
|
|
func TestMemoryStoreInsertSearch(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := newMemTestStore(t).VectorMemory()
|
|
|
|
// three orthonormal-ish vectors; a query aligned with the second must rank it top.
|
|
if err := m.Insert(ctx, "a", []float32{1, 0, 0}, map[string]string{"text": "вода"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.Insert(ctx, "b", []float32{0, 1, 0}, map[string]string{"text": "сон", "type": "fact"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.Insert(ctx, "c", []float32{0, 0, 1}, map[string]string{"text": "еда"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := m.Search(ctx, []float32{0, 1, 0}, 2)
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("topK=2 returned %d results", len(got))
|
|
}
|
|
if got[0].ID != "b" {
|
|
t.Errorf("top hit = %q, want b", got[0].ID)
|
|
}
|
|
if got[0].Meta["text"] != "сон" || got[0].Meta["type"] != "fact" {
|
|
t.Errorf("meta not round-tripped: %v", got[0].Meta)
|
|
}
|
|
if got[0].Score < 0.99 {
|
|
t.Errorf("aligned vector score = %v, want ~1.0", got[0].Score)
|
|
}
|
|
}
|
|
|
|
func TestMemoryStoreUpsertReplaces(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := newMemTestStore(t).VectorMemory()
|
|
|
|
if err := m.Insert(ctx, "x", []float32{1, 0}, map[string]string{"text": "старое"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.Insert(ctx, "x", []float32{0, 1}, map[string]string{"text": "новое"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := m.Search(ctx, []float32{0, 1}, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("re-inserting the same id produced %d rows, want 1 (upsert)", len(got))
|
|
}
|
|
if got[0].Meta["text"] != "новое" {
|
|
t.Errorf("upsert kept the old value: %q", got[0].Meta["text"])
|
|
}
|
|
}
|
|
|
|
func TestMemoryStorePersistsAcrossReopen(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "persist.db")
|
|
|
|
st, err := Open(ctx, path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.VectorMemory().Insert(ctx, "k", []float32{1, 0, 0}, map[string]string{"text": "запомни"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// reopen the same file — the in-memory floor would have lost this.
|
|
st2, err := Open(ctx, path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = st2.Close() })
|
|
got, err := st2.VectorMemory().Search(ctx, []float32{1, 0, 0}, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].Meta["text"] != "запомни" {
|
|
t.Fatalf("memory did not survive reopen: %v", got)
|
|
}
|
|
}
|
|
|
|
// A voiceprint sharing the vector table must never rank as a note hit. The
|
|
// dimensions match here on purpose: what used to hide these rows was cosine
|
|
// returning 0 on a width mismatch, which is a property of two model choices and
|
|
// not of the store.
|
|
func TestMemoryStoreSearchSkipsVoiceprints(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := newMemTestStore(t).VectorMemory()
|
|
|
|
if err := m.Insert(ctx, "note:1", []float32{0, 1, 0}, map[string]string{"text": "заметка"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.Insert(ctx, memory.NonRecallPrefix+"kami", []float32{1, 0, 0}, map[string]string{"name": "Ками"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := m.Search(ctx, []float32{1, 0, 0}, 10)
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
for _, r := range got {
|
|
if strings.HasPrefix(r.ID, memory.NonRecallPrefix) {
|
|
t.Fatalf("recall returned a voiceprint: %+v", r)
|
|
}
|
|
}
|
|
if len(got) != 1 || got[0].ID != "note:1" {
|
|
t.Fatalf("Search = %+v, want just the note", got)
|
|
}
|
|
|
|
// The row is still there for the speaker code that owns it.
|
|
recs, err := m.ByPrefix(ctx, memory.NonRecallPrefix)
|
|
if err != nil || len(recs) != 1 {
|
|
t.Fatalf("ByPrefix = %+v, %v; want the voiceprint", recs, err)
|
|
}
|
|
}
|