feat(leann-phase2): implement hybrid vector storage and graph-based search

- [x] Add AST-aware code chunking for Go, Python, and TypeScript using tree-sitter
- [x] Implement LEANN-inspired hybrid vector storage with hub detection and selective embedding storage (60-80% savings)
- [x] Add observation relationship graph with CSR format and edge detection (file overlap, semantic similarity, temporal, concept)
- [x] Implement graph-aware search with two-level traversal and relationship-based ranking
- [x] Add auto-tuning system for dynamic hub threshold adjustment based on query performance
- [x] Add comprehensive metrics tracking for vector storage, queries, latency, and graph traversals
- [x] Update configuration system with graph and hybrid storage settings
- [x] Add graph stats and vector metrics endpoints to worker service
- [x] Enhance UI sidebar with advanced metrics display and graph visualization
- [x] Optimize struct field alignment throughout codebase for memory efficiency
- [x] Update documentation with LEANN Phase 2 features and performance benefits
- [x] Add tree-sitter dependency for AST parsing
This commit is contained in:
2026-01-07 20:43:10 +00:00
parent 7ab4b07cf2
commit 74ae8ed4c1
83 changed files with 5190 additions and 603 deletions
+309
View File
@@ -0,0 +1,309 @@
package hybrid
import (
"context"
"sync"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/rs/zerolog/log"
)
// AutoTuner dynamically adjusts hub threshold based on query performance
type AutoTuner struct {
ctx context.Context
client *Client
cancel context.CancelFunc
latencies []time.Duration
wg sync.WaitGroup
queries int64
targetLatency time.Duration
adjustPeriod time.Duration
minThreshold int
maxThreshold int
adjustments int
latenciesMu sync.Mutex
}
// AutoTunerConfig configures the auto-tuner
type AutoTunerConfig struct {
TargetLatency time.Duration // Target p95 latency (default: 50ms)
MinThreshold int // Min hub threshold (default: 2)
MaxThreshold int // Max hub threshold (default: 20)
AdjustPeriod time.Duration // Adjustment frequency (default: 5min)
}
// DefaultAutoTunerConfig returns sensible defaults
func DefaultAutoTunerConfig() AutoTunerConfig {
return AutoTunerConfig{
TargetLatency: 50 * time.Millisecond,
MinThreshold: 2,
MaxThreshold: 20,
AdjustPeriod: 5 * time.Minute,
}
}
// NewAutoTuner creates a new auto-tuner for the hybrid client
func NewAutoTuner(client *Client, cfg AutoTunerConfig) *AutoTuner {
ctx, cancel := context.WithCancel(context.Background())
tuner := &AutoTuner{
client: client,
targetLatency: cfg.TargetLatency,
minThreshold: cfg.MinThreshold,
maxThreshold: cfg.MaxThreshold,
adjustPeriod: cfg.AdjustPeriod,
latencies: make([]time.Duration, 0, 1000),
ctx: ctx,
cancel: cancel,
}
return tuner
}
// Start begins auto-tuning in the background
func (a *AutoTuner) Start() {
a.wg.Add(1)
go a.tuningLoop()
log.Info().
Dur("target_latency", a.targetLatency).
Int("min_threshold", a.minThreshold).
Int("max_threshold", a.maxThreshold).
Dur("adjust_period", a.adjustPeriod).
Msg("Auto-tuner started")
}
// Stop stops the auto-tuner
func (a *AutoTuner) Stop() {
a.cancel()
a.wg.Wait()
log.Info().Msg("Auto-tuner stopped")
}
// RecordQuery records a query latency for analysis
func (a *AutoTuner) RecordQuery(latency time.Duration) {
a.latenciesMu.Lock()
defer a.latenciesMu.Unlock()
a.queries++
a.latencies = append(a.latencies, latency)
// Keep only recent queries (last 1000)
if len(a.latencies) > 1000 {
a.latencies = a.latencies[len(a.latencies)-1000:]
}
}
// tuningLoop periodically adjusts hub threshold
func (a *AutoTuner) tuningLoop() {
defer a.wg.Done()
ticker := time.NewTicker(a.adjustPeriod)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.adjustThreshold()
}
}
}
// adjustThreshold analyzes recent queries and adjusts hub threshold
func (a *AutoTuner) adjustThreshold() {
a.latenciesMu.Lock()
defer a.latenciesMu.Unlock()
if len(a.latencies) < 10 {
// Not enough data yet
return
}
// Calculate p95 latency
p95 := calculateP95(a.latencies)
currentThreshold := a.client.hubThreshold
log.Debug().
Dur("p95_latency", p95).
Dur("target_latency", a.targetLatency).
Int("current_threshold", currentThreshold).
Int("queries", len(a.latencies)).
Msg("Auto-tuner evaluating performance")
// Determine adjustment direction
var newThreshold int
if p95 > a.targetLatency {
// Too slow - lower threshold (more hubs = faster queries)
adjustment := calculateAdjustment(p95, a.targetLatency)
newThreshold = currentThreshold - adjustment
if newThreshold < a.minThreshold {
newThreshold = a.minThreshold
}
log.Info().
Dur("p95", p95).
Int("old_threshold", currentThreshold).
Int("new_threshold", newThreshold).
Msg("Auto-tuner: Lowering hub threshold (too slow)")
} else if p95 < a.targetLatency*8/10 {
// Too fast - raise threshold (fewer hubs = more savings)
// Only adjust if significantly faster (20% margin)
adjustment := calculateAdjustment(a.targetLatency, p95)
newThreshold = currentThreshold + adjustment
if newThreshold > a.maxThreshold {
newThreshold = a.maxThreshold
}
log.Info().
Dur("p95", p95).
Int("old_threshold", currentThreshold).
Int("new_threshold", newThreshold).
Msg("Auto-tuner: Raising hub threshold (room for savings)")
} else {
// Within acceptable range, no adjustment needed
log.Debug().
Dur("p95", p95).
Int("threshold", currentThreshold).
Msg("Auto-tuner: Performance acceptable, no adjustment")
return
}
// Apply adjustment
if newThreshold != currentThreshold {
a.client.hubThreshold = newThreshold
a.adjustments++
// Clear latency history after adjustment
a.latencies = make([]time.Duration, 0, 1000)
log.Info().
Int("threshold", newThreshold).
Int("total_adjustments", a.adjustments).
Msg("Hub threshold adjusted by auto-tuner")
}
}
// calculateP95 computes the 95th percentile latency
func calculateP95(latencies []time.Duration) time.Duration {
if len(latencies) == 0 {
return 0
}
// Sort latencies
sorted := make([]time.Duration, len(latencies))
copy(sorted, latencies)
// Simple bubble sort (small dataset)
n := len(sorted)
for i := 0; i < n-1; i++ {
for j := 0; j < n-i-1; j++ {
if sorted[j] > sorted[j+1] {
sorted[j], sorted[j+1] = sorted[j+1], sorted[j]
}
}
}
// Return 95th percentile
idx := int(float64(len(sorted)) * 0.95)
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
// calculateAdjustment determines how much to adjust threshold
func calculateAdjustment(actual, target time.Duration) int {
// Calculate percentage difference
diff := float64(actual-target) / float64(target)
// Adjust more aggressively for larger differences
if diff > 0.5 || diff < -0.5 {
return 3 // Large adjustment
} else if diff > 0.2 || diff < -0.2 {
return 2 // Medium adjustment
}
return 1 // Small adjustment
}
// GetStats returns auto-tuner statistics
func (a *AutoTuner) GetStats() AutoTunerStats {
a.latenciesMu.Lock()
defer a.latenciesMu.Unlock()
stats := AutoTunerStats{
CurrentThreshold: a.client.hubThreshold,
TargetLatency: a.targetLatency,
TotalQueries: a.queries,
TotalAdjustments: a.adjustments,
RecentQueries: len(a.latencies),
}
if len(a.latencies) > 0 {
stats.P95Latency = calculateP95(a.latencies)
// Calculate average
var total time.Duration
for _, lat := range a.latencies {
total += lat
}
stats.AvgLatency = total / time.Duration(len(a.latencies))
}
return stats
}
// AutoTunerStats contains auto-tuner statistics
type AutoTunerStats struct {
CurrentThreshold int
TargetLatency time.Duration
P95Latency time.Duration
AvgLatency time.Duration
TotalQueries int64
TotalAdjustments int
RecentQueries int
}
// AutoTunedClient wraps Client with automatic performance tuning
type AutoTunedClient struct {
*Client
tuner *AutoTuner
}
// Query wraps the underlying Query call with latency tracking
func (a *AutoTunedClient) Query(ctx context.Context, query string, limit int, where map[string]any) ([]sqlitevec.QueryResult, error) {
start := time.Now()
results, err := a.Client.Query(ctx, query, limit, where)
latency := time.Since(start)
a.tuner.RecordQuery(latency)
return results, err
}
// WithAutoTuning wraps a hybrid client with auto-tuning enabled
func WithAutoTuning(client *Client, cfg AutoTunerConfig) *AutoTunedClient {
tuner := NewAutoTuner(client, cfg)
tuner.Start()
return &AutoTunedClient{
Client: client,
tuner: tuner,
}
}
// Stop stops the auto-tuner
func (a *AutoTunedClient) StopTuning() {
a.tuner.Stop()
}
+515
View File
@@ -0,0 +1,515 @@
// Package hybrid provides LEANN-inspired selective vector storage for claude-mnemonic.
//
// This package implements a hybrid storage strategy where frequently-accessed
// observations ("hubs") have their embeddings stored, while infrequently-accessed
// observations have their embeddings recomputed on-demand during search.
//
// This approach reduces storage by 60-80% with minimal impact on search latency (<50ms).
package hybrid
import (
"context"
"database/sql"
"fmt"
"math"
"sync"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/rs/zerolog/log"
)
// VectorStorageStrategy defines how embeddings are stored/computed
type VectorStorageStrategy int
const (
// StorageAlways stores all embeddings (current behavior, backwards compatible)
StorageAlways VectorStorageStrategy = iota
// StorageHub stores only frequently-accessed "hub" embeddings (recommended)
StorageHub
// StorageOnDemand recomputes all embeddings during search (maximum savings)
StorageOnDemand
)
// Client wraps sqlitevec.Client with selective storage logic
type Client struct {
base *sqlitevec.Client
db *sql.DB
embedSvc *embedding.Service
accessCount map[string]int
lastAccess map[string]time.Time
contentCache map[string]string
strategy VectorStorageStrategy
hubThreshold int
mu sync.RWMutex
cacheMu sync.RWMutex
}
// Config for hybrid client
type Config struct {
BaseClient *sqlitevec.Client
DB *sql.DB
EmbedSvc *embedding.Service
Strategy VectorStorageStrategy
HubThreshold int // Default: 5 accesses
}
// NewClient creates a new hybrid vector client
func NewClient(cfg Config) *Client {
if cfg.HubThreshold <= 0 {
cfg.HubThreshold = 5
}
log.Info().
Str("strategy", strategyToString(cfg.Strategy)).
Int("hub_threshold", cfg.HubThreshold).
Msg("Initializing LEANN hybrid vector client")
return &Client{
base: cfg.BaseClient,
db: cfg.DB,
embedSvc: cfg.EmbedSvc,
strategy: cfg.Strategy,
hubThreshold: cfg.HubThreshold,
accessCount: make(map[string]int),
lastAccess: make(map[string]time.Time),
contentCache: make(map[string]string),
}
}
// AddDocuments implements selective storage based on strategy
func (c *Client) AddDocuments(ctx context.Context, docs []sqlitevec.Document) error {
if len(docs) == 0 {
return nil
}
switch c.strategy {
case StorageAlways:
// Use existing implementation - store all embeddings
return c.base.AddDocuments(ctx, docs)
case StorageHub:
// Store only hub candidates
return c.addDocumentsSelective(ctx, docs)
case StorageOnDemand:
// Don't store embeddings, only cache content
return c.cacheDocuments(ctx, docs)
default:
return c.base.AddDocuments(ctx, docs)
}
}
// addDocumentsSelective stores embeddings only for hub-qualified documents
func (c *Client) addDocumentsSelective(ctx context.Context, docs []sqlitevec.Document) error {
// Always cache content for potential recomputation
if err := c.cacheDocuments(ctx, docs); err != nil {
return err
}
// Filter to hub documents
hubDocs := make([]sqlitevec.Document, 0, len(docs))
for _, doc := range docs {
if c.isHub(doc.ID) {
hubDocs = append(hubDocs, doc)
}
}
// Store only hub embeddings
if len(hubDocs) > 0 {
log.Debug().
Int("total", len(docs)).
Int("hubs", len(hubDocs)).
Msg("Storing selective embeddings")
return c.base.AddDocuments(ctx, hubDocs)
}
log.Debug().Int("total", len(docs)).Msg("All documents cached, no hubs to store")
return nil
}
// cacheDocuments stores content for later recomputation
func (c *Client) cacheDocuments(ctx context.Context, docs []sqlitevec.Document) error {
c.cacheMu.Lock()
defer c.cacheMu.Unlock()
for _, doc := range docs {
c.contentCache[doc.ID] = doc.Content
}
return nil
}
// DeleteDocuments removes documents by their IDs
func (c *Client) DeleteDocuments(ctx context.Context, ids []string) error {
// Remove from base storage
if err := c.base.DeleteDocuments(ctx, ids); err != nil {
return err
}
// Clean up caches
c.mu.Lock()
for _, id := range ids {
delete(c.accessCount, id)
delete(c.lastAccess, id)
}
c.mu.Unlock()
c.cacheMu.Lock()
for _, id := range ids {
delete(c.contentCache, id)
}
c.cacheMu.Unlock()
return nil
}
// Query performs search with dynamic recomputation
func (c *Client) Query(ctx context.Context, query string, limit int, where map[string]any) ([]sqlitevec.QueryResult, error) {
switch c.strategy {
case StorageAlways:
// Use existing implementation
return c.queryAndTrack(ctx, query, limit, where)
case StorageHub:
// Search hubs, then expand with recomputation
return c.queryHybrid(ctx, query, limit, where)
case StorageOnDemand:
// Fully dynamic search
return c.queryDynamic(ctx, query, limit, where)
default:
return c.queryAndTrack(ctx, query, limit, where)
}
}
// queryAndTrack wraps base Query with access tracking
func (c *Client) queryAndTrack(ctx context.Context, query string, limit int, where map[string]any) ([]sqlitevec.QueryResult, error) {
results, err := c.base.Query(ctx, query, limit, where)
if err != nil {
return nil, err
}
// Track access for hub detection
c.trackAccess(results)
return results, nil
}
// queryHybrid searches stored hubs and recomputes non-hubs
func (c *Client) queryHybrid(ctx context.Context, query string, limit int, where map[string]any) ([]sqlitevec.QueryResult, error) {
startTime := time.Now()
// 1. Query stored hub embeddings (limit * 2 for expansion)
hubResults, err := c.base.Query(ctx, query, limit*2, where)
if err != nil {
return nil, err
}
// 2. Track access
c.trackAccess(hubResults)
// 3. Get candidate non-hub IDs (from content cache)
candidates := c.getCandidateNonHubs(where, limit*2)
// 4. Recompute embeddings for candidates if we have any
var recomputedResults []sqlitevec.QueryResult
if len(candidates) > 0 {
recomputedResults, err = c.recomputeAndScore(ctx, query, candidates)
if err != nil {
// Log but don't fail - use hub results only
log.Warn().Err(err).Msg("Failed to recompute embeddings, using hub results only")
recomputedResults = nil
}
}
// 5. Merge and rank
allResults := append(hubResults, recomputedResults...)
sortBySimilarity(allResults)
// 6. Return top K
if len(allResults) > limit {
allResults = allResults[:limit]
}
duration := time.Since(startTime)
log.Debug().
Dur("duration_ms", duration).
Int("hubs", len(hubResults)).
Int("recomputed", len(recomputedResults)).
Int("results", len(allResults)).
Msg("Hybrid search completed")
return allResults, nil
}
// queryDynamic recomputes all embeddings on-the-fly
func (c *Client) queryDynamic(ctx context.Context, query string, limit int, where map[string]any) ([]sqlitevec.QueryResult, error) {
startTime := time.Now()
// Get all candidate IDs from content cache
candidates := c.getCandidateNonHubs(where, limit*5)
// Recompute and score all
results, err := c.recomputeAndScore(ctx, query, candidates)
if err != nil {
return nil, err
}
// Track access
c.trackAccess(results)
// Return top K
if len(results) > limit {
results = results[:limit]
}
duration := time.Since(startTime)
log.Debug().
Dur("duration_ms", duration).
Int("recomputed", len(candidates)).
Int("results", len(results)).
Msg("Dynamic search completed")
return results, nil
}
// recomputeAndScore generates embeddings and computes similarities
func (c *Client) recomputeAndScore(ctx context.Context, query string, candidateIDs []string) ([]sqlitevec.QueryResult, error) {
if len(candidateIDs) == 0 {
return nil, nil
}
// Generate query embedding
queryEmb, err := c.embedSvc.Embed(query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
// Get content for candidates
c.cacheMu.RLock()
texts := make([]string, 0, len(candidateIDs))
validIDs := make([]string, 0, len(candidateIDs))
for _, id := range candidateIDs {
if content, ok := c.contentCache[id]; ok && content != "" {
texts = append(texts, content)
validIDs = append(validIDs, id)
}
}
c.cacheMu.RUnlock()
if len(texts) == 0 {
return nil, nil
}
// Batch generate embeddings
embeddings, err := c.embedSvc.EmbedBatch(texts)
if err != nil {
return nil, fmt.Errorf("batch embed: %w", err)
}
// Compute similarities
results := make([]sqlitevec.QueryResult, len(embeddings))
for i, emb := range embeddings {
similarity := cosineSimilarity(queryEmb, emb)
distance := 1.0 - similarity // Convert to distance
results[i] = sqlitevec.QueryResult{
ID: validIDs[i],
Distance: float64(distance),
Similarity: float64(similarity),
Metadata: make(map[string]any),
}
}
return results, nil
}
// trackAccess records document access for hub detection
func (c *Client) trackAccess(results []sqlitevec.QueryResult) {
if len(results) == 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for _, r := range results {
c.accessCount[r.ID]++
c.lastAccess[r.ID] = now
}
}
// isHub checks if a document qualifies as a hub
func (c *Client) isHub(docID string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
count := c.accessCount[docID]
return count >= c.hubThreshold
}
// getCandidateNonHubs returns IDs of non-hub documents matching filter
func (c *Client) getCandidateNonHubs(where map[string]any, limit int) []string {
c.cacheMu.RLock()
defer c.cacheMu.RUnlock()
candidates := make([]string, 0, limit)
for id := range c.contentCache {
if !c.isHub(id) {
candidates = append(candidates, id)
if len(candidates) >= limit {
break
}
}
}
return candidates
}
// IsConnected always returns true (wraps base client)
func (c *Client) IsConnected() bool {
return c.base.IsConnected()
}
// Close releases resources
func (c *Client) Close() error {
return c.base.Close()
}
// Count returns the total number of vectors in the store
func (c *Client) Count(ctx context.Context) (int64, error) {
return c.base.Count(ctx)
}
// ModelVersion returns the current embedding model version
func (c *Client) ModelVersion() string {
return c.base.ModelVersion()
}
// NeedsRebuild checks if vectors need to be rebuilt due to model version change
func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
return c.base.NeedsRebuild(ctx)
}
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions
func (c *Client) GetStaleVectors(ctx context.Context) ([]sqlitevec.StaleVectorInfo, error) {
return c.base.GetStaleVectors(ctx)
}
// DeleteVectorsByDocIDs removes vectors by their doc_ids
func (c *Client) DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) error {
return c.base.DeleteVectorsByDocIDs(ctx, docIDs)
}
// GetStorageStats returns storage efficiency metrics
func (c *Client) GetStorageStats(ctx context.Context) (StorageStats, error) {
c.mu.RLock()
c.cacheMu.RLock()
defer c.mu.RUnlock()
defer c.cacheMu.RUnlock()
totalDocs := len(c.contentCache)
hubCount := 0
for id := range c.contentCache {
if c.accessCount[id] >= c.hubThreshold {
hubCount++
}
}
storedCount := hubCount
if c.strategy == StorageAlways {
// Get actual count from database
if count, err := c.base.Count(ctx); err == nil {
storedCount = int(count)
}
} else if c.strategy == StorageOnDemand {
storedCount = 0
}
embeddingSize := 384 * 4 // 384 dims × 4 bytes (float32)
storedBytes := storedCount * embeddingSize
potentialBytes := totalDocs * embeddingSize
savingsPercent := 0.0
if potentialBytes > 0 {
savingsPercent = (1.0 - float64(storedBytes)/float64(potentialBytes)) * 100
}
return StorageStats{
TotalDocuments: totalDocs,
HubDocuments: hubCount,
StoredEmbeddings: storedCount,
StorageBytes: storedBytes,
SavingsPercent: savingsPercent,
Strategy: c.strategy,
}, nil
}
// StorageStats contains storage efficiency metrics
type StorageStats struct {
TotalDocuments int
HubDocuments int
StoredEmbeddings int
StorageBytes int
SavingsPercent float64
Strategy VectorStorageStrategy
}
// Helper functions
func cosineSimilarity(a, b []float32) float32 {
var dotProduct, normA, normB float32
for i := range a {
dotProduct += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dotProduct / float32(math.Sqrt(float64(normA))*math.Sqrt(float64(normB)))
}
func sortBySimilarity(results []sqlitevec.QueryResult) {
// Use a simple but efficient sorting algorithm
n := len(results)
for i := 0; i < n-1; i++ {
for j := 0; j < n-i-1; j++ {
if results[j].Similarity < results[j+1].Similarity {
results[j], results[j+1] = results[j+1], results[j]
}
}
}
}
func strategyToString(s VectorStorageStrategy) string {
switch s {
case StorageAlways:
return "always"
case StorageHub:
return "hub"
case StorageOnDemand:
return "on_demand"
default:
return "unknown"
}
}
// ParseStrategy converts a string to VectorStorageStrategy
func ParseStrategy(s string) VectorStorageStrategy {
switch s {
case "hub":
return StorageHub
case "on_demand":
return StorageOnDemand
case "always":
return StorageAlways
default:
return StorageHub // Default to hub strategy
}
}
+186
View File
@@ -0,0 +1,186 @@
package hybrid
import (
"testing"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/stretchr/testify/assert"
)
func TestParseStrategy(t *testing.T) {
tests := []struct {
name string
input string
expected VectorStorageStrategy
}{
{"hub_strategy", "hub", StorageHub},
{"on_demand_strategy", "on_demand", StorageOnDemand},
{"always_strategy", "always", StorageAlways},
{"invalid_defaults_to_hub", "invalid", StorageHub},
{"empty_defaults_to_hub", "", StorageHub},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ParseStrategy(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestStrategyToString(t *testing.T) {
tests := []struct {
name string
expected string
input VectorStorageStrategy
}{
{"hub_to_string", "hub", StorageHub},
{"on_demand_to_string", "on_demand", StorageOnDemand},
{"always_to_string", "always", StorageAlways},
{"invalid_to_unknown", "unknown", VectorStorageStrategy(99)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := strategyToString(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestCosineSimilarity(t *testing.T) {
tests := []struct {
name string
a []float32
b []float32
expected float32
}{
{
name: "identical_vectors",
a: []float32{1, 0, 0},
b: []float32{1, 0, 0},
expected: 1.0,
},
{
name: "orthogonal_vectors",
a: []float32{1, 0, 0},
b: []float32{0, 1, 0},
expected: 0.0,
},
{
name: "opposite_vectors",
a: []float32{1, 0, 0},
b: []float32{-1, 0, 0},
expected: -1.0,
},
{
name: "zero_vector",
a: []float32{0, 0, 0},
b: []float32{1, 1, 1},
expected: 0.0,
},
{
name: "parallel_vectors",
a: []float32{2, 0, 0},
b: []float32{4, 0, 0},
expected: 1.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cosineSimilarity(tt.a, tt.b)
assert.InDelta(t, tt.expected, result, 0.001)
})
}
}
func TestSortBySimilarity(t *testing.T) {
tests := []struct {
name string
input []sqlitevec.QueryResult
expected []string // Expected order of IDs
}{
{
name: "already_sorted",
input: []sqlitevec.QueryResult{
{ID: "doc1", Similarity: 0.9},
{ID: "doc2", Similarity: 0.7},
{ID: "doc3", Similarity: 0.5},
},
expected: []string{"doc1", "doc2", "doc3"},
},
{
name: "reverse_sorted",
input: []sqlitevec.QueryResult{
{ID: "doc1", Similarity: 0.3},
{ID: "doc2", Similarity: 0.7},
{ID: "doc3", Similarity: 0.9},
},
expected: []string{"doc3", "doc2", "doc1"},
},
{
name: "random_order",
input: []sqlitevec.QueryResult{
{ID: "doc1", Similarity: 0.5},
{ID: "doc2", Similarity: 0.9},
{ID: "doc3", Similarity: 0.3},
{ID: "doc4", Similarity: 0.7},
},
expected: []string{"doc2", "doc4", "doc1", "doc3"},
},
{
name: "identical_similarities",
input: []sqlitevec.QueryResult{
{ID: "doc1", Similarity: 0.5},
{ID: "doc2", Similarity: 0.5},
{ID: "doc3", Similarity: 0.5},
},
expected: []string{"doc1", "doc2", "doc3"},
},
{
name: "empty_list",
input: []sqlitevec.QueryResult{},
expected: []string{},
},
{
name: "single_element",
input: []sqlitevec.QueryResult{
{ID: "doc1", Similarity: 0.5},
},
expected: []string{"doc1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sortBySimilarity(tt.input)
actual := make([]string, len(tt.input))
for i, r := range tt.input {
actual[i] = r.ID
}
assert.Equal(t, tt.expected, actual)
})
}
}
func TestSortBySimilarity_PreserveOtherFields(t *testing.T) {
input := []sqlitevec.QueryResult{
{ID: "doc1", Similarity: 0.3, Distance: 0.7, Metadata: map[string]any{"key": "val1"}},
{ID: "doc2", Similarity: 0.9, Distance: 0.1, Metadata: map[string]any{"key": "val2"}},
}
sortBySimilarity(input)
assert.Equal(t, "doc2", input[0].ID)
assert.InDelta(t, 0.9, input[0].Similarity, 0.001)
assert.InDelta(t, 0.1, input[0].Distance, 0.001)
assert.Equal(t, "val2", input[0].Metadata["key"])
assert.Equal(t, "doc1", input[1].ID)
assert.InDelta(t, 0.3, input[1].Similarity, 0.001)
assert.InDelta(t, 0.7, input[1].Distance, 0.001)
assert.Equal(t, "val1", input[1].Metadata["key"])
}
+62
View File
@@ -0,0 +1,62 @@
package hybrid
import (
"os"
"strconv"
"github.com/rs/zerolog/log"
)
// GetStrategyFromEnv reads CLAUDE_MNEMONIC_VECTOR_STRATEGY from environment
func GetStrategyFromEnv() VectorStorageStrategy {
strategyStr := os.Getenv("CLAUDE_MNEMONIC_VECTOR_STRATEGY")
if strategyStr == "" {
// Default to hub strategy for optimal balance
return StorageHub
}
strategy := ParseStrategy(strategyStr)
log.Info().
Str("env_value", strategyStr).
Str("strategy", strategyToString(strategy)).
Msg("Vector storage strategy from environment")
return strategy
}
// GetHubThresholdFromEnv reads CLAUDE_MNEMONIC_HUB_THRESHOLD from environment
func GetHubThresholdFromEnv() int {
thresholdStr := os.Getenv("CLAUDE_MNEMONIC_HUB_THRESHOLD")
if thresholdStr == "" {
return 5 // Default threshold
}
threshold, err := strconv.Atoi(thresholdStr)
if err != nil {
log.Warn().
Err(err).
Str("env_value", thresholdStr).
Msg("Invalid hub threshold in environment, using default")
return 5
}
if threshold < 1 {
log.Warn().
Int("env_value", threshold).
Msg("Hub threshold too low, using minimum of 1")
return 1
}
log.Info().
Int("threshold", threshold).
Msg("Hub threshold from environment")
return threshold
}
// IsHybridEnabled checks if hybrid storage should be used
// Returns false if CLAUDE_MNEMONIC_VECTOR_STRATEGY=always (backwards compat)
func IsHybridEnabled() bool {
strategy := GetStrategyFromEnv()
return strategy != StorageAlways
}
+308
View File
@@ -0,0 +1,308 @@
package hybrid
import (
"context"
"fmt"
"sort"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/graph"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// GraphConfig configures graph-aware search
type GraphConfig struct {
Enabled bool
MaxHops int // Maximum graph traversal depth (default: 2)
BranchFactor int // Number of neighbors to expand per node (default: 5)
EdgeWeight float64 // Minimum edge weight to follow (default: 0.3)
}
// DefaultGraphConfig returns sensible defaults for graph search
func DefaultGraphConfig() GraphConfig {
return GraphConfig{
Enabled: true,
MaxHops: 2,
BranchFactor: 5,
EdgeWeight: 0.3,
}
}
// GraphSearchClient wraps hybrid.Client with graph-aware search
type GraphSearchClient struct {
*Client
graph *graph.ObservationGraph
graphConfig GraphConfig
}
// NewGraphSearchClient creates a graph-enhanced hybrid client
func NewGraphSearchClient(baseClient *Client, observationGraph *graph.ObservationGraph, cfg GraphConfig) *GraphSearchClient {
return &GraphSearchClient{
Client: baseClient,
graph: observationGraph,
graphConfig: cfg,
}
}
// Query performs graph-aware vector search with two-level traversal
func (g *GraphSearchClient) Query(ctx context.Context, query string, limit int, where map[string]any) ([]sqlitevec.QueryResult, error) {
if !g.graphConfig.Enabled || g.graph == nil {
// Fall back to standard hybrid search
return g.Client.Query(ctx, query, limit, where)
}
startTime := time.Now()
// 1. Generate query embedding
queryEmb, err := g.embedSvc.Embed(query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
// 2. Search hub nodes (stored embeddings)
hubResults, err := g.base.Query(ctx, query, limit*2, where)
if err != nil {
// Fall back to standard search on error
log.Warn().Err(err).Msg("Hub search failed, falling back to hybrid search")
return g.Client.Query(ctx, query, limit, where)
}
// 3. Track hub access
g.trackAccess(hubResults)
// 4. Expand via graph traversal
expandedIDs := g.expandFromHubs(hubResults, limit*4)
// 5. Filter to non-hubs that need recomputation
nonHubIDs := make([]string, 0)
for _, id := range expandedIDs {
if !g.isHub(id) {
nonHubIDs = append(nonHubIDs, id)
}
}
// 6. Batch recompute non-hub embeddings
recomputedResults, err := g.recomputeAndScore(ctx, query, nonHubIDs)
if err != nil {
log.Warn().Err(err).Msg("Recomputation failed, using hub results only")
recomputedResults = nil
}
// 7. Apply graph-based ranking boost
allResults := g.mergeAndRankWithGraph(hubResults, recomputedResults, queryEmb)
// 8. Return top K
if len(allResults) > limit {
allResults = allResults[:limit]
}
duration := time.Since(startTime)
log.Debug().
Dur("duration_ms", duration).
Int("hubs", len(hubResults)).
Int("expanded", len(expandedIDs)).
Int("recomputed", len(recomputedResults)).
Int("results", len(allResults)).
Msg("Graph search completed")
return allResults, nil
}
// expandFromHubs traverses graph from hub nodes to find promising candidates
func (g *GraphSearchClient) expandFromHubs(hubResults []sqlitevec.QueryResult, maxCandidates int) []string {
if g.graph == nil {
return nil
}
expanded := make(map[string]float64) // doc_id -> relevance score
visited := make(map[int64]bool)
// Start from top hub results
for i, result := range hubResults {
if i >= g.graphConfig.BranchFactor*2 {
break // Limit starting points
}
// Parse observation ID from doc_id
obsID := parseObservationID(result.ID)
if obsID == 0 {
continue
}
// Mark as visited with high relevance (direct match)
visited[obsID] = true
expanded[result.ID] = result.Similarity
// Traverse graph from this hub
g.traverseGraph(obsID, result.Similarity, 0, expanded, visited)
}
// Convert to sorted list
type candidate struct {
ID string
Relevance float64
}
candidates := make([]candidate, 0, len(expanded))
for id, rel := range expanded {
candidates = append(candidates, candidate{ID: id, Relevance: rel})
}
// Sort by relevance descending
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].Relevance > candidates[j].Relevance
})
// Return top candidates
if len(candidates) > maxCandidates {
candidates = candidates[:maxCandidates]
}
result := make([]string, len(candidates))
for i, c := range candidates {
result[i] = c.ID
}
return result
}
// traverseGraph performs depth-limited graph traversal
func (g *GraphSearchClient) traverseGraph(nodeID int64, baseRelevance float64, depth int, expanded map[string]float64, visited map[int64]bool) {
if depth >= g.graphConfig.MaxHops {
return // Max depth reached
}
// Get neighbors from graph
neighbors, weights, err := g.graph.GetNeighbors(nodeID)
if err != nil {
return // No neighbors or error
}
// Traverse top neighbors by weight
type neighborWeight struct {
ID int64
Weight float32
}
neighborList := make([]neighborWeight, len(neighbors))
for i := range neighbors {
neighborList[i] = neighborWeight{
ID: neighbors[i],
Weight: weights[i],
}
}
// Sort by weight descending
sort.Slice(neighborList, func(i, j int) bool {
return neighborList[i].Weight > neighborList[j].Weight
})
// Expand top branch_factor neighbors
expanded_count := 0
for _, nw := range neighborList {
if expanded_count >= g.graphConfig.BranchFactor {
break
}
// Skip if edge weight too low
if float64(nw.Weight) < g.graphConfig.EdgeWeight {
continue
}
// Skip if already visited
if visited[nw.ID] {
continue
}
visited[nw.ID] = true
// Calculate propagated relevance (decays with distance)
decay := 0.7 // 30% decay per hop
propagatedRelevance := baseRelevance * float64(nw.Weight) * decay
// Add to expanded set
docID := formatObservationDocID(nw.ID)
if existing, ok := expanded[docID]; !ok || propagatedRelevance > existing {
expanded[docID] = propagatedRelevance
}
// Recursively traverse
g.traverseGraph(nw.ID, propagatedRelevance, depth+1, expanded, visited)
expanded_count++
}
}
// mergeAndRankWithGraph combines hub and recomputed results with graph-based ranking
func (g *GraphSearchClient) mergeAndRankWithGraph(hubResults, recomputedResults []sqlitevec.QueryResult, queryEmb []float32) []sqlitevec.QueryResult {
// Merge results
allResults := append(hubResults, recomputedResults...)
// Apply graph-based re-ranking
if g.graph != nil {
for i := range allResults {
obsID := parseObservationID(allResults[i].ID)
if obsID == 0 {
continue
}
// Boost score based on node degree (hubs are more important)
node, err := g.graph.GetNode(obsID)
if err == nil && node.Degree > 0 {
// Degree boost: up to 10% increase for high-degree nodes
degreeBoost := 1.0 + (0.1 * float64(node.Degree) / 20.0)
if degreeBoost > 1.1 {
degreeBoost = 1.1
}
allResults[i].Similarity *= degreeBoost
}
}
}
// Sort by adjusted similarity
sortBySimilarity(allResults)
return allResults
}
// parseObservationID extracts observation ID from doc_id
// Format: "obs-{id}-{field}"
func parseObservationID(docID string) int64 {
var obsID int64
// Ignore error - returns 0 on parse failure, which callers handle
_, _ = fmt.Sscanf(docID, "obs-%d-", &obsID)
return obsID
}
// formatObservationDocID creates a doc_id for an observation
func formatObservationDocID(obsID int64) string {
return fmt.Sprintf("obs-%d-combined", obsID)
}
// GetGraphStats returns statistics about the observation graph
func (g *GraphSearchClient) GetGraphStats() graph.GraphStats {
if g.graph == nil {
return graph.GraphStats{}
}
return g.graph.Stats()
}
// RebuildGraph rebuilds the observation graph from current observations
// This should be called periodically or when observations change significantly
func (g *GraphSearchClient) RebuildGraph(ctx context.Context, observations []*models.Observation) error {
log.Info().Int("observations", len(observations)).Msg("Rebuilding observation graph")
newGraph, err := graph.BuildFromObservations(ctx, observations)
if err != nil {
return fmt.Errorf("build graph: %w", err)
}
g.graph = newGraph
log.Info().
Int("nodes", newGraph.Stats().NodeCount).
Int("edges", newGraph.Stats().EdgeCount).
Msg("Graph rebuilt successfully")
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package hybrid
import (
"testing"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector"
)
// TestInterfaceImplementation verifies that hybrid clients implement vector.Client interface
func TestInterfaceImplementation(t *testing.T) {
// Compile-time check that Client implements vector.Client
var _ vector.Client = (*Client)(nil)
// Compile-time check that GraphSearchClient implements vector.Client
var _ vector.Client = (*GraphSearchClient)(nil)
}
+272
View File
@@ -0,0 +1,272 @@
package hybrid
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// Metrics tracks performance and usage statistics for hybrid vector storage
type Metrics struct {
startTime time.Time
recentLatencies []time.Duration
latenciesMu sync.Mutex
totalQueries atomic.Int64
hubOnlyQueries atomic.Int64
hybridQueries atomic.Int64
onDemandQueries atomic.Int64
graphQueries atomic.Int64
totalLatency atomic.Int64 // Sum in microseconds
hubLatency atomic.Int64
recomputeLatency atomic.Int64
totalDocuments atomic.Int64
hubDocuments atomic.Int64
storedEmbeddings atomic.Int64
recomputedCount atomic.Int64
cacheHits atomic.Int64
cacheMisses atomic.Int64
graphTraversals atomic.Int64
avgTraversalDepth atomic.Int64
}
// NewMetrics creates a new metrics tracker
func NewMetrics() *Metrics {
return &Metrics{
recentLatencies: make([]time.Duration, 0, 1000),
startTime: time.Now(),
}
}
// RecordQuery records a query execution
func (m *Metrics) RecordQuery(queryType string, latency time.Duration, recomputed int) {
m.totalQueries.Add(1)
m.totalLatency.Add(latency.Microseconds())
switch queryType {
case "hub_only":
m.hubOnlyQueries.Add(1)
case "hybrid":
m.hybridQueries.Add(1)
case "on_demand":
m.onDemandQueries.Add(1)
case "graph":
m.graphQueries.Add(1)
}
if recomputed > 0 {
m.recomputedCount.Add(int64(recomputed))
}
// Track recent latencies
m.latenciesMu.Lock()
m.recentLatencies = append(m.recentLatencies, latency)
if len(m.recentLatencies) > 1000 {
m.recentLatencies = m.recentLatencies[len(m.recentLatencies)-1000:]
}
m.latenciesMu.Unlock()
}
// RecordHubLatency records time spent in hub search
func (m *Metrics) RecordHubLatency(latency time.Duration) {
m.hubLatency.Add(latency.Microseconds())
}
// RecordRecomputeLatency records time spent recomputing embeddings
func (m *Metrics) RecordRecomputeLatency(latency time.Duration) {
m.recomputeLatency.Add(latency.Microseconds())
}
// RecordCacheHit records a content cache hit
func (m *Metrics) RecordCacheHit() {
m.cacheHits.Add(1)
}
// RecordCacheMiss records a content cache miss
func (m *Metrics) RecordCacheMiss() {
m.cacheMisses.Add(1)
}
// RecordGraphTraversal records a graph traversal operation
func (m *Metrics) RecordGraphTraversal(depth int) {
m.graphTraversals.Add(1)
m.avgTraversalDepth.Add(int64(depth))
}
// UpdateStorageStats updates current storage statistics
func (m *Metrics) UpdateStorageStats(total, hubs, stored int) {
m.totalDocuments.Store(int64(total))
m.hubDocuments.Store(int64(hubs))
m.storedEmbeddings.Store(int64(stored))
}
// GetSnapshot returns current metrics snapshot
func (m *Metrics) GetSnapshot() MetricsSnapshot {
m.latenciesMu.Lock()
defer m.latenciesMu.Unlock()
totalQueries := m.totalQueries.Load()
snapshot := MetricsSnapshot{
// Query counts
TotalQueries: totalQueries,
HubOnlyQueries: m.hubOnlyQueries.Load(),
HybridQueries: m.hybridQueries.Load(),
OnDemandQueries: m.onDemandQueries.Load(),
GraphQueries: m.graphQueries.Load(),
// Storage
TotalDocuments: int(m.totalDocuments.Load()),
HubDocuments: int(m.hubDocuments.Load()),
StoredEmbeddings: int(m.storedEmbeddings.Load()),
RecomputedTotal: m.recomputedCount.Load(),
// Cache
CacheHits: m.cacheHits.Load(),
CacheMisses: m.cacheMisses.Load(),
// Graph
GraphTraversals: m.graphTraversals.Load(),
// Runtime
Uptime: time.Since(m.startTime),
}
// Calculate latencies
if totalQueries > 0 {
snapshot.AvgLatency = time.Duration(m.totalLatency.Load()/totalQueries) * time.Microsecond
snapshot.AvgHubLatency = time.Duration(m.hubLatency.Load()/totalQueries) * time.Microsecond
}
if m.recomputedCount.Load() > 0 {
snapshot.AvgRecomputeLatency = time.Duration(m.recomputeLatency.Load()/m.recomputedCount.Load()) * time.Microsecond
}
// Calculate percentiles
if len(m.recentLatencies) > 0 {
sorted := make([]time.Duration, len(m.recentLatencies))
copy(sorted, m.recentLatencies)
sortDurations(sorted)
snapshot.P50Latency = percentile(sorted, 0.50)
snapshot.P95Latency = percentile(sorted, 0.95)
snapshot.P99Latency = percentile(sorted, 0.99)
}
// Calculate cache hit rate
totalCacheOps := snapshot.CacheHits + snapshot.CacheMisses
if totalCacheOps > 0 {
snapshot.CacheHitRate = float64(snapshot.CacheHits) / float64(totalCacheOps)
}
// Calculate storage savings
if snapshot.TotalDocuments > 0 {
embeddingSize := 384 * 4 // 384 dims × 4 bytes
fullStorage := snapshot.TotalDocuments * embeddingSize
actualStorage := snapshot.StoredEmbeddings * embeddingSize
if fullStorage > 0 {
snapshot.StorageSavingsPercent = (1.0 - float64(actualStorage)/float64(fullStorage)) * 100
}
}
// Calculate avg traversal depth
if snapshot.GraphTraversals > 0 {
snapshot.AvgTraversalDepth = float64(m.avgTraversalDepth.Load()) / float64(snapshot.GraphTraversals)
}
return snapshot
}
// MetricsSnapshot represents a point-in-time metrics snapshot
type MetricsSnapshot struct {
// Query metrics
TotalQueries int64
HubOnlyQueries int64
HybridQueries int64
OnDemandQueries int64
GraphQueries int64
// Latency metrics
AvgLatency time.Duration
P50Latency time.Duration
P95Latency time.Duration
P99Latency time.Duration
AvgHubLatency time.Duration
AvgRecomputeLatency time.Duration
// Storage metrics
TotalDocuments int
HubDocuments int
StoredEmbeddings int
StorageSavingsPercent float64
RecomputedTotal int64
// Cache metrics
CacheHits int64
CacheMisses int64
CacheHitRate float64
// Graph metrics
GraphTraversals int64
AvgTraversalDepth float64
// Runtime
Uptime time.Duration
}
// sortDurations sorts a slice of durations in ascending order
func sortDurations(durations []time.Duration) {
n := len(durations)
for i := 0; i < n-1; i++ {
for j := 0; j < n-i-1; j++ {
if durations[j] > durations[j+1] {
durations[j], durations[j+1] = durations[j+1], durations[j]
}
}
}
}
// percentile calculates the Nth percentile from a sorted slice
func percentile(sorted []time.Duration, p float64) time.Duration {
if len(sorted) == 0 {
return 0
}
idx := int(float64(len(sorted)) * p)
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
// String returns a human-readable representation of metrics
func (s MetricsSnapshot) String() string {
return fmt.Sprintf(`Hybrid Vector Storage Metrics:
Queries:
Total: %d (Hub: %d, Hybrid: %d, OnDemand: %d, Graph: %d)
Avg Latency: %v (p50: %v, p95: %v, p99: %v)
Hub Latency: %v, Recompute Latency: %v
Storage:
Documents: %d (Hubs: %d, %.1f%%)
Stored Embeddings: %d
Savings: %.1f%%
Total Recomputed: %d
Cache:
Hits: %d, Misses: %d (Hit Rate: %.1f%%)
Graph:
Traversals: %d (Avg Depth: %.2f)
Runtime: %v`,
s.TotalQueries, s.HubOnlyQueries, s.HybridQueries, s.OnDemandQueries, s.GraphQueries,
s.AvgLatency, s.P50Latency, s.P95Latency, s.P99Latency,
s.AvgHubLatency, s.AvgRecomputeLatency,
s.TotalDocuments, s.HubDocuments, float64(s.HubDocuments)/float64(s.TotalDocuments)*100,
s.StoredEmbeddings,
s.StorageSavingsPercent,
s.RecomputedTotal,
s.CacheHits, s.CacheMisses, s.CacheHitRate*100,
s.GraphTraversals, s.AvgTraversalDepth,
s.Uptime,
)
}
+42
View File
@@ -0,0 +1,42 @@
// Package vector provides common interfaces for vector storage implementations
package vector
import (
"context"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
)
// Client defines the interface for vector storage operations.
// Both sqlitevec.Client and hybrid.Client implement this interface.
type Client interface {
// AddDocuments adds documents with their embeddings to the vector store
AddDocuments(ctx context.Context, docs []sqlitevec.Document) error
// DeleteDocuments removes documents by their IDs
DeleteDocuments(ctx context.Context, ids []string) error
// Query performs a vector similarity search
Query(ctx context.Context, query string, limit int, where map[string]any) ([]sqlitevec.QueryResult, error)
// IsConnected checks if the vector store is available
IsConnected() bool
// Close releases resources
Close() error
// Count returns the total number of vectors in the store
Count(ctx context.Context) (int64, error)
// ModelVersion returns the current embedding model version
ModelVersion() string
// NeedsRebuild checks if vectors need to be rebuilt due to model version change
NeedsRebuild(ctx context.Context) (bool, string)
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions
GetStaleVectors(ctx context.Context) ([]sqlitevec.StaleVectorInfo, error)
// DeleteVectorsByDocIDs removes vectors by their doc_ids
DeleteVectorsByDocIDs(ctx context.Context, docIDs []string) error
}
+1 -1
View File
@@ -319,11 +319,11 @@ func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
// StaleVectorInfo contains information about a vector that needs rebuilding.
type StaleVectorInfo struct {
DocID string
SQLiteID int64
DocType string
FieldType string
Project string
Scope string
SQLiteID int64
}
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
+3 -3
View File
@@ -12,17 +12,17 @@ const (
// Document represents a document to store with vector embedding.
type Document struct {
Metadata map[string]any
ID string
Content string
Metadata map[string]any
}
// QueryResult represents a search result from vector search.
type QueryResult struct {
Metadata map[string]any
ID string
Distance float64
Similarity float64 // 1.0 = identical, 0.0 = opposite (derived from distance)
Metadata map[string]any
Similarity float64
}
// DistanceToSimilarity converts sqlite-vec cosine distance to similarity score.
+4 -4
View File
@@ -42,10 +42,10 @@ func TestQueryResult_Fields(t *testing.T) {
func TestBuildWhereFilter(t *testing.T) {
tests := []struct {
expected map[string]interface{}
name string
docType DocType
project string
expected map[string]interface{}
}{
{
name: "empty_filters",
@@ -474,9 +474,9 @@ func TestCopyMetadataMulti(t *testing.T) {
func TestJoinStrings(t *testing.T) {
tests := []struct {
name string
strs []string
sep string
expected string
strs []string
}{
{
name: "empty_slice",
@@ -522,8 +522,8 @@ func TestTruncateString(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
expected string
maxLen int
}{
{
name: "shorter_than_max",
@@ -577,10 +577,10 @@ func TestFilterByThreshold(t *testing.T) {
tests := []struct {
name string
results []QueryResult
expectedIDs []string
threshold float64
maxResults int
expectedLen int
expectedIDs []string
}{
{
name: "empty_results",