Files
Maven/internal/store/memory.go
T
kami f02f3b55b6 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.
2026-08-01 14:12:43 +04:00

191 lines
6.6 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/binary"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/kami/maven/internal/memory"
)
// MemoryStore is the persistent backend for internal/memory's vector Store,
// sharing the main encrypted sqlite database so recall text (note/fact bodies
// carried in the meta blob) inherits at-rest encryption — a plaintext sidecar
// file would undercut store.OpenEncrypted. It survives daemon restarts, which
// the InMemoryStore does not: that was the last gap keeping long-term memory
// from being real.
//
// Search is brute-force cosine over every row loaded into memory — the same
// algorithm as InMemoryStore, just sourced from disk. At the single-user note+
// fact scale (thousands of rows, not millions) a full scan per query is well
// under a millisecond; an ANN index is the swap for later, behind this same
// interface. Vectors are assumed L2-normalized by the embedder, so cosine is a
// dot product.
type MemoryStore struct {
db *sql.DB
}
// VectorMemory returns a persistent memory.Store backed by this store's db.
// The returned store shares the db handle (single writer — the daemon), so it
// participates in the same encrypted tmpfs working copy and is sealed on Close.
func (s *Store) VectorMemory() *MemoryStore {
return &MemoryStore{db: s.db}
}
// compile-time check: MemoryStore satisfies the memory.Store interface, and the
// wider Catalog that speaker profiles need (enumerate by prefix, delete by id).
var _ memory.Store = (*MemoryStore)(nil)
var _ memory.Catalog = (*MemoryStore)(nil)
// Insert upserts a vector by id: a repeated id replaces the prior row rather
// than accumulating duplicates (the note/fact ids are stable and unique, so a
// re-index is an update, not a second copy — an improvement on InMemoryStore's
// append-always). meta is stored as a JSON object.
func (m *MemoryStore) Insert(ctx context.Context, id string, vec []float32, meta map[string]string) error {
metaJSON, err := json.Marshal(meta)
if err != nil {
return fmt.Errorf("memory: marshal meta: %w", err)
}
_, err = m.db.ExecContext(ctx,
`INSERT INTO memory_vectors (id, vec, meta, created_ts) VALUES (?,?,?,?)
ON CONFLICT(id) DO UPDATE SET vec = excluded.vec, meta = excluded.meta, created_ts = excluded.created_ts`,
id, encodeVec(vec), string(metaJSON), time.Now().UnixMilli())
if err != nil {
return fmt.Errorf("memory: insert %q: %w", id, err)
}
return nil
}
// Search returns the topK nearest rows by cosine similarity. A full scan; see
// the type doc for why that's fine at this scale.
//
// Rows under memory.NonRecallPrefix are excluded in SQL. They are speaker
// voiceprints sharing this table, and note recall must not rank them; see that
// constant for why the previous arrangement only appeared to do this.
func (m *MemoryStore) Search(ctx context.Context, vec []float32, topK int) ([]memory.Result, error) {
if topK <= 0 {
topK = 10
}
rows, err := m.db.QueryContext(ctx,
`SELECT id, vec, meta FROM memory_vectors WHERE id NOT LIKE ? ESCAPE '\'`,
escapeLike(memory.NonRecallPrefix)+"%")
if err != nil {
return nil, fmt.Errorf("memory: scan: %w", err)
}
defer rows.Close()
var out []memory.Result
for rows.Next() {
var id, metaJSON string
var blob []byte
if err := rows.Scan(&id, &blob, &metaJSON); err != nil {
return nil, fmt.Errorf("memory: row: %w", err)
}
meta := map[string]string{}
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
return nil, fmt.Errorf("memory: unmarshal meta for %q: %w", id, err)
}
out = append(out, memory.Result{ID: id, Score: dot(vec, decodeVec(blob)), Meta: meta})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: rows: %w", 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, nil
}
// ByPrefix returns every row whose id starts with prefix, vectors included.
//
// This is not a similarity query and deliberately does not score anything:
// listing the enrolled voices is a question about which rows exist, and asking
// it through Search would mean inventing a query vector to rank them by. The
// prefix is matched with LIKE against an escaped pattern, so a profile id
// containing % or _ cannot widen the match.
func (m *MemoryStore) ByPrefix(ctx context.Context, prefix string) ([]memory.Record, error) {
pattern := escapeLike(prefix) + "%"
rows, err := m.db.QueryContext(ctx,
`SELECT id, vec, meta FROM memory_vectors WHERE id LIKE ? ESCAPE '\'`, pattern)
if err != nil {
return nil, fmt.Errorf("memory: by prefix %q: %w", prefix, err)
}
defer rows.Close()
var out []memory.Record
for rows.Next() {
var id, metaJSON string
var blob []byte
if err := rows.Scan(&id, &blob, &metaJSON); err != nil {
return nil, fmt.Errorf("memory: row: %w", err)
}
meta := map[string]string{}
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
return nil, fmt.Errorf("memory: unmarshal meta for %q: %w", id, err)
}
out = append(out, memory.Record{ID: id, Vec: decodeVec(blob), Meta: meta})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: rows: %w", err)
}
return out, nil
}
// Delete removes one vector by id. A row that is not there is not an error —
// "forget this voice" is satisfied either way.
func (m *MemoryStore) Delete(ctx context.Context, id string) error {
if _, err := m.db.ExecContext(ctx, `DELETE FROM memory_vectors WHERE id = ?`, id); err != nil {
return fmt.Errorf("memory: delete %q: %w", id, err)
}
return nil
}
// escapeLike neutralises the LIKE wildcards in a literal prefix.
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// encodeVec serializes a float32 slice as little-endian IEEE-754 bytes (4 bytes
// per element) for the BLOB column.
func encodeVec(v []float32) []byte {
b := make([]byte, 4*len(v))
for i, f := range v {
binary.LittleEndian.PutUint32(b[4*i:], math.Float32bits(f))
}
return b
}
// decodeVec reverses encodeVec. A blob whose length isn't a multiple of 4 is
// truncated to the whole-element prefix (defensive — a well-formed row can't
// produce that).
func decodeVec(b []byte) []float32 {
n := len(b) / 4
v := make([]float32, n)
for i := 0; i < n; i++ {
v[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[4*i:]))
}
return v
}
// dot is the cosine similarity for L2-normalized vectors (mismatched lengths ⇒
// 0, matching internal/memory's cosine).
func dot(a, b []float32) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var sum float64
for i := range a {
sum += float64(a[i]) * float64(b[i])
}
return sum
}