Skip to main content

everruns_core/
vector_store.rs

1//! Vendor-neutral vector store abstraction for Knowledge Indexes.
2//!
3//! See `specs/knowledge-indexes.md`. Embedding vectors for Knowledge Index
4//! chunks live in an external vector database, not in Postgres. This module
5//! defines the platform-selected `VectorStore` trait (mirroring how
6//! `SessionFileSystemFactory` keeps the filesystem pluggable) plus an
7//! `InMemoryVectorStore` used by dev mode and storage-parity tests.
8//!
9//! The store is **multitenant and multi-index** by construction: one namespace
10//! per index, org-prefixed for isolation. Callers derive the namespace with
11//! [`index_namespace`] and must validate the index's `org_id` before issuing a
12//! call so cross-org reads are structurally impossible. The reference
13//! production backend (Turbopuffer) maps each namespace onto a Turbopuffer
14//! namespace.
15
16use std::collections::HashMap;
17use std::sync::Mutex;
18
19use anyhow::Result;
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22
23/// Reciprocal-rank-fusion constant for hybrid (vector + text) queries.
24const RRF_K: f32 = 60.0;
25
26/// A single retrieved chunk returned by [`KnowledgeIndexSearch::search`], shaped
27/// to the `search_index` citation contract in `specs/knowledge-indexes.md`. The
28/// `id` (`kchk_…`) + `source_uri` + `location` give the agent a stable, linkable
29/// citation. Kept compatible with the planned `search_knowledge` citation shape
30/// so the UI can render both uniformly.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct KnowledgeIndexCitation {
33    /// Chunk `public_id` (`kchk_…`). The stable citation id.
34    pub id: String,
35    /// Owning Knowledge Index `public_id` (`kidx_…`).
36    pub index_id: String,
37    /// Title of the source document, if known.
38    pub document_title: Option<String>,
39    /// Stable per-source locator (e.g. `github://owner/repo@main/docs/x.md`).
40    pub source_uri: String,
41    /// Provenance within the document (line / char / page ranges).
42    pub location: Option<serde_json::Value>,
43    /// A trimmed prefix of the chunk passage.
44    pub snippet: String,
45    /// Relevance score; higher is more relevant. Use for ordering only.
46    pub score: f32,
47}
48
49/// Server-implemented hybrid retrieval over an org's bound Knowledge Indexes.
50///
51/// Carried on `ToolContext` so the `search_index` agent tool can reach the
52/// server-side embedding + vector-store machinery without `everruns-core`
53/// depending on server types (mirrors `PlatformStore` / `UserConnectionResolver`).
54#[async_trait]
55pub trait KnowledgeIndexSearch: Send + Sync {
56    /// Embed `query`, run hybrid retrieval against each bound index's
57    /// vector-store namespace, and return up to `top_k` citations ordered by
58    /// score (descending).
59    ///
60    /// `index_ids` are the `kidx_` public ids bound in the capability config.
61    /// Ids that are missing, cross-org, archived, or deleted are silently
62    /// skipped — no existence leak.
63    async fn search(
64        &self,
65        org_id: i64,
66        index_ids: &[String],
67        query: &str,
68        top_k: usize,
69    ) -> Result<Vec<KnowledgeIndexCitation>>;
70}
71
72/// Derive the vector-store namespace for an index.
73///
74/// Org-prefixed so every query targets a single, org-derived namespace and
75/// cross-org reads cannot happen. `public_id` is the `kidx_…` index id.
76pub fn index_namespace(org_id: i64, public_id: &str) -> String {
77    format!("org_{org_id}__{public_id}")
78}
79
80/// A single point stored in the vector store, keyed by the chunk `public_id`
81/// (`kchk_…`), which is also the stable citation id.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct VectorRecord {
84    /// Chunk `public_id` (`kchk_…`). Stable citation id and primary key.
85    pub id: String,
86    /// Embedding for the chunk. Length must match the index's `vector_dim`.
87    pub vector: Vec<f32>,
88    /// Chunk passage text, stored to enable BM25 full-text scoring.
89    pub text: String,
90    /// Owning document `public_id` (`kidoc_…`), used for bulk delete on re-sync.
91    pub document_id: String,
92}
93
94/// A retrieval request against a single namespace. At least one of `vector`
95/// (semantic KNN) or `text` (BM25) must be set; when both are present the
96/// backend fuses them with reciprocal-rank fusion.
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct VectorQuery {
99    /// Query embedding for semantic KNN.
100    pub vector: Option<Vec<f32>>,
101    /// Query text for full-text (BM25) scoring.
102    pub text: Option<String>,
103    /// Maximum number of matches to return.
104    pub top_k: usize,
105}
106
107/// A ranked match returned from [`VectorStore::query`].
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct VectorMatch {
110    /// Chunk `public_id` (`kchk_…`) — hydrate text/citation from Postgres.
111    pub id: String,
112    /// Owning document `public_id` (`kidoc_…`).
113    pub document_id: String,
114    /// Higher is more relevant. Scale is backend-defined; use for ordering only.
115    pub score: f32,
116}
117
118/// Pluggable embedding store for Knowledge Indexes. Selected through
119/// `PlatformDefinition`; the in-memory backend backs dev/tests, Turbopuffer is
120/// the reference production backend.
121#[async_trait]
122pub trait VectorStore: Send + Sync {
123    /// Insert or replace records in a namespace, keyed by `VectorRecord::id`.
124    async fn upsert(&self, namespace: &str, records: Vec<VectorRecord>) -> Result<()>;
125
126    /// Rank records in a namespace against the query. Returns up to
127    /// `query.top_k` matches, most relevant first.
128    async fn query(&self, namespace: &str, query: VectorQuery) -> Result<Vec<VectorMatch>>;
129
130    /// Remove every record belonging to a document (used on document re-sync).
131    async fn delete_by_document(&self, namespace: &str, document_id: &str) -> Result<()>;
132
133    /// Drop an entire namespace (used on index hard delete).
134    async fn delete_namespace(&self, namespace: &str) -> Result<()>;
135}
136
137/// In-memory brute-force `VectorStore` for dev mode and storage-parity tests.
138///
139/// Vector ranking uses cosine similarity; text ranking uses a simple
140/// term-overlap score standing in for BM25. Hybrid queries fuse the two ranked
141/// lists with reciprocal-rank fusion. Not for production scale.
142#[derive(Default)]
143pub struct InMemoryVectorStore {
144    namespaces: Mutex<HashMap<String, Vec<VectorRecord>>>,
145}
146
147impl InMemoryVectorStore {
148    pub fn new() -> Self {
149        Self::default()
150    }
151}
152
153#[async_trait]
154impl VectorStore for InMemoryVectorStore {
155    async fn upsert(&self, namespace: &str, records: Vec<VectorRecord>) -> Result<()> {
156        let mut store = self.namespaces.lock().expect("vector store poisoned");
157        let entry = store.entry(namespace.to_string()).or_default();
158        for record in records {
159            if let Some(existing) = entry.iter_mut().find(|r| r.id == record.id) {
160                *existing = record;
161            } else {
162                entry.push(record);
163            }
164        }
165        Ok(())
166    }
167
168    async fn query(&self, namespace: &str, query: VectorQuery) -> Result<Vec<VectorMatch>> {
169        if query.top_k == 0 {
170            return Ok(Vec::new());
171        }
172        let store = self.namespaces.lock().expect("vector store poisoned");
173        let Some(records) = store.get(namespace) else {
174            return Ok(Vec::new());
175        };
176
177        let vector_ranked = query
178            .vector
179            .as_ref()
180            .map(|v| rank_by(records, |r| cosine_similarity(&r.vector, v)));
181        let text_ranked = query.text.as_ref().and_then(|t| {
182            let t = t.trim();
183            (!t.is_empty()).then(|| rank_by(records, |r| term_overlap_score(&r.text, t)))
184        });
185
186        let ordered_ids = match (vector_ranked, text_ranked) {
187            (Some(v), Some(t)) => fuse_rrf(&v, &t),
188            (Some(v), None) => v,
189            (None, Some(t)) => t,
190            // No query signal: nothing to rank against.
191            (None, None) => return Ok(Vec::new()),
192        };
193
194        let by_id: HashMap<&str, &VectorRecord> =
195            records.iter().map(|r| (r.id.as_str(), r)).collect();
196        let matches = ordered_ids
197            .into_iter()
198            .take(query.top_k)
199            .filter_map(|(id, score)| {
200                by_id.get(id.as_str()).map(|r| VectorMatch {
201                    id: r.id.clone(),
202                    document_id: r.document_id.clone(),
203                    score,
204                })
205            })
206            .collect();
207        Ok(matches)
208    }
209
210    async fn delete_by_document(&self, namespace: &str, document_id: &str) -> Result<()> {
211        let mut store = self.namespaces.lock().expect("vector store poisoned");
212        if let Some(entry) = store.get_mut(namespace) {
213            entry.retain(|r| r.document_id != document_id);
214        }
215        Ok(())
216    }
217
218    async fn delete_namespace(&self, namespace: &str) -> Result<()> {
219        let mut store = self.namespaces.lock().expect("vector store poisoned");
220        store.remove(namespace);
221        Ok(())
222    }
223}
224
225/// Rank records by a scoring function, descending. Returns `(id, score)` pairs.
226fn rank_by(records: &[VectorRecord], score: impl Fn(&VectorRecord) -> f32) -> Vec<(String, f32)> {
227    let mut scored: Vec<(String, f32)> = records
228        .iter()
229        .map(|r| (r.id.clone(), score(r)))
230        .filter(|(_, s)| *s > f32::NEG_INFINITY)
231        .collect();
232    scored.sort_by(|a, b| b.1.total_cmp(&a.1));
233    scored
234}
235
236/// Fuse two ranked lists with reciprocal-rank fusion, returning ids ordered by
237/// fused score (descending). The score carried out is the RRF score.
238fn fuse_rrf(a: &[(String, f32)], b: &[(String, f32)]) -> Vec<(String, f32)> {
239    let mut fused: HashMap<&str, f32> = HashMap::new();
240    for list in [a, b] {
241        for (rank, (id, _)) in list.iter().enumerate() {
242            *fused.entry(id.as_str()).or_insert(0.0) += 1.0 / (RRF_K + rank as f32 + 1.0);
243        }
244    }
245    let mut ranked: Vec<(String, f32)> = fused
246        .into_iter()
247        .map(|(id, s)| (id.to_string(), s))
248        .collect();
249    ranked.sort_by(|x, y| y.1.total_cmp(&x.1));
250    ranked
251}
252
253/// Cosine similarity in [-1, 1]; mismatched dimensions or zero vectors score
254/// as the minimum so they sort last.
255fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
256    if a.len() != b.len() || a.is_empty() {
257        return f32::NEG_INFINITY;
258    }
259    let mut dot = 0.0;
260    let mut norm_a = 0.0;
261    let mut norm_b = 0.0;
262    for (x, y) in a.iter().zip(b.iter()) {
263        dot += x * y;
264        norm_a += x * x;
265        norm_b += y * y;
266    }
267    if norm_a == 0.0 || norm_b == 0.0 {
268        return f32::NEG_INFINITY;
269    }
270    dot / (norm_a.sqrt() * norm_b.sqrt())
271}
272
273/// Fraction of distinct query terms present in the text (case-insensitive).
274/// A lightweight stand-in for BM25 in the in-memory backend.
275fn term_overlap_score(text: &str, query: &str) -> f32 {
276    let haystack = text.to_lowercase();
277    let terms: Vec<String> = query
278        .to_lowercase()
279        .split_whitespace()
280        .map(str::to_string)
281        .collect::<std::collections::BTreeSet<_>>()
282        .into_iter()
283        .collect();
284    if terms.is_empty() {
285        return f32::NEG_INFINITY;
286    }
287    let hits = terms.iter().filter(|t| haystack.contains(*t)).count();
288    if hits == 0 {
289        f32::NEG_INFINITY
290    } else {
291        hits as f32 / terms.len() as f32
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn record(id: &str, doc: &str, vector: Vec<f32>, text: &str) -> VectorRecord {
300        VectorRecord {
301            id: id.to_string(),
302            vector,
303            text: text.to_string(),
304            document_id: doc.to_string(),
305        }
306    }
307
308    #[test]
309    fn namespace_is_org_prefixed() {
310        assert_eq!(
311            index_namespace(1, "kidx_00000000000000000000000000000001"),
312            "org_1__kidx_00000000000000000000000000000001"
313        );
314    }
315
316    #[tokio::test]
317    async fn vector_query_ranks_by_cosine() {
318        let store = InMemoryVectorStore::new();
319        let ns = index_namespace(1, "kidx_00000000000000000000000000000001");
320        store
321            .upsert(
322                &ns,
323                vec![
324                    record("kchk_a", "kidoc_1", vec![1.0, 0.0], "alpha"),
325                    record("kchk_b", "kidoc_1", vec![0.0, 1.0], "beta"),
326                    record("kchk_c", "kidoc_2", vec![0.9, 0.1], "gamma"),
327                ],
328            )
329            .await
330            .unwrap();
331
332        let matches = store
333            .query(
334                &ns,
335                VectorQuery {
336                    vector: Some(vec![1.0, 0.0]),
337                    text: None,
338                    top_k: 2,
339                },
340            )
341            .await
342            .unwrap();
343
344        let ids: Vec<_> = matches.iter().map(|m| m.id.as_str()).collect();
345        assert_eq!(ids, vec!["kchk_a", "kchk_c"]);
346        assert_eq!(matches[0].document_id, "kidoc_1");
347    }
348
349    #[tokio::test]
350    async fn upsert_replaces_existing_id() {
351        let store = InMemoryVectorStore::new();
352        let ns = "org_1__kidx_x";
353        store
354            .upsert(ns, vec![record("kchk_a", "kidoc_1", vec![1.0, 0.0], "old")])
355            .await
356            .unwrap();
357        store
358            .upsert(ns, vec![record("kchk_a", "kidoc_1", vec![0.0, 1.0], "new")])
359            .await
360            .unwrap();
361
362        let matches = store
363            .query(
364                ns,
365                VectorQuery {
366                    vector: Some(vec![0.0, 1.0]),
367                    text: None,
368                    top_k: 5,
369                },
370            )
371            .await
372            .unwrap();
373        assert_eq!(matches.len(), 1);
374        assert!(matches[0].score > 0.99);
375    }
376
377    #[tokio::test]
378    async fn text_query_ranks_by_term_overlap() {
379        let store = InMemoryVectorStore::new();
380        let ns = "org_1__kidx_x";
381        store
382            .upsert(
383                ns,
384                vec![
385                    record("kchk_a", "kidoc_1", vec![0.0], "the quick brown fox"),
386                    record("kchk_b", "kidoc_1", vec![0.0], "a slow green turtle"),
387                ],
388            )
389            .await
390            .unwrap();
391
392        let matches = store
393            .query(
394                ns,
395                VectorQuery {
396                    vector: None,
397                    text: Some("quick fox".to_string()),
398                    top_k: 5,
399                },
400            )
401            .await
402            .unwrap();
403        assert_eq!(matches.len(), 1);
404        assert_eq!(matches[0].id, "kchk_a");
405    }
406
407    #[tokio::test]
408    async fn delete_by_document_and_namespace() {
409        let store = InMemoryVectorStore::new();
410        let ns = "org_1__kidx_x";
411        store
412            .upsert(
413                ns,
414                vec![
415                    record("kchk_a", "kidoc_1", vec![1.0], "a"),
416                    record("kchk_b", "kidoc_2", vec![1.0], "b"),
417                ],
418            )
419            .await
420            .unwrap();
421
422        store.delete_by_document(ns, "kidoc_1").await.unwrap();
423        let after = store
424            .query(
425                ns,
426                VectorQuery {
427                    vector: Some(vec![1.0]),
428                    text: None,
429                    top_k: 5,
430                },
431            )
432            .await
433            .unwrap();
434        assert_eq!(after.len(), 1);
435        assert_eq!(after[0].id, "kchk_b");
436
437        store.delete_namespace(ns).await.unwrap();
438        let empty = store
439            .query(
440                ns,
441                VectorQuery {
442                    vector: Some(vec![1.0]),
443                    text: None,
444                    top_k: 5,
445                },
446            )
447            .await
448            .unwrap();
449        assert!(empty.is_empty());
450    }
451
452    #[tokio::test]
453    async fn hybrid_query_fuses_both_signals() {
454        let store = InMemoryVectorStore::new();
455        let ns = "org_1__kidx_x";
456        store
457            .upsert(
458                ns,
459                vec![
460                    record(
461                        "kchk_a",
462                        "kidoc_1",
463                        vec![1.0, 0.0],
464                        "database indexing guide",
465                    ),
466                    record("kchk_b", "kidoc_1", vec![0.0, 1.0], "cooking recipes"),
467                ],
468            )
469            .await
470            .unwrap();
471
472        let matches = store
473            .query(
474                ns,
475                VectorQuery {
476                    vector: Some(vec![1.0, 0.0]),
477                    text: Some("database".to_string()),
478                    top_k: 2,
479                },
480            )
481            .await
482            .unwrap();
483        assert_eq!(matches[0].id, "kchk_a");
484    }
485}