dd91c6961c
The fact vector id carries a timestamp, so tapping the same key twice added a row instead of replacing one and recall then scored the old value against the current one. CorrectValue and VoidLatestFact already prune the key; an ordinary re-tap is the third way a value is superseded and it did not. actionFact now prunes fact:<key>: before inserting, so exactly one vector survives per key. InMemoryStore gained the matching DeletePrefix, because a test double that quietly kept both rows would pass a test the daemon fails. The prune is best-effort and silent on a store that cannot do it: the fact row is the truth, and a stale vector costs a wrong recall, not a lost fact.
208 lines
6.0 KiB
Go
208 lines
6.0 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"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
|
|
Score float64
|
|
Meta map[string]string
|
|
}
|
|
|
|
// Store is a vector memory interface.
|
|
type Store interface {
|
|
Insert(ctx context.Context, id string, vec []float32, meta map[string]string) error
|
|
Search(ctx context.Context, vec []float32, topK int) ([]Result, error)
|
|
}
|
|
|
|
// Record is a stored vector read back whole — id, vector and metadata — as
|
|
// opposed to Result, which is a search hit and carries a score instead of the
|
|
// vector.
|
|
type Record struct {
|
|
ID string
|
|
Vec []float32
|
|
Meta map[string]string
|
|
}
|
|
|
|
// Catalog is a Store that can also be enumerated by id prefix and deleted from.
|
|
//
|
|
// Search is not enough for every user of the vector table. Speaker profiles
|
|
// (internal/speaker) need to list exactly their own rows without scoring
|
|
// anything, because listing enrolled voices is not a similarity question, and
|
|
// they need Delete because a voiceprint is data about a person and "forget this
|
|
// voice" has to actually remove it. Note and fact recall use plain Store and are
|
|
// unaffected.
|
|
type Catalog interface {
|
|
Store
|
|
// ByPrefix returns every row whose id starts with prefix, in no particular
|
|
// order. An empty prefix returns everything.
|
|
ByPrefix(ctx context.Context, prefix string) ([]Record, error)
|
|
// Delete removes one row by id. Deleting a row that is not there is not an
|
|
// error: the caller asked for it to be gone and it is gone.
|
|
Delete(ctx context.Context, id string) error
|
|
}
|
|
|
|
// item is a single stored vector with metadata.
|
|
type item struct {
|
|
id string
|
|
vec []float32
|
|
meta map[string]string
|
|
}
|
|
|
|
// InMemoryStore implements Store with cosine similarity search.
|
|
type InMemoryStore struct {
|
|
mu sync.RWMutex
|
|
items []item
|
|
}
|
|
|
|
// compile-time check: InMemoryStore satisfies Catalog.
|
|
var _ Catalog = (*InMemoryStore)(nil)
|
|
|
|
func NewInMemoryStore() *InMemoryStore {
|
|
return &InMemoryStore{}
|
|
}
|
|
|
|
// Insert upserts by id, matching the persistent store.MemoryStore: a repeated
|
|
// id replaces the prior row rather than accumulating a second copy. Re-indexing
|
|
// a note is an update, and re-enrolling a voice must replace the old voiceprint
|
|
// rather than leave it searchable.
|
|
func (s *InMemoryStore) Insert(_ context.Context, id string, vec []float32, meta map[string]string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.items {
|
|
if s.items[i].id == id {
|
|
s.items[i] = item{id: id, vec: vec, meta: meta}
|
|
return nil
|
|
}
|
|
}
|
|
s.items = append(s.items, item{id: id, vec: vec, meta: meta})
|
|
return nil
|
|
}
|
|
|
|
// ByPrefix implements Catalog.
|
|
func (s *InMemoryStore) ByPrefix(_ context.Context, prefix string) ([]Record, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var out []Record
|
|
for _, it := range s.items {
|
|
if !strings.HasPrefix(it.id, prefix) {
|
|
continue
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// Delete implements Catalog.
|
|
func (s *InMemoryStore) Delete(_ context.Context, id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.items {
|
|
if s.items[i].id == id {
|
|
s.items = append(s.items[:i], s.items[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeletePrefix removes every row whose id starts with prefix and returns how
|
|
// many went. It matches store.MemoryStore's method so a test double and the
|
|
// real index agree about superseding a fact (Vikunja #493) — a double that
|
|
// silently kept the old vectors would pass a test the daemon fails.
|
|
func (s *InMemoryStore) DeletePrefix(_ context.Context, prefix string) (int64, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
kept := s.items[:0]
|
|
var n int64
|
|
for _, it := range s.items {
|
|
if strings.HasPrefix(it.id, prefix) {
|
|
n++
|
|
continue
|
|
}
|
|
kept = append(kept, it)
|
|
}
|
|
s.items = kept
|
|
return n, nil
|
|
}
|
|
|
|
func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Result, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if topK <= 0 {
|
|
topK = 10
|
|
}
|
|
|
|
type scored struct {
|
|
id string
|
|
score float64
|
|
meta map[string]string
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
sort.Slice(scores, func(i, j int) bool {
|
|
return scores[i].score > scores[j].score // descending
|
|
})
|
|
|
|
if topK > len(scores) {
|
|
topK = len(scores)
|
|
}
|
|
|
|
out := make([]Result, topK)
|
|
for i := 0; i < topK; i++ {
|
|
out[i] = Result{
|
|
ID: scores[i].id,
|
|
Score: scores[i].score,
|
|
Meta: scores[i].meta,
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// cosine similarity (dot product, assumes L2-normalized vectors).
|
|
func cosine(a, b []float32) float64 {
|
|
if len(a) != len(b) || len(a) == 0 {
|
|
return 0
|
|
}
|
|
var dot float64
|
|
for i := range a {
|
|
dot += float64(a[i]) * float64(b[i])
|
|
}
|
|
return dot
|
|
}
|