1use std::collections::HashMap;
17use std::sync::Mutex;
18
19use anyhow::Result;
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22
23const RRF_K: f32 = 60.0;
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct KnowledgeIndexCitation {
33 pub id: String,
35 pub index_id: String,
37 pub document_title: Option<String>,
39 pub source_uri: String,
41 pub location: Option<serde_json::Value>,
43 pub snippet: String,
45 pub score: f32,
47}
48
49#[async_trait]
55pub trait KnowledgeIndexSearch: Send + Sync {
56 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
72pub fn index_namespace(org_id: i64, public_id: &str) -> String {
77 format!("org_{org_id}__{public_id}")
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct VectorRecord {
84 pub id: String,
86 pub vector: Vec<f32>,
88 pub text: String,
90 pub document_id: String,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct VectorQuery {
99 pub vector: Option<Vec<f32>>,
101 pub text: Option<String>,
103 pub top_k: usize,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct VectorMatch {
110 pub id: String,
112 pub document_id: String,
114 pub score: f32,
116}
117
118#[async_trait]
122pub trait VectorStore: Send + Sync {
123 async fn upsert(&self, namespace: &str, records: Vec<VectorRecord>) -> Result<()>;
125
126 async fn query(&self, namespace: &str, query: VectorQuery) -> Result<Vec<VectorMatch>>;
129
130 async fn delete_by_document(&self, namespace: &str, document_id: &str) -> Result<()>;
132
133 async fn delete_namespace(&self, namespace: &str) -> Result<()>;
135}
136
137#[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 (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
225fn 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
236fn 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
253fn 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
273fn 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}