maven: long-term memory vector-store interface (task 7)
- New internal/memory/ package: Store interface, InMemoryStore (cosine sim) - Tests: insert→search roundtrip, topK truncation, empty store, cosine edges - Wire into voice: memStore on reactiveHandler, insert note embedding after WriteNote - Memory Insert is best-effort, log-and-continue on error Co-Authored-By: opencode <opencode@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func NewInMemoryStore() *InMemoryStore {
|
||||
return &InMemoryStore{}
|
||||
}
|
||||
|
||||
func (s *InMemoryStore) Insert(_ context.Context, id string, vec []float32, meta map[string]string) error {
|
||||
s.mu.Lock()
|
||||
s.items = append(s.items, item{id: id, vec: vec, meta: meta})
|
||||
s.mu.Unlock()
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Insert(ctx, "", []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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user