Skip to main content

ctx/embeddings/
mod.rs

1//! Semantic search via embeddings.
2//!
3//! This module provides embedding generation and vector similarity search
4//! for semantic code search. It supports multiple embedding providers:
5//!
6//! - **OpenAI**: Uses text-embedding-3-small for high-quality embeddings
7//! - **Local**: Uses fastembed for local, offline embeddings
8//!
9//! # Architecture
10//!
11//! Embeddings are stored in SQLite as JSON-encoded float arrays. Similarity
12//! search is performed in Rust using cosine similarity for accuracy and
13//! portability (no native vector extensions required).
14//!
15//! # Usage
16//!
17//! ```ignore
18//! let provider = OpenAIProvider::new("sk-...")?;
19//! let embedding = provider.embed("fn authenticate(user: &str)")?;
20//!
21//! let results = search_similar(&db, "authentication functions", 10)?;
22//! ```
23
24pub mod local;
25pub mod openai;
26
27// Re-export providers for convenience
28pub use local::LocalProvider;
29pub use openai::OpenAIProvider;
30
31use crate::error::Result;
32
33/// Embedding dimension for different models
34pub const OPENAI_EMBEDDING_DIM: usize = 1536; // text-embedding-3-small
35pub const LOCAL_EMBEDDING_DIM: usize = 384; // all-MiniLM-L6-v2
36
37/// A vector embedding.
38#[derive(Debug, Clone)]
39pub struct Embedding {
40    /// The embedding vector
41    pub vector: Vec<f32>,
42    /// Number of tokens in the input
43    #[allow(dead_code)]
44    pub token_count: Option<usize>,
45}
46
47impl Embedding {
48    /// Create a new embedding from a vector.
49    pub fn new(vector: Vec<f32>) -> Self {
50        Self {
51            vector,
52            token_count: None,
53        }
54    }
55
56    /// Get the dimension of this embedding.
57    #[allow(dead_code)]
58    pub fn dim(&self) -> usize {
59        self.vector.len()
60    }
61
62    /// Compute cosine similarity with another embedding.
63    #[allow(dead_code)]
64    pub fn cosine_similarity(&self, other: &Embedding) -> f32 {
65        cosine_similarity(&self.vector, &other.vector)
66    }
67
68    /// Serialize to JSON for storage.
69    #[allow(dead_code)]
70    pub fn to_json(&self) -> Result<String> {
71        Ok(serde_json::to_string(&self.vector)?)
72    }
73
74    /// Deserialize from JSON.
75    #[allow(dead_code)]
76    pub fn from_json(json: &str) -> Result<Self> {
77        let vector: Vec<f32> = serde_json::from_str(json)?;
78        Ok(Self::new(vector))
79    }
80}
81
82/// Trait for embedding providers.
83pub trait EmbeddingProvider: Send + Sync {
84    /// Get the name of this provider.
85    fn name(&self) -> &str;
86
87    /// Get the embedding dimension for this provider.
88    fn dimension(&self) -> usize;
89
90    /// Generate an embedding for a single text.
91    fn embed(&self, text: &str) -> Result<Embedding>;
92
93    /// Generate embeddings for multiple texts (batch).
94    /// Default implementation calls embed() for each text.
95    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Embedding>> {
96        texts.iter().map(|t| self.embed(t)).collect()
97    }
98}
99
100/// Compute cosine similarity between two vectors.
101pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
102    if a.len() != b.len() {
103        return 0.0;
104    }
105
106    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
107    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
108    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
109
110    if norm_a == 0.0 || norm_b == 0.0 {
111        return 0.0;
112    }
113
114    dot / (norm_a * norm_b)
115}
116
117/// Compute dot product similarity between two vectors.
118#[allow(dead_code)]
119pub fn dot_product(a: &[f32], b: &[f32]) -> f32 {
120    if a.len() != b.len() {
121        return 0.0;
122    }
123    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
124}
125
126/// Normalize a vector to unit length.
127#[allow(dead_code)]
128pub fn normalize(v: &mut [f32]) {
129    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
130    if norm > 0.0 {
131        for x in v.iter_mut() {
132            *x /= norm;
133        }
134    }
135}
136
137/// Search result from similarity search.
138#[derive(Debug, Clone)]
139pub struct SearchResult {
140    /// Symbol ID
141    pub symbol_id: String,
142    /// Similarity score (0.0 to 1.0)
143    pub score: f32,
144    /// Symbol name
145    pub name: String,
146    /// Symbol kind
147    pub kind: String,
148    /// File path
149    pub file_path: String,
150    /// Line number
151    pub line: u32,
152}
153
154/// Perform semantic similarity search using embeddings.
155///
156/// This automatically uses the fast vector search (sqlite-vec) when available,
157/// falling back to O(n) cosine similarity search otherwise.
158pub fn semantic_search(
159    db: &crate::db::Database,
160    query_embedding: &Embedding,
161    limit: usize,
162) -> Result<Vec<SearchResult>> {
163    // Try fast vector search first (sqlite-vec, O(log n))
164    if db.has_vector_embeddings() {
165        if let Ok(results) = db.vector_search(&query_embedding.vector, limit) {
166            if !results.is_empty() {
167                // Convert L2 distance to similarity score (0-1 range)
168                // L2 distance 0 = identical, higher = less similar
169                // We use 1/(1+d) to convert to similarity
170                return Ok(results
171                    .into_iter()
172                    .map(
173                        |(symbol_id, name, kind, file_path, line, distance)| SearchResult {
174                            symbol_id,
175                            score: 1.0 / (1.0 + distance),
176                            name,
177                            kind,
178                            file_path,
179                            line,
180                        },
181                    )
182                    .collect());
183            }
184        }
185    }
186
187    // Fallback to O(n) cosine similarity search
188    semantic_search_slow(db, query_embedding, limit)
189}
190
191/// O(n) semantic search using cosine similarity.
192/// This loads all embeddings and computes similarity for each.
193fn semantic_search_slow(
194    db: &crate::db::Database,
195    query_embedding: &Embedding,
196    limit: usize,
197) -> Result<Vec<SearchResult>> {
198    // Get all embeddings from database
199    let all_embeddings = db.get_all_embeddings()?;
200
201    // Compute similarity for each
202    let mut scored: Vec<_> = all_embeddings
203        .into_iter()
204        .map(|(symbol_id, name, kind, file_path, line, vector)| {
205            let score = cosine_similarity(&query_embedding.vector, &vector);
206            SearchResult {
207                symbol_id,
208                score,
209                name,
210                kind,
211                file_path,
212                line,
213            }
214        })
215        .collect();
216
217    // Sort by score descending
218    scored.sort_by(|a, b| {
219        b.score
220            .partial_cmp(&a.score)
221            .unwrap_or(std::cmp::Ordering::Equal)
222    });
223    scored.truncate(limit);
224
225    Ok(scored)
226}
227
228/// Embed all symbols that don't have embeddings yet.
229pub fn embed_missing_symbols<P: EmbeddingProvider + ?Sized>(
230    db: &crate::db::Database,
231    provider: &P,
232    batch_size: usize,
233    progress_callback: Option<&dyn Fn(usize, usize)>,
234) -> Result<usize> {
235    let mut total_embedded = 0;
236
237    loop {
238        // Get symbols without embeddings
239        let symbols = db.get_symbols_without_embeddings(batch_size as i64)?;
240
241        if symbols.is_empty() {
242            break;
243        }
244
245        // Generate embedding text for each symbol
246        let texts: Vec<String> = symbols.iter().map(|s| s.to_embedding_text()).collect();
247
248        let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
249
250        // Generate embeddings
251        let embeddings = provider.embed_batch(&text_refs)?;
252
253        // Store embeddings
254        for (symbol, embedding) in symbols.iter().zip(embeddings.iter()) {
255            db.store_embedding(&symbol.id, provider.name(), "default", &embedding.vector)?;
256        }
257
258        total_embedded += symbols.len();
259
260        if let Some(callback) = progress_callback {
261            callback(total_embedded, 0); // 0 = unknown total
262        }
263
264        if symbols.len() < batch_size {
265            break;
266        }
267    }
268
269    Ok(total_embedded)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_cosine_similarity() {
278        let a = vec![1.0, 0.0, 0.0];
279        let b = vec![1.0, 0.0, 0.0];
280        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
281
282        let c = vec![0.0, 1.0, 0.0];
283        assert!(cosine_similarity(&a, &c).abs() < 1e-6);
284
285        let d = vec![-1.0, 0.0, 0.0];
286        assert!((cosine_similarity(&a, &d) + 1.0).abs() < 1e-6);
287    }
288
289    #[test]
290    fn test_normalize() {
291        let mut v = vec![3.0, 4.0];
292        normalize(&mut v);
293        assert!((v[0] - 0.6).abs() < 1e-6);
294        assert!((v[1] - 0.8).abs() < 1e-6);
295    }
296
297    #[test]
298    fn test_embedding_json_roundtrip() {
299        let emb = Embedding::new(vec![0.1, 0.2, 0.3]);
300        let json = emb.to_json().unwrap();
301        let restored = Embedding::from_json(&json).unwrap();
302        assert_eq!(emb.vector, restored.vector);
303    }
304}