memory: keep voiceprints out of note and fact recall

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.
This commit is contained in:
kami
2026-08-01 14:12:43 +04:00
parent 7ab9b48259
commit f02f3b55b6
4 changed files with 142 additions and 2 deletions
+25 -1
View File
@@ -7,6 +7,20 @@ import (
"sync"
)
// NonRecallPrefix — rows whose id starts with this are excluded from Search by
// every backend. Speaker voiceprints live in the same vector table as notes and
// facts (internal/speaker writes them under this prefix), and they are not
// recall material: a voiceprint has no text to read back and surfacing one as a
// note hit leaks a name attached to a biometric.
//
// Reading them through Catalog.ByPrefix was documented as what keeps them out
// of recall. It is not. It controls how speaker code reads its own rows and
// says nothing about what Search scores. What actually kept them out was that
// cosine returns 0 on a width mismatch, so a 192-dim voiceprint scored 0
// against a 384-dim query. That is a coincidence of two model choices — some
// x-vector exports are 384-dim — and not an invariant. This is the invariant.
const NonRecallPrefix = "speaker:"
// Result is a single search hit.
type Result struct {
ID string
@@ -93,7 +107,14 @@ func (s *InMemoryStore) ByPrefix(_ context.Context, prefix string) ([]Record, er
if !strings.HasPrefix(it.id, prefix) {
continue
}
out = append(out, Record{ID: it.id, Vec: append([]float32(nil), it.vec...), Meta: it.meta})
// Copy the metadata too. Returning it.meta by reference let a caller
// mutating the returned map edit the stored row, and the persistent
// backend unmarshals fresh, so the two disagreed.
meta := make(map[string]string, len(it.meta))
for k, v := range it.meta {
meta[k] = v
}
out = append(out, Record{ID: it.id, Vec: append([]float32(nil), it.vec...), Meta: meta})
}
return out, nil
}
@@ -127,6 +148,9 @@ func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Re
scores := make([]scored, 0, len(s.items))
for _, it := range s.items {
if strings.HasPrefix(it.id, NonRecallPrefix) {
continue
}
score := cosine(vec, it.vec)
scores = append(scores, scored{id: it.id, score: score, meta: it.meta})
}
+72
View File
@@ -80,3 +80,75 @@ func TestCosineEdgeCases(t *testing.T) {
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"])
}
}