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.
155 lines
4.6 KiB
Go
155 lines
4.6 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
func TestInsertAndSearch(t *testing.T) {
|
|
s := NewInMemoryStore()
|
|
ctx := context.Background()
|
|
|
|
// Insert a few vectors
|
|
s.Insert(ctx, "doc1", []float32{1, 0, 0}, nil)
|
|
s.Insert(ctx, "doc2", []float32{0, 1, 0}, nil)
|
|
s.Insert(ctx, "doc3", []float32{0, 0, 1}, map[string]string{"source": "note"})
|
|
|
|
// Search for something close to doc1
|
|
results, err := s.Search(ctx, []float32{0.9, 0.1, 0}, 5)
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
if len(results) != 3 {
|
|
t.Fatalf("expected 3 results, got %d", len(results))
|
|
}
|
|
if results[0].ID != "doc1" {
|
|
t.Errorf("nearest should be doc1, got %s", results[0].ID)
|
|
}
|
|
if math.Abs(results[0].Score-0.9) > 0.01 {
|
|
t.Errorf("doc1 score should be near 0.9, got %f", results[0].Score)
|
|
}
|
|
|
|
// Check metadata preserved
|
|
if results[2].Meta["source"] != "note" {
|
|
t.Errorf("doc3 meta.source = %q, want note", results[2].Meta["source"])
|
|
}
|
|
}
|
|
|
|
func TestTopKTruncation(t *testing.T) {
|
|
s := NewInMemoryStore()
|
|
ctx := context.Background()
|
|
|
|
// Distinct ids: Insert upserts by id, so ten rows need ten ids.
|
|
for i := 0; i < 10; i++ {
|
|
s.Insert(ctx, fmt.Sprintf("n%d", i), []float32{float32(i) / 10, 0, 0}, nil)
|
|
}
|
|
|
|
results, err := s.Search(ctx, []float32{1, 0, 0}, 3)
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
if len(results) != 3 {
|
|
t.Fatalf("expected 3 results with topK=3, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestEmptyStore(t *testing.T) {
|
|
s := NewInMemoryStore()
|
|
results, err := s.Search(context.Background(), []float32{1, 0, 0}, 5)
|
|
if err != nil {
|
|
t.Fatalf("Search on empty store: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Fatalf("expected 0 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestCosineEdgeCases(t *testing.T) {
|
|
if c := cosine(nil, []float32{1}); c != 0 {
|
|
t.Errorf("nil first: expected 0, got %f", c)
|
|
}
|
|
if c := cosine([]float32{1}, nil); c != 0 {
|
|
t.Errorf("nil second: expected 0, got %f", c)
|
|
}
|
|
if c := cosine([]float32{}, []float32{}); c != 0 {
|
|
t.Errorf("empty: expected 0, got %f", c)
|
|
}
|
|
if c := cosine([]float32{1, 2}, []float32{1, 2}); math.Abs(c-5) > 0.001 {
|
|
t.Errorf("dot(1,2;1,2) = %f, want 5", c)
|
|
}
|
|
}
|
|
|
|
// The in-memory backend has to hide voiceprints from recall exactly like the
|
|
// persistent one, or a test passing here says nothing about the daemon.
|
|
func TestInMemorySearchSkipsVoiceprints(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewInMemoryStore()
|
|
if err := s.Insert(ctx, "note:1", []float32{0, 1}, map[string]string{"text": "заметка"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Insert(ctx, NonRecallPrefix+"kami", []float32{1, 0}, map[string]string{"name": "Ками"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := s.Search(ctx, []float32{1, 0}, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].ID != "note:1" {
|
|
t.Fatalf("Search = %+v, want just the note", got)
|
|
}
|
|
recs, err := s.ByPrefix(ctx, NonRecallPrefix)
|
|
if err != nil || len(recs) != 1 {
|
|
t.Fatalf("ByPrefix = %+v, %v; want the voiceprint", recs, err)
|
|
}
|
|
}
|
|
|
|
// ByPrefix hands back a copy of the metadata. It used to return the stored map
|
|
// by reference, so a caller editing a returned Record silently edited the row,
|
|
// and the persistent backend did not behave that way.
|
|
func TestInMemoryByPrefixCopiesMeta(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewInMemoryStore()
|
|
if err := s.Insert(ctx, "speaker:kami", []float32{1, 0}, map[string]string{"name": "Ками"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recs, err := s.ByPrefix(ctx, "speaker:")
|
|
if err != nil || len(recs) != 1 {
|
|
t.Fatalf("ByPrefix = %+v, %v", recs, err)
|
|
}
|
|
recs[0].Meta["name"] = "не Ками"
|
|
|
|
again, err := s.ByPrefix(ctx, "speaker:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again[0].Meta["name"] != "Ками" {
|
|
t.Errorf("the stored row was edited through the returned map: %q", again[0].Meta["name"])
|
|
}
|
|
}
|
|
|
|
// Insert upserts. This is not only a speaker-profile concern: every user of the
|
|
// in-memory store used to accumulate a second row for a re-indexed id, and the
|
|
// stale copy stayed searchable.
|
|
func TestInMemoryInsertUpserts(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewInMemoryStore()
|
|
if err := s.Insert(ctx, "note:1", []float32{1, 0}, map[string]string{"text": "старое"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Insert(ctx, "note:1", []float32{0, 1}, map[string]string{"text": "новое"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := s.Search(ctx, []float32{1, 0}, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("Search returned %d rows, want 1 (the old copy is still searchable)", len(got))
|
|
}
|
|
if got[0].Meta["text"] != "новое" {
|
|
t.Errorf("row = %q, want the replacement", got[0].Meta["text"])
|
|
}
|
|
}
|