1use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6
7use tokio::sync::RwLock;
8
9use crate::auth::TenantScope;
10use crate::error::Error;
11
12use super::{Chunk, KnowledgeBase, KnowledgeQuery, SearchResult};
13
14pub struct InMemoryKnowledgeBase {
21 chunks: RwLock<HashMap<(String, String), Chunk>>,
27}
28
29impl InMemoryKnowledgeBase {
30 pub fn new() -> Self {
32 Self {
33 chunks: RwLock::new(HashMap::new()),
34 }
35 }
36}
37
38impl Default for InMemoryKnowledgeBase {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44fn tokenize(text: &str) -> Vec<String> {
46 let mut seen = std::collections::HashSet::new();
47 text.split_whitespace()
48 .map(|w| {
49 w.to_lowercase()
50 .trim_matches(|c: char| !c.is_alphanumeric())
51 .to_string()
52 })
53 .filter(|w| !w.is_empty() && seen.insert(w.clone()))
54 .collect()
55}
56
57fn count_matches(query_tokens: &[String], content: &str) -> usize {
59 let lower = content.to_lowercase();
60 query_tokens
61 .iter()
62 .filter(|t| lower.contains(t.as_str()))
63 .count()
64}
65
66impl KnowledgeBase for InMemoryKnowledgeBase {
67 fn index(
68 &self,
69 scope: &TenantScope,
70 mut chunk: Chunk,
71 ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
72 let tid = scope.tenant_id.clone();
75 Box::pin(async move {
76 chunk.tenant_id = if tid.is_empty() {
77 None
78 } else {
79 Some(tid.clone())
80 };
81 let key = (tid, chunk.id.clone());
82 let mut data = self.chunks.write().await;
83 data.insert(key, chunk);
84 Ok(())
85 })
86 }
87
88 fn search(
89 &self,
90 scope: &TenantScope,
91 query: KnowledgeQuery,
92 ) -> Pin<Box<dyn Future<Output = Result<Vec<SearchResult>, Error>> + Send + '_>> {
93 let tid = scope.tenant_id.clone();
94 Box::pin(async move {
95 let data = self.chunks.read().await;
96 let tokens = tokenize(&query.text);
97
98 if tokens.is_empty() {
99 return Ok(vec![]);
100 }
101
102 let tenant_match = move |chunk: &Chunk| -> bool {
105 let chunk_tid = chunk.tenant_id.as_deref().unwrap_or("");
106 chunk_tid == tid.as_str()
107 };
108
109 let mut results: Vec<SearchResult> = data
110 .values()
111 .filter(|chunk| tenant_match(chunk))
112 .filter(|chunk| {
113 if let Some(ref filter) = query.source_filter {
114 chunk.source.uri.starts_with(filter)
115 } else {
116 true
117 }
118 })
119 .filter_map(|chunk| {
120 let matches = count_matches(&tokens, &chunk.content);
121 if matches > 0 {
122 Some(SearchResult {
123 chunk: chunk.clone(),
124 match_count: matches,
125 })
126 } else {
127 None
128 }
129 })
130 .collect();
131
132 results.sort_by(|a, b| {
134 b.match_count
135 .cmp(&a.match_count)
136 .then_with(|| a.chunk.chunk_index.cmp(&b.chunk.chunk_index))
137 .then_with(|| a.chunk.source.uri.cmp(&b.chunk.source.uri))
138 });
139
140 if query.limit > 0 {
141 results.truncate(query.limit);
142 }
143
144 Ok(results)
145 })
146 }
147
148 fn chunk_count(
149 &self,
150 scope: &TenantScope,
151 ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
152 let tid = scope.tenant_id.clone();
153 Box::pin(async move {
154 let data = self.chunks.read().await;
155 let count = data
156 .values()
157 .filter(|c| c.tenant_id.as_deref().unwrap_or("") == tid.as_str())
158 .count();
159 Ok(count)
160 })
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::knowledge::DocumentSource;
168 use std::sync::Arc;
169
170 fn make_chunk(id: &str, content: &str, uri: &str, index: usize) -> Chunk {
171 Chunk {
172 id: id.into(),
173 content: content.into(),
174 source: DocumentSource {
175 uri: uri.into(),
176 title: uri.into(),
177 },
178 chunk_index: index,
179 tenant_id: None,
180 }
181 }
182
183 fn s() -> TenantScope {
184 TenantScope::default()
185 }
186
187 #[tokio::test]
188 async fn index_and_search_roundtrip() {
189 let kb = InMemoryKnowledgeBase::new();
190 kb.index(
191 &s(),
192 make_chunk(
193 "c1",
194 "Rust is a systems programming language",
195 "docs/rust.md",
196 0,
197 ),
198 )
199 .await
200 .unwrap();
201
202 let results = kb
203 .search(
204 &s(),
205 KnowledgeQuery {
206 text: "rust programming".into(),
207 source_filter: None,
208 limit: 5,
209 },
210 )
211 .await
212 .unwrap();
213
214 assert_eq!(results.len(), 1);
215 assert_eq!(results[0].chunk.id, "c1");
216 assert_eq!(results[0].match_count, 2); }
218
219 #[tokio::test]
220 async fn search_is_case_insensitive() {
221 let kb = InMemoryKnowledgeBase::new();
222 kb.index(&s(), make_chunk("c1", "RUST is GREAT", "f.md", 0))
223 .await
224 .unwrap();
225
226 let results = kb
227 .search(
228 &s(),
229 KnowledgeQuery {
230 text: "rust great".into(),
231 source_filter: None,
232 limit: 5,
233 },
234 )
235 .await
236 .unwrap();
237
238 assert_eq!(results.len(), 1);
239 assert_eq!(results[0].match_count, 2);
240 }
241
242 #[tokio::test]
243 async fn source_filter_restricts_results() {
244 let kb = InMemoryKnowledgeBase::new();
245 kb.index(&s(), make_chunk("c1", "Rust language", "docs/rust.md", 0))
246 .await
247 .unwrap();
248 kb.index(&s(), make_chunk("c2", "Rust compiler", "api/rust.md", 0))
249 .await
250 .unwrap();
251
252 let results = kb
253 .search(
254 &s(),
255 KnowledgeQuery {
256 text: "rust".into(),
257 source_filter: Some("docs/".into()),
258 limit: 10,
259 },
260 )
261 .await
262 .unwrap();
263
264 assert_eq!(results.len(), 1);
265 assert_eq!(results[0].chunk.source.uri, "docs/rust.md");
266 }
267
268 #[tokio::test]
269 async fn limit_truncates_results() {
270 let kb = InMemoryKnowledgeBase::new();
271 for i in 0..10 {
272 kb.index(
273 &s(),
274 make_chunk(
275 &format!("c{i}"),
276 "rust programming language",
277 "docs/rust.md",
278 i,
279 ),
280 )
281 .await
282 .unwrap();
283 }
284
285 let results = kb
286 .search(
287 &s(),
288 KnowledgeQuery {
289 text: "rust".into(),
290 source_filter: None,
291 limit: 3,
292 },
293 )
294 .await
295 .unwrap();
296
297 assert_eq!(results.len(), 3);
298 }
299
300 #[tokio::test]
301 async fn sorted_by_match_count_descending() {
302 let kb = InMemoryKnowledgeBase::new();
303 kb.index(&s(), make_chunk("c1", "rust", "f.md", 0))
304 .await
305 .unwrap();
306 kb.index(
307 &s(),
308 make_chunk("c2", "rust programming rust systems", "f.md", 1),
309 )
310 .await
311 .unwrap();
312 kb.index(&s(), make_chunk("c3", "rust programming", "f.md", 2))
313 .await
314 .unwrap();
315
316 let results = kb
317 .search(
318 &s(),
319 KnowledgeQuery {
320 text: "rust programming systems".into(),
321 source_filter: None,
322 limit: 10,
323 },
324 )
325 .await
326 .unwrap();
327
328 assert_eq!(results.len(), 3);
329 assert_eq!(results[0].chunk.id, "c2"); assert_eq!(results[1].chunk.id, "c3"); assert_eq!(results[2].chunk.id, "c1"); }
333
334 #[tokio::test]
335 async fn reindex_replaces_chunk() {
336 let kb = InMemoryKnowledgeBase::new();
337 kb.index(&s(), make_chunk("c1", "old content", "f.md", 0))
338 .await
339 .unwrap();
340 kb.index(&s(), make_chunk("c1", "new content about rust", "f.md", 0))
341 .await
342 .unwrap();
343
344 assert_eq!(kb.chunk_count(&s()).await.unwrap(), 1);
345
346 let results = kb
347 .search(
348 &s(),
349 KnowledgeQuery {
350 text: "rust".into(),
351 source_filter: None,
352 limit: 5,
353 },
354 )
355 .await
356 .unwrap();
357
358 assert_eq!(results.len(), 1);
359 assert!(results[0].chunk.content.contains("new content"));
360 }
361
362 #[tokio::test]
363 async fn empty_query_returns_no_results() {
364 let kb = InMemoryKnowledgeBase::new();
365 kb.index(&s(), make_chunk("c1", "some content", "f.md", 0))
366 .await
367 .unwrap();
368
369 let results = kb
370 .search(
371 &s(),
372 KnowledgeQuery {
373 text: "".into(),
374 source_filter: None,
375 limit: 5,
376 },
377 )
378 .await
379 .unwrap();
380
381 assert!(results.is_empty());
382 }
383
384 #[tokio::test]
385 async fn no_match_returns_empty() {
386 let kb = InMemoryKnowledgeBase::new();
387 kb.index(&s(), make_chunk("c1", "hello world", "f.md", 0))
388 .await
389 .unwrap();
390
391 let results = kb
392 .search(
393 &s(),
394 KnowledgeQuery {
395 text: "zzzznotfound".into(),
396 source_filter: None,
397 limit: 5,
398 },
399 )
400 .await
401 .unwrap();
402
403 assert!(results.is_empty());
404 }
405
406 #[tokio::test]
407 async fn chunk_count_tracks_size() {
408 let kb = InMemoryKnowledgeBase::new();
409 assert_eq!(kb.chunk_count(&s()).await.unwrap(), 0);
410
411 kb.index(&s(), make_chunk("c1", "a", "f.md", 0))
412 .await
413 .unwrap();
414 kb.index(&s(), make_chunk("c2", "b", "f.md", 1))
415 .await
416 .unwrap();
417 assert_eq!(kb.chunk_count(&s()).await.unwrap(), 2);
418 }
419
420 #[test]
421 fn is_send_sync() {
422 fn assert_send_sync<T: Send + Sync>() {}
423 assert_send_sync::<InMemoryKnowledgeBase>();
424 fn _accepts_dyn(_kb: &dyn KnowledgeBase) {}
425 }
426
427 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
428 async fn concurrent_index_and_search() {
429 let kb = Arc::new(InMemoryKnowledgeBase::new());
430 let mut handles = Vec::new();
431
432 for i in 0..20 {
434 let kb = kb.clone();
435 handles.push(tokio::spawn(async move {
436 kb.index(
437 &s(),
438 make_chunk(
439 &format!("c{i}"),
440 &format!("rust content item {i}"),
441 "f.md",
442 i,
443 ),
444 )
445 .await
446 .unwrap();
447 }));
448 }
449
450 for _ in 0..10 {
452 let kb = kb.clone();
453 handles.push(tokio::spawn(async move {
454 let _ = kb
455 .search(
456 &s(),
457 KnowledgeQuery {
458 text: "rust".into(),
459 source_filter: None,
460 limit: 5,
461 },
462 )
463 .await
464 .unwrap();
465 }));
466 }
467
468 for h in handles {
469 h.await.unwrap();
470 }
471
472 assert_eq!(kb.chunk_count(&s()).await.unwrap(), 20);
473 }
474
475 #[tokio::test]
476 async fn duplicate_query_terms_not_inflated() {
477 let kb = InMemoryKnowledgeBase::new();
478 kb.index(&s(), make_chunk("c1", "rust is great", "f.md", 0))
479 .await
480 .unwrap();
481
482 let results = kb
483 .search(
484 &s(),
485 KnowledgeQuery {
486 text: "rust rust rust".into(),
487 source_filter: None,
488 limit: 5,
489 },
490 )
491 .await
492 .unwrap();
493
494 assert_eq!(results.len(), 1);
495 assert_eq!(results[0].match_count, 1); }
497
498 #[tokio::test]
502 async fn same_chunk_id_across_tenants_does_not_clobber() {
503 let kb = InMemoryKnowledgeBase::new();
504 let scope_a = TenantScope::new("tenant-a");
505 let scope_b = TenantScope::new("tenant-b");
506
507 kb.index(
509 &scope_a,
510 make_chunk("readme-0", "alice rust document", "README.md", 0),
511 )
512 .await
513 .unwrap();
514 kb.index(
515 &scope_b,
516 make_chunk("readme-0", "bob rust document", "README.md", 0),
517 )
518 .await
519 .unwrap();
520
521 assert_eq!(kb.chunk_count(&scope_a).await.unwrap(), 1);
523 assert_eq!(kb.chunk_count(&scope_b).await.unwrap(), 1);
524
525 let results_a = kb
526 .search(
527 &scope_a,
528 KnowledgeQuery {
529 text: "alice".into(),
530 source_filter: None,
531 limit: 10,
532 },
533 )
534 .await
535 .unwrap();
536 assert_eq!(results_a.len(), 1, "tenant A's chunk was clobbered");
537 assert!(results_a[0].chunk.content.contains("alice"));
538 }
539
540 #[tokio::test]
545 async fn search_isolates_by_tenant() {
546 let kb = InMemoryKnowledgeBase::new();
547 let scope_a = TenantScope::new("tenant-a");
548 let scope_b = TenantScope::new("tenant-b");
549
550 kb.index(
551 &scope_a,
552 make_chunk("a1", "alice secret rust note", "a/notes.md", 0),
553 )
554 .await
555 .unwrap();
556 kb.index(
557 &scope_b,
558 make_chunk("b1", "bob secret rust note", "b/notes.md", 0),
559 )
560 .await
561 .unwrap();
562
563 let results_a = kb
565 .search(
566 &scope_a,
567 KnowledgeQuery {
568 text: "rust".into(),
569 source_filter: None,
570 limit: 10,
571 },
572 )
573 .await
574 .unwrap();
575 assert_eq!(results_a.len(), 1);
576 assert_eq!(results_a[0].chunk.id, "a1");
577
578 let results_b = kb
580 .search(
581 &scope_b,
582 KnowledgeQuery {
583 text: "rust".into(),
584 source_filter: None,
585 limit: 10,
586 },
587 )
588 .await
589 .unwrap();
590 assert_eq!(results_b.len(), 1);
591 assert_eq!(results_b[0].chunk.id, "b1");
592
593 assert_eq!(kb.chunk_count(&scope_a).await.unwrap(), 1);
595 assert_eq!(kb.chunk_count(&scope_b).await.unwrap(), 1);
596 }
597
598 #[tokio::test]
599 async fn sort_stable_across_sources() {
600 let kb = InMemoryKnowledgeBase::new();
601 kb.index(&s(), make_chunk("c1", "rust programming", "z_file.md", 0))
602 .await
603 .unwrap();
604 kb.index(&s(), make_chunk("c2", "rust programming", "a_file.md", 0))
605 .await
606 .unwrap();
607
608 let results = kb
609 .search(
610 &s(),
611 KnowledgeQuery {
612 text: "rust".into(),
613 source_filter: None,
614 limit: 10,
615 },
616 )
617 .await
618 .unwrap();
619
620 assert_eq!(results.len(), 2);
621 assert_eq!(results[0].chunk.source.uri, "a_file.md");
623 assert_eq!(results[1].chunk.source.uri, "z_file.md");
624 }
625}