Skip to main content

skm_embed/
index.rs

1//! Persistent embedding index for fast skill lookup.
2
3use std::path::Path;
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8use skm_core::{SkillName, SkillRegistry};
9
10use crate::embedding::Embedding;
11use crate::error::EmbedError;
12use crate::multicomp::{ComponentScores, ComponentWeights, SkillEmbeddings};
13use crate::provider::EmbeddingProvider;
14
15/// A skill with its similarity score.
16#[derive(Debug, Clone)]
17pub struct ScoredSkill {
18    /// The skill name.
19    pub name: SkillName,
20
21    /// Overall weighted similarity score.
22    pub score: f32,
23
24    /// Per-component score breakdown.
25    pub component_scores: ComponentScores,
26}
27
28/// Persistent embedding index.
29///
30/// Serialized to disk via bincode for fast startup.
31/// Rebuilds only when skill content changes (tracked via content_hash).
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct EmbeddingIndex {
34    /// Embeddings for all indexed skills.
35    entries: Vec<SkillEmbeddings>,
36
37    /// Model identifier (invalidate if model changes).
38    model_id: String,
39
40    /// When this index was built.
41    built_at: SystemTime,
42}
43
44impl EmbeddingIndex {
45    /// Build index from registry using the given embedding provider.
46    ///
47    /// Embeds all skills in parallel where possible.
48    pub async fn build(
49        registry: &SkillRegistry,
50        provider: &dyn EmbeddingProvider,
51        weights: ComponentWeights,
52    ) -> Result<Self, EmbedError> {
53        let catalog = registry.catalog().await;
54        let mut entries = Vec::with_capacity(catalog.len());
55
56        // Collect all texts to embed
57        let mut texts: Vec<String> = Vec::new();
58        let mut skill_indices: Vec<(usize, SkillName)> = Vec::new();
59
60        for meta in &catalog {
61            let base_idx = texts.len();
62            skill_indices.push((base_idx, meta.name.clone()));
63
64            // Description
65            texts.push(meta.description.clone());
66
67            // Triggers (concatenated)
68            let triggers_text = if meta.triggers.is_empty() {
69                meta.description.clone() // Fallback to description
70            } else {
71                meta.triggers.join(", ")
72            };
73            texts.push(triggers_text);
74
75            // Tags (concatenated)
76            let tags_text = if meta.tags.is_empty() {
77                meta.description.clone() // Fallback to description
78            } else {
79                meta.tags.join(", ")
80            };
81            texts.push(tags_text);
82
83            // Examples (we don't have them in metadata, use description as placeholder)
84            texts.push(meta.description.clone());
85        }
86
87        // Batch embed all texts
88        let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
89
90        // Split into batches if needed
91        let max_batch = provider.max_batch_size();
92        let mut all_embeddings = Vec::with_capacity(texts.len());
93
94        for chunk in text_refs.chunks(max_batch) {
95            let batch_embeddings = provider.embed(chunk).await?;
96            all_embeddings.extend(batch_embeddings);
97        }
98
99        // Reconstruct skill embeddings from flat list
100        for (base_idx, skill_name) in skill_indices {
101            let skill_embedding = SkillEmbeddings::new(
102                skill_name,
103                all_embeddings[base_idx].clone(),     // description
104                all_embeddings[base_idx + 1].clone(), // triggers
105                all_embeddings[base_idx + 2].clone(), // tags
106                all_embeddings[base_idx + 3].clone(), // examples
107                weights.clone(),
108            );
109            entries.push(skill_embedding);
110        }
111
112        Ok(Self {
113            entries,
114            model_id: provider.model_id().to_string(),
115            built_at: SystemTime::now(),
116        })
117    }
118
119    /// Load from disk cache. Returns None if cache is stale or doesn't exist.
120    pub fn load_cached(path: &Path, registry: &SkillRegistry) -> Result<Option<Self>, EmbedError> {
121        if !path.exists() {
122            return Ok(None);
123        }
124
125        let data = std::fs::read(path).map_err(EmbedError::Io)?;
126        let index: Self = bincode::deserialize(&data)
127            .map_err(|e| EmbedError::Serialization(e.to_string()))?;
128
129        // TODO: Validate cache against registry content hashes
130        // For now, just return the loaded index
131        let _ = registry; // Unused for now
132
133        Ok(Some(index))
134    }
135
136    /// Save to disk.
137    pub fn save(&self, path: &Path) -> Result<(), EmbedError> {
138        let data = bincode::serialize(self)
139            .map_err(|e| EmbedError::Serialization(e.to_string()))?;
140
141        // Create parent directory if needed
142        if let Some(parent) = path.parent() {
143            std::fs::create_dir_all(parent)?;
144        }
145
146        std::fs::write(path, data)?;
147        Ok(())
148    }
149
150    /// Query: return top-k skills by similarity.
151    pub fn query(&self, query_embedding: &Embedding, top_k: usize) -> Vec<ScoredSkill> {
152        let mut scored: Vec<ScoredSkill> = self
153            .entries
154            .iter()
155            .map(|e| ScoredSkill {
156                name: e.skill_name.clone(),
157                score: e.score(query_embedding),
158                component_scores: e.component_scores(query_embedding),
159            })
160            .collect();
161
162        // Sort by score descending
163        scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
164
165        // Take top-k
166        scored.truncate(top_k);
167        scored
168    }
169
170    /// Query with adaptive k: return skills above threshold,
171    /// with automatic gap detection.
172    pub fn query_adaptive(
173        &self,
174        query_embedding: &Embedding,
175        min_score: f32,
176        max_k: usize,
177        gap_threshold: f32,
178    ) -> Vec<ScoredSkill> {
179        let mut scored: Vec<ScoredSkill> = self
180            .entries
181            .iter()
182            .map(|e| ScoredSkill {
183                name: e.skill_name.clone(),
184                score: e.score(query_embedding),
185                component_scores: e.component_scores(query_embedding),
186            })
187            .filter(|s| s.score >= min_score)
188            .collect();
189
190        // Sort by score descending
191        scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
192
193        // Find cutoff based on gap detection
194        let mut cutoff = scored.len().min(max_k);
195
196        for i in 1..cutoff {
197            let gap = scored[i - 1].score - scored[i].score;
198            if gap >= gap_threshold {
199                cutoff = i;
200                break;
201            }
202        }
203
204        scored.truncate(cutoff);
205        scored
206    }
207
208    /// Get the model ID this index was built with.
209    pub fn model_id(&self) -> &str {
210        &self.model_id
211    }
212
213    /// Get when this index was built.
214    pub fn built_at(&self) -> SystemTime {
215        self.built_at
216    }
217
218    /// Number of skills in the index.
219    pub fn len(&self) -> usize {
220        self.entries.len()
221    }
222
223    /// Check if the index is empty.
224    pub fn is_empty(&self) -> bool {
225        self.entries.is_empty()
226    }
227
228    /// Get all skill names in the index.
229    pub fn skill_names(&self) -> Vec<&SkillName> {
230        self.entries.iter().map(|e| &e.skill_name).collect()
231    }
232
233    /// Get embeddings for a specific skill.
234    pub fn get(&self, name: &SkillName) -> Option<&SkillEmbeddings> {
235        self.entries.iter().find(|e| &e.skill_name == name)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::provider::tests::MockProvider;
243    use std::collections::HashMap;
244    use std::path::PathBuf;
245    use tempfile::TempDir;
246
247    async fn setup_test_registry() -> (TempDir, SkillRegistry) {
248        let temp = TempDir::new().unwrap();
249
250        // Create test skills
251        let skill1 = r#"---
252name: pdf-processing
253description: Extract text and tables from PDF files
254metadata:
255  triggers: "pdf, extract text"
256  tags: "document, extraction"
257---
258
259Instructions for PDF processing.
260"#;
261
262        let skill2 = r#"---
263name: weather-lookup
264description: Get current weather and forecasts
265metadata:
266  triggers: "weather, forecast, temperature"
267  tags: "weather, api"
268---
269
270Instructions for weather lookup.
271"#;
272
273        // Write skills
274        let skill1_dir = temp.path().join("pdf-processing");
275        std::fs::create_dir_all(&skill1_dir).unwrap();
276        std::fs::write(skill1_dir.join("SKILL.md"), skill1).unwrap();
277
278        let skill2_dir = temp.path().join("weather-lookup");
279        std::fs::create_dir_all(&skill2_dir).unwrap();
280        std::fs::write(skill2_dir.join("SKILL.md"), skill2).unwrap();
281
282        let registry = SkillRegistry::new(&[temp.path()]).await.unwrap();
283        (temp, registry)
284    }
285
286    #[tokio::test]
287    async fn test_index_build() {
288        let (_temp, registry) = setup_test_registry().await;
289        let provider = MockProvider::new(384);
290
291        let index = EmbeddingIndex::build(&registry, &provider, ComponentWeights::default())
292            .await
293            .unwrap();
294
295        assert_eq!(index.len(), 2);
296        assert_eq!(index.model_id(), "mock-embedding-model");
297    }
298
299    #[tokio::test]
300    async fn test_index_query() {
301        let (_temp, registry) = setup_test_registry().await;
302        let provider = MockProvider::new(384);
303
304        let index = EmbeddingIndex::build(&registry, &provider, ComponentWeights::default())
305            .await
306            .unwrap();
307
308        let query = provider.embed_one("extract text from pdf").await.unwrap();
309        let results = index.query(&query, 5);
310
311        assert!(!results.is_empty());
312        assert!(results[0].score >= -1.0 && results[0].score <= 1.0);
313    }
314
315    #[tokio::test]
316    async fn test_index_query_adaptive() {
317        let (_temp, registry) = setup_test_registry().await;
318        let provider = MockProvider::new(384);
319
320        let index = EmbeddingIndex::build(&registry, &provider, ComponentWeights::default())
321            .await
322            .unwrap();
323
324        let query = provider.embed_one("pdf").await.unwrap();
325        let results = index.query_adaptive(&query, 0.0, 10, 0.1);
326
327        // Should return some results
328        assert!(!results.is_empty());
329    }
330
331    #[tokio::test]
332    async fn test_index_save_load() {
333        let (_temp, registry) = setup_test_registry().await;
334        let provider = MockProvider::new(384);
335
336        let index = EmbeddingIndex::build(&registry, &provider, ComponentWeights::default())
337            .await
338            .unwrap();
339
340        let cache_dir = TempDir::new().unwrap();
341        let cache_path = cache_dir.path().join("index.bin");
342
343        // Save
344        index.save(&cache_path).unwrap();
345        assert!(cache_path.exists());
346
347        // Load
348        let loaded = EmbeddingIndex::load_cached(&cache_path, &registry)
349            .unwrap()
350            .unwrap();
351
352        assert_eq!(loaded.len(), index.len());
353        assert_eq!(loaded.model_id(), index.model_id());
354    }
355
356    #[tokio::test]
357    async fn test_index_get() {
358        let (_temp, registry) = setup_test_registry().await;
359        let provider = MockProvider::new(384);
360
361        let index = EmbeddingIndex::build(&registry, &provider, ComponentWeights::default())
362            .await
363            .unwrap();
364
365        let name = SkillName::new("pdf-processing").unwrap();
366        let embeddings = index.get(&name).unwrap();
367
368        assert_eq!(embeddings.skill_name, name);
369    }
370}