1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
23
24use parking_lot::RwLock;
25
26use super::entity::{EntityData, EntityId, EntityKind, UnifiedEntity};
27use super::tokenization::{push_text_tokens, push_value_tokens, MAX_INDEX_TOKENS};
28use reddb_types::Value;
29
30const MAX_FIELD_INDEX_PAIRS: usize = 1024;
31
32#[derive(Debug, Clone)]
38pub struct ContextPosting {
39 pub entity_id: EntityId,
40 pub collection: String,
41 pub field: String,
42}
43
44#[derive(Debug, Clone)]
46pub struct ContextSearchHit {
47 pub entity_id: EntityId,
48 pub collection: String,
49 pub score: f32,
50 pub matched_tokens: usize,
51 pub total_tokens: usize,
52}
53
54#[derive(Debug, Clone, Default)]
56pub struct ContextIndexStats {
57 pub indexed_entities: usize,
58 pub token_count: usize,
59 pub field_value_count: usize,
60 pub total_postings: usize,
61}
62
63#[derive(Debug, Clone, Default)]
65struct EntityKeys {
66 token_keys: Vec<String>,
67 field_value_keys: Vec<(String, String)>,
68}
69
70pub struct ContextIndex {
79 tokens: RwLock<BTreeMap<String, Vec<ContextPosting>>>,
81 field_values: RwLock<BTreeMap<(String, String), Vec<ContextPosting>>>,
83 reverse: RwLock<HashMap<u64, EntityKeys>>,
85 indexed: RwLock<HashSet<u64>>,
87 enabled_collections: RwLock<HashSet<String>>,
91}
92
93impl ContextIndex {
94 pub fn new() -> Self {
96 Self {
97 tokens: RwLock::new(BTreeMap::new()),
98 field_values: RwLock::new(BTreeMap::new()),
99 reverse: RwLock::new(HashMap::new()),
100 indexed: RwLock::new(HashSet::new()),
101 enabled_collections: RwLock::new(HashSet::new()),
102 }
103 }
104
105 pub fn set_collection_enabled(&self, collection: &str, enabled: bool) {
108 let mut set = self.enabled_collections.write();
109 if enabled {
110 set.insert(collection.to_string());
111 } else {
112 set.remove(collection);
113 }
114 }
115
116 pub fn is_collection_enabled(&self, collection: &str) -> bool {
118 self.enabled_collections.read().contains(collection)
119 }
120
121 pub fn index_entity(&self, collection: &str, entity: &UnifiedEntity) {
123 if !self.is_collection_enabled(collection) {
124 return;
125 }
126 self.index_entities(collection, std::iter::once(entity));
127 }
128
129 pub fn index_entities<'a, I>(&self, collection: &str, entities: I)
132 where
133 I: IntoIterator<Item = &'a UnifiedEntity>,
134 {
135 if !self.is_collection_enabled(collection) {
136 return;
137 }
138 let collection = collection.to_string();
139 let prepared: Vec<(
140 u64,
141 EntityKeys,
142 Vec<(String, String)>,
143 Vec<(String, String)>,
144 )> = entities
145 .into_iter()
146 .map(|entity| {
147 let entity_tokens = extract_entity_tokens(entity);
148 let field_pairs = extract_field_lookup_pairs(entity);
149 let keys = EntityKeys {
150 token_keys: entity_tokens
151 .iter()
152 .map(|(token, _)| token.clone())
153 .collect(),
154 field_value_keys: field_pairs.clone(),
155 };
156 (entity.id.raw(), keys, entity_tokens, field_pairs)
157 })
158 .collect();
159
160 if prepared.is_empty() {
161 return;
162 }
163
164 let previous_keys: Vec<(u64, EntityKeys)> = {
165 let mut reverse = self.reverse.write();
166 prepared
167 .iter()
168 .filter_map(|(entity_id, _, _, _)| {
169 reverse.remove(entity_id).map(|keys| (*entity_id, keys))
170 })
171 .collect()
172 };
173
174 {
175 let mut index = self.tokens.write();
176 for (entity_id, keys) in &previous_keys {
177 let entity_id = EntityId::new(*entity_id);
178 for key in &keys.token_keys {
179 if let Some(postings) = index.get_mut(key) {
180 postings.retain(|posting| posting.entity_id != entity_id);
181 if postings.is_empty() {
182 index.remove(key);
183 }
184 }
185 }
186 }
187
188 for (entity_id, _, entity_tokens, _) in &prepared {
189 let entity_id = EntityId::new(*entity_id);
190 for (token, field) in entity_tokens {
191 index
192 .entry(token.clone())
193 .or_default()
194 .push(ContextPosting {
195 entity_id,
196 collection: collection.clone(),
197 field: field.clone(),
198 });
199 }
200 }
201 }
202
203 {
204 let mut index = self.field_values.write();
205 for (entity_id, keys) in &previous_keys {
206 let entity_id = EntityId::new(*entity_id);
207 for key in &keys.field_value_keys {
208 if let Some(postings) = index.get_mut(key) {
209 postings.retain(|posting| posting.entity_id != entity_id);
210 if postings.is_empty() {
211 index.remove(key);
212 }
213 }
214 }
215 }
216
217 for (entity_id, _, _, field_pairs) in &prepared {
218 let entity_id = EntityId::new(*entity_id);
219 for (field, value_token) in field_pairs {
220 index
221 .entry((field.clone(), value_token.clone()))
222 .or_default()
223 .push(ContextPosting {
224 entity_id,
225 collection: collection.clone(),
226 field: field.clone(),
227 });
228 }
229 }
230 }
231
232 {
233 let mut reverse = self.reverse.write();
234 for (entity_id, keys, _, _) in &prepared {
235 reverse.insert(*entity_id, keys.clone());
236 }
237 }
238
239 {
240 let mut indexed = self.indexed.write();
241 for (entity_id, _, _, _) in &prepared {
242 indexed.insert(*entity_id);
243 }
244 }
245 }
246
247 pub fn remove_entity(&self, entity_id: EntityId) {
249 let keys = {
250 let mut reverse = self.reverse.write();
251 reverse.remove(&entity_id.raw())
252 };
253
254 let Some(keys) = keys else {
255 return;
256 };
257
258 if !keys.token_keys.is_empty() {
259 let mut index = self.tokens.write();
260 for key in &keys.token_keys {
261 if let Some(postings) = index.get_mut(key) {
262 postings.retain(|p| p.entity_id != entity_id);
263 if postings.is_empty() {
264 index.remove(key);
265 }
266 }
267 }
268 }
269
270 if !keys.field_value_keys.is_empty() {
271 let mut index = self.field_values.write();
272 for key in &keys.field_value_keys {
273 if let Some(postings) = index.get_mut(key) {
274 postings.retain(|p| p.entity_id != entity_id);
275 if postings.is_empty() {
276 index.remove(key);
277 }
278 }
279 }
280 }
281
282 {
283 let mut indexed = self.indexed.write();
284 indexed.remove(&entity_id.raw());
285 }
286 }
287
288 pub fn search(
290 &self,
291 query: &str,
292 limit: usize,
293 allowed_collections: Option<&BTreeSet<String>>,
294 ) -> Vec<ContextSearchHit> {
295 let query_tokens = tokenize_query(query);
296 if query_tokens.is_empty() {
297 return Vec::new();
298 }
299
300 let index = self.tokens.read();
301
302 let mut scored: HashMap<u64, (String, usize)> = HashMap::new();
303
304 for token in &query_tokens {
305 if let Some(postings) = index.get(token) {
306 for posting in postings {
307 if allowed_collections
308 .as_ref()
309 .is_some_and(|allowed| !allowed.contains(&posting.collection))
310 {
311 continue;
312 }
313 let entry = scored
314 .entry(posting.entity_id.raw())
315 .or_insert_with(|| (posting.collection.clone(), 0));
316 entry.1 += 1;
317 }
318 }
319 }
320
321 let total_tokens = query_tokens.len();
322 let mut results: Vec<ContextSearchHit> = scored
323 .into_iter()
324 .map(|(entity_id, (collection, overlap))| ContextSearchHit {
325 entity_id: EntityId::new(entity_id),
326 collection,
327 score: (overlap as f32 / total_tokens.max(1) as f32).min(1.0),
328 matched_tokens: overlap,
329 total_tokens,
330 })
331 .collect();
332
333 results.sort_by(|a, b| {
334 b.score
335 .partial_cmp(&a.score)
336 .unwrap_or(std::cmp::Ordering::Equal)
337 .then_with(|| a.entity_id.raw().cmp(&b.entity_id.raw()))
338 });
339 results.truncate(limit.max(1));
340 results
341 }
342
343 pub fn search_bm25(
350 &self,
351 query: &str,
352 limit: usize,
353 allowed_collections: Option<&BTreeSet<String>>,
354 ) -> Vec<ContextSearchHit> {
355 let query_tokens = tokenize_query(query);
356 if query_tokens.is_empty() {
357 return Vec::new();
358 }
359
360 const K1: f32 = 1.2;
361 const B: f32 = 0.75;
362
363 let token_index = self.tokens.read();
364 let reverse = self.reverse.read();
365 if reverse.is_empty() {
366 return Vec::new();
367 }
368 let doc_count = reverse.len() as f32;
369 let avg_doc_len = (reverse
370 .values()
371 .map(|keys| keys.token_keys.len().max(1) as f32)
372 .sum::<f32>()
373 / doc_count)
374 .max(1.0);
375
376 let mut scored: HashMap<u64, (String, f32, usize)> = HashMap::new();
377
378 for token in &query_tokens {
379 let Some(postings) = token_index.get(token) else {
380 continue;
381 };
382 let filtered: Vec<&ContextPosting> = postings
383 .iter()
384 .filter(|posting| {
385 allowed_collections
386 .as_ref()
387 .is_none_or(|allowed| allowed.contains(&posting.collection))
388 })
389 .collect();
390 if filtered.is_empty() {
391 continue;
392 }
393
394 let df = filtered
395 .iter()
396 .map(|posting| posting.entity_id.raw())
397 .collect::<HashSet<_>>()
398 .len() as f32;
399 let idf = ((doc_count - df + 0.5) / (df + 0.5) + 1.0).ln();
400
401 for posting in filtered {
402 let doc_len = reverse
403 .get(&posting.entity_id.raw())
404 .map(|keys| keys.token_keys.len().max(1) as f32)
405 .unwrap_or(1.0);
406 let tf = 1.0;
407 let tf_component =
408 (tf * (K1 + 1.0)) / (tf + K1 * (1.0 - B + B * doc_len / avg_doc_len));
409 let entry = scored
410 .entry(posting.entity_id.raw())
411 .or_insert_with(|| (posting.collection.clone(), 0.0, 0));
412 entry.1 += idf * tf_component;
413 entry.2 += 1;
414 }
415 }
416
417 let total_tokens = query_tokens.len();
418 let mut results: Vec<ContextSearchHit> = scored
419 .into_iter()
420 .map(
421 |(entity_id, (collection, score, matched_tokens))| ContextSearchHit {
422 entity_id: EntityId::new(entity_id),
423 collection,
424 score,
425 matched_tokens,
426 total_tokens,
427 },
428 )
429 .collect();
430
431 results.sort_by(|a, b| {
432 b.score
433 .partial_cmp(&a.score)
434 .unwrap_or(std::cmp::Ordering::Equal)
435 .then_with(|| a.entity_id.raw().cmp(&b.entity_id.raw()))
436 });
437 results.truncate(limit.max(1));
438 results
439 }
440
441 pub fn search_field(
443 &self,
444 field: &str,
445 value: &str,
446 exact: bool,
447 limit: usize,
448 allowed_collections: Option<&BTreeSet<String>>,
449 ) -> Vec<ContextSearchHit> {
450 let field_tokens = tokenize_field_name(field);
451 let value_tokens = if exact {
452 tokenize_exact(value)
453 } else {
454 tokenize_query(value)
455 };
456
457 if field_tokens.is_empty() || value_tokens.is_empty() {
458 return Vec::new();
459 }
460
461 let index = self.field_values.read();
462
463 let mut scored: HashMap<u64, (String, usize)> = HashMap::new();
464 let mut total_pairs = 0usize;
465
466 for field_token in &field_tokens {
467 for value_token in &value_tokens {
468 total_pairs += 1;
469 if let Some(postings) = index.get(&(field_token.clone(), value_token.clone())) {
470 for posting in postings {
471 if allowed_collections
472 .as_ref()
473 .is_some_and(|allowed| !allowed.contains(&posting.collection))
474 {
475 continue;
476 }
477 let entry = scored
478 .entry(posting.entity_id.raw())
479 .or_insert_with(|| (posting.collection.clone(), 0));
480 entry.1 += 1;
481 }
482 }
483 }
484 }
485
486 let mut results: Vec<ContextSearchHit> = scored
487 .into_iter()
488 .map(|(entity_id, (collection, overlap))| ContextSearchHit {
489 entity_id: EntityId::new(entity_id),
490 collection,
491 score: (overlap as f32 / total_pairs.max(1) as f32).min(1.0),
492 matched_tokens: overlap,
493 total_tokens: total_pairs,
494 })
495 .collect();
496
497 results.sort_by(|a, b| {
498 b.score
499 .partial_cmp(&a.score)
500 .unwrap_or(std::cmp::Ordering::Equal)
501 .then_with(|| a.entity_id.raw().cmp(&b.entity_id.raw()))
502 });
503 results.truncate(limit.max(1));
504 results
505 }
506
507 pub fn stats(&self) -> ContextIndexStats {
509 let token_count = self.tokens.read().len();
510 let field_value_count = self.field_values.read().len();
511 let total_postings = self.tokens.read().values().map(|v| v.len()).sum();
512 let indexed_entities = self.indexed.read().len();
513
514 ContextIndexStats {
515 indexed_entities,
516 token_count,
517 field_value_count,
518 total_postings,
519 }
520 }
521}
522
523impl Default for ContextIndex {
524 fn default() -> Self {
525 Self::new()
526 }
527}
528
529pub fn tokenize_query(query: &str) -> Vec<String> {
535 let mut tokens = BTreeSet::new();
536 push_text_tokens(&mut tokens, query, true);
537 tokens.into_iter().take(MAX_INDEX_TOKENS).collect()
538}
539
540pub fn tokenize_exact(value: &str) -> Vec<String> {
542 let mut tokens = BTreeSet::new();
543 push_text_tokens(&mut tokens, value, false);
544 tokens.into_iter().take(MAX_INDEX_TOKENS).collect()
545}
546
547pub fn tokenize_field_name(field: &str) -> Vec<String> {
549 let mut tokens = BTreeSet::new();
550 push_text_tokens(&mut tokens, field, false);
551 tokens.into_iter().take(MAX_INDEX_TOKENS).collect()
552}
553
554pub fn entity_tokens_for_search(entity: &UnifiedEntity) -> Vec<String> {
557 let mut tokens: Vec<String> = extract_entity_tokens(entity)
558 .into_iter()
559 .map(|(token, _)| token)
560 .collect();
561 tokens.sort_unstable();
562 tokens.dedup();
563 tokens
564}
565
566fn extract_entity_tokens(entity: &UnifiedEntity) -> Vec<(String, String)> {
568 let mut token_fields: Vec<(String, String)> = Vec::new();
569 let mut tokens = BTreeSet::new();
570
571 let mut id_tokens = BTreeSet::new();
573 push_text_tokens(&mut id_tokens, &entity.id.raw().to_string(), false);
574 push_text_tokens(&mut id_tokens, &entity.id.to_string(), false);
575 push_text_tokens(&mut id_tokens, entity.kind.collection(), false);
576 push_text_tokens(&mut id_tokens, entity.kind.storage_type(), false);
577 for t in &id_tokens {
578 token_fields.push((t.clone(), "_id".to_string()));
579 }
580 tokens.extend(id_tokens);
581
582 match &entity.kind {
584 EntityKind::TableRow { row_id, .. } => {
585 let mut kind_tokens = BTreeSet::new();
586 push_text_tokens(&mut kind_tokens, &row_id.to_string(), false);
587 push_text_tokens(&mut kind_tokens, &format!("e{row_id}"), false);
588 for t in &kind_tokens {
589 if tokens.insert(t.clone()) {
590 token_fields.push((t.clone(), "_row_id".to_string()));
591 }
592 }
593 }
594 EntityKind::GraphNode(ref node) => {
595 let mut kind_tokens = BTreeSet::new();
596 push_text_tokens(&mut kind_tokens, &node.label, false);
597 push_text_tokens(&mut kind_tokens, &node.node_type, false);
598 for t in &kind_tokens {
599 if tokens.insert(t.clone()) {
600 token_fields.push((t.clone(), "_label".to_string()));
601 }
602 }
603 }
604 EntityKind::GraphEdge(ref edge) => {
605 let mut kind_tokens = BTreeSet::new();
606 push_text_tokens(&mut kind_tokens, &edge.label, false);
607 push_text_tokens(&mut kind_tokens, &edge.from_node, false);
608 push_text_tokens(&mut kind_tokens, &edge.to_node, false);
609 for t in &kind_tokens {
610 if tokens.insert(t.clone()) {
611 token_fields.push((t.clone(), "_edge".to_string()));
612 }
613 }
614 }
615 EntityKind::Vector { collection } => {
616 let mut kind_tokens = BTreeSet::new();
617 push_text_tokens(&mut kind_tokens, collection, false);
618 for t in &kind_tokens {
619 if tokens.insert(t.clone()) {
620 token_fields.push((t.clone(), "red_collection".to_string()));
621 }
622 }
623 }
624 EntityKind::TimeSeriesPoint(_) | EntityKind::QueueMessage { .. } => {}
625 }
626
627 match &entity.data {
629 EntityData::Row(row) => {
630 if let Some(named) = row.named.as_ref() {
631 for (key, value) in named {
632 let mut field_tokens = BTreeSet::new();
633 push_text_tokens(&mut field_tokens, key, false);
634 push_value_tokens(&mut field_tokens, value);
635 for t in &field_tokens {
636 if tokens.insert(t.clone()) {
637 token_fields.push((t.clone(), key.clone()));
638 }
639 }
640 }
641 } else {
642 for (i, value) in row.columns.iter().enumerate() {
643 let field_name = format!("_col{i}");
644 let mut field_tokens = BTreeSet::new();
645 push_value_tokens(&mut field_tokens, value);
646 for t in &field_tokens {
647 if tokens.insert(t.clone()) {
648 token_fields.push((t.clone(), field_name.clone()));
649 }
650 }
651 }
652 }
653 }
654 EntityData::Node(node) => {
655 for (key, value) in &node.properties {
656 let mut field_tokens = BTreeSet::new();
657 push_text_tokens(&mut field_tokens, key, false);
658 push_value_tokens(&mut field_tokens, value);
659 for t in &field_tokens {
660 if tokens.insert(t.clone()) {
661 token_fields.push((t.clone(), key.clone()));
662 }
663 }
664 }
665 }
666 EntityData::Edge(edge) => {
667 let mut field_tokens = BTreeSet::new();
668 push_text_tokens(&mut field_tokens, &edge.weight.to_string(), false);
669 for t in &field_tokens {
670 if tokens.insert(t.clone()) {
671 token_fields.push((t.clone(), "_weight".to_string()));
672 }
673 }
674 for (key, value) in &edge.properties {
675 let mut field_tokens = BTreeSet::new();
676 push_text_tokens(&mut field_tokens, key, false);
677 push_value_tokens(&mut field_tokens, value);
678 for t in &field_tokens {
679 if tokens.insert(t.clone()) {
680 token_fields.push((t.clone(), key.clone()));
681 }
682 }
683 }
684 }
685 EntityData::Vector(vector) => {
686 if let Some(content) = vector.content.as_ref() {
687 let mut field_tokens = BTreeSet::new();
688 push_text_tokens(&mut field_tokens, content, true);
689 for t in &field_tokens {
690 if tokens.insert(t.clone()) {
691 token_fields.push((t.clone(), "content".to_string()));
692 }
693 }
694 }
695 }
696 EntityData::TimeSeries(_) | EntityData::QueueMessage(_) => {}
697 }
698
699 for xref in entity.cross_refs() {
701 let mut xref_tokens = BTreeSet::new();
702 push_text_tokens(&mut xref_tokens, &xref.target.raw().to_string(), false);
703 push_text_tokens(&mut xref_tokens, &xref.target.to_string(), false);
704 push_text_tokens(&mut xref_tokens, &xref.target_collection, false);
705 push_text_tokens(&mut xref_tokens, &format!("{:?}", xref.ref_type), false);
706 for t in &xref_tokens {
707 if tokens.insert(t.clone()) {
708 token_fields.push((t.clone(), "_xref".to_string()));
709 }
710 }
711 }
712
713 token_fields.into_iter().take(MAX_INDEX_TOKENS).collect()
714}
715
716fn extract_field_lookup_pairs(entity: &UnifiedEntity) -> Vec<(String, String)> {
718 let mut pairs = BTreeSet::new();
719
720 fn push_field_value_pairs(pairs: &mut BTreeSet<(String, String)>, field: &str, value: &Value) {
721 if pairs.len() >= MAX_FIELD_INDEX_PAIRS {
722 return;
723 }
724 let mut field_tokens = BTreeSet::new();
725 push_text_tokens(&mut field_tokens, field, false);
726
727 let mut value_tokens = BTreeSet::new();
728 push_value_tokens(&mut value_tokens, value);
729
730 for field_token in &field_tokens {
731 for value_token in &value_tokens {
732 if field_token.is_empty() || value_token.is_empty() {
733 continue;
734 }
735 pairs.insert((field_token.clone(), value_token.clone()));
736 if pairs.len() >= MAX_FIELD_INDEX_PAIRS {
737 return;
738 }
739 }
740 }
741 }
742
743 match &entity.data {
744 EntityData::Row(row) => {
745 if let Some(named) = row.named.as_ref() {
746 for (field, value) in named {
747 push_field_value_pairs(&mut pairs, field, value);
748 if pairs.len() >= MAX_FIELD_INDEX_PAIRS {
749 break;
750 }
751 }
752 }
753 }
754 EntityData::Node(node) => {
755 for (field, value) in &node.properties {
756 push_field_value_pairs(&mut pairs, field, value);
757 if pairs.len() >= MAX_FIELD_INDEX_PAIRS {
758 break;
759 }
760 }
761 }
762 EntityData::Edge(edge) => {
763 for (field, value) in &edge.properties {
764 push_field_value_pairs(&mut pairs, field, value);
765 if pairs.len() >= MAX_FIELD_INDEX_PAIRS {
766 break;
767 }
768 }
769 }
770 EntityData::Vector(vector) => {
771 if let Some(content) = vector.content.as_ref() {
772 let mut value_tokens = BTreeSet::new();
773 push_text_tokens(&mut value_tokens, content, true);
774 for token in value_tokens {
775 pairs.insert(("content".to_string(), token));
776 if pairs.len() >= MAX_FIELD_INDEX_PAIRS {
777 break;
778 }
779 }
780 }
781 }
782 EntityData::TimeSeries(_) | EntityData::QueueMessage(_) => {}
783 }
784
785 pairs.into_iter().collect()
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use crate::storage::entity::{EntityData, EntityKind, RowData};
792 use std::collections::HashMap;
793
794 fn make_row_entity(id: u64, table: &str, fields: Vec<(&str, Value)>) -> UnifiedEntity {
795 let named: HashMap<String, Value> = fields
796 .into_iter()
797 .map(|(k, v)| (k.to_string(), v))
798 .collect();
799 UnifiedEntity::new(
800 EntityId::new(id),
801 EntityKind::TableRow {
802 table: std::sync::Arc::from(table),
803 row_id: id,
804 },
805 EntityData::Row(RowData {
806 columns: Vec::new(),
807 named: Some(named),
808 schema: None,
809 }),
810 )
811 }
812
813 #[test]
814 fn test_index_and_search() {
815 let index = ContextIndex::new();
816 index.set_collection_enabled("customers", true);
817 let entity = make_row_entity(
818 1,
819 "customers",
820 vec![
821 ("name", Value::text("Alice".to_string())),
822 ("cpf", Value::text("000.000.000-00".to_string())),
823 ],
824 );
825 index.index_entity("customers", &entity);
826
827 let results = index.search("000.000.000-00", 10, None);
828 assert!(!results.is_empty());
829 assert_eq!(results[0].entity_id, EntityId::new(1));
830 assert_eq!(results[0].collection, "customers");
831 }
832
833 #[test]
834 fn test_field_search() {
835 let index = ContextIndex::new();
836 index.set_collection_enabled("customers", true);
837 let entity = make_row_entity(
838 42,
839 "customers",
840 vec![("cpf", Value::text("000.000.000-00".to_string()))],
841 );
842 index.index_entity("customers", &entity);
843
844 let results = index.search_field("cpf", "000.000.000-00", true, 10, None);
845 assert!(!results.is_empty());
846 assert_eq!(results[0].entity_id, EntityId::new(42));
847 }
848
849 #[test]
850 fn test_remove_entity() {
851 let index = ContextIndex::new();
852 index.set_collection_enabled("test", true);
853 let entity = make_row_entity(1, "test", vec![("key", Value::text("value".to_string()))]);
854 index.index_entity("test", &entity);
855
856 assert!(!index.search("value", 10, None).is_empty());
857
858 index.remove_entity(EntityId::new(1));
859 assert!(index.search("value", 10, None).is_empty());
860 }
861
862 #[test]
863 fn test_collection_filtering() {
864 let index = ContextIndex::new();
865 index.set_collection_enabled("col_a", true);
866 index.set_collection_enabled("col_b", true);
867 let e1 = make_row_entity(1, "col_a", vec![("name", Value::text("Alice".to_string()))]);
868 let e2 = make_row_entity(2, "col_b", vec![("name", Value::text("Alice".to_string()))]);
869 index.index_entity("col_a", &e1);
870 index.index_entity("col_b", &e2);
871
872 let all = index.search("alice", 10, None);
873 assert_eq!(all.len(), 2);
874
875 let allowed: BTreeSet<String> = ["col_a".to_string()].into();
876 let filtered = index.search("alice", 10, Some(&allowed));
877 assert_eq!(filtered.len(), 1);
878 assert_eq!(filtered[0].collection, "col_a");
879 }
880
881 #[test]
882 fn test_bm25_search_ranks_more_specific_document_first() {
883 let index = ContextIndex::new();
884 index.set_collection_enabled("docs", true);
885 let specific = make_row_entity(
886 1,
887 "docs",
888 vec![("body", Value::text("passport renewal".to_string()))],
889 );
890 let broad = make_row_entity(
891 2,
892 "docs",
893 vec![(
894 "body",
895 Value::text(
896 "passport renewal travel hotel airline visa luggage itinerary".to_string(),
897 ),
898 )],
899 );
900 index.index_entity("docs", &specific);
901 index.index_entity("docs", &broad);
902
903 let results = index.search_bm25("passport renewal", 10, None);
904
905 assert_eq!(results.len(), 2);
906 assert_eq!(results[0].entity_id, EntityId::new(1));
907 assert!(
908 results[0].score > results[1].score,
909 "shorter exact document should outrank broader match: {results:?}"
910 );
911 }
912
913 #[test]
914 fn test_bm25_search_respects_collection_filter() {
915 let index = ContextIndex::new();
916 index.set_collection_enabled("visible", true);
917 index.set_collection_enabled("hidden", true);
918 let visible = make_row_entity(1, "visible", vec![("body", Value::text("alice"))]);
919 let hidden = make_row_entity(2, "hidden", vec![("body", Value::text("alice"))]);
920 index.index_entity("visible", &visible);
921 index.index_entity("hidden", &hidden);
922
923 let allowed: BTreeSet<String> = ["visible".to_string()].into();
924 let filtered = index.search_bm25("alice", 10, Some(&allowed));
925
926 assert_eq!(filtered.len(), 1);
927 assert_eq!(filtered[0].collection, "visible");
928 }
929
930 #[test]
931 fn test_stats() {
932 let index = ContextIndex::new();
933 index.set_collection_enabled("test", true);
934 let entity = make_row_entity(1, "test", vec![("k", Value::text("v".to_string()))]);
935 index.index_entity("test", &entity);
936
937 let stats = index.stats();
938 assert_eq!(stats.indexed_entities, 1);
939 assert!(stats.token_count > 0);
940 }
941}