package memory import ( "context" "sort" "strings" "sync" ) // 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 } out = append(out, Record{ID: it.id, Vec: append([]float32(nil), it.vec...), Meta: it.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 } 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 { 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 }