Skip to main content

reddb_engine/storage/
context_index.rs

1//! Context Index — dedicated cross-structure inverted index for context search.
2//!
3//! Replaces the `_mm_index.*` / `_mm_field_index.*` metadata hack with a proper
4//! inverted index structure that maps tokens and field:value pairs to posting lists.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌──────────────────────────────────────────────────────────────────────┐
10//! │                          ContextIndex                                │
11//! ├──────────────────────────────────────────────────────────────────────┤
12//! │  ┌──────────────────────────────┐  ┌──────────────────────────────┐  │
13//! │  │  Token Index                 │  │  Field-Value Index           │  │
14//! │  │  BTreeMap<token, postings>   │  │  BTreeMap<(f,v), postings>  │  │
15//! │  │                              │  │                              │  │
16//! │  │  "00000000000" → [e42, e99]  │  │  ("cpf","081...") → [e42]   │  │
17//! │  │  "alice" → [e42, e55]       │  │  ("name","alice") → [e55]   │  │
18//! │  └──────────────────────────────┘  └──────────────────────────────┘  │
19//! └──────────────────────────────────────────────────────────────────────┘
20//! ```
21
22use 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// ============================================================================
33// Types
34// ============================================================================
35
36/// A posting entry pointing to a specific entity in a collection.
37#[derive(Debug, Clone)]
38pub struct ContextPosting {
39    pub entity_id: EntityId,
40    pub collection: String,
41    pub field: String,
42}
43
44/// A search hit from the context index.
45#[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/// Statistics about the context index.
55#[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/// Keys tracked per entity for O(k) removal.
64#[derive(Debug, Clone, Default)]
65struct EntityKeys {
66    token_keys: Vec<String>,
67    field_value_keys: Vec<(String, String)>,
68}
69
70// ============================================================================
71// ContextIndex
72// ============================================================================
73
74/// Dedicated cross-structure inverted index for context search.
75///
76/// Uses a reverse index (`entity_id → keys`) so that removal is O(k) where k
77/// is the number of tokens for that entity, instead of O(total_tokens).
78pub struct ContextIndex {
79    /// Token → posting list (all entities containing this token)
80    tokens: RwLock<BTreeMap<String, Vec<ContextPosting>>>,
81    /// (field, value_token) → posting list (field-specific lookups)
82    field_values: RwLock<BTreeMap<(String, String), Vec<ContextPosting>>>,
83    /// Reverse index: entity_id → keys it was indexed under (for fast removal)
84    reverse: RwLock<HashMap<u64, EntityKeys>>,
85    /// Set of currently indexed entity IDs (accurate count)
86    indexed: RwLock<HashSet<u64>>,
87    /// Collections opted in via `CREATE TABLE ... WITH context_index = true`.
88    /// Writes to collections absent from this set are no-ops — OLTP tables
89    /// skip tokenisation + 3-way RwLock write storm entirely.
90    enabled_collections: RwLock<HashSet<String>>,
91}
92
93impl ContextIndex {
94    /// Create a new empty context index.
95    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    /// Opt a collection in or out of context indexing. Driven by
106    /// `CREATE TABLE ... WITH context_index = true` at DDL time.
107    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    /// Whether writes to this collection should land in the index.
117    pub fn is_collection_enabled(&self, collection: &str) -> bool {
118        self.enabled_collections.read().contains(collection)
119    }
120
121    /// Index an entity — extracts tokens and field:value pairs into posting lists.
122    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    /// Batch variant of `index_entity` that amortizes lock traffic across
130    /// multiple rewrites.
131    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    /// Remove all postings for an entity. O(k) where k = entity's token count.
248    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    /// Search by tokens — tokenizes the query, looks up posting lists, and scores by overlap.
289    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    /// Search by tokens using BM25-style sparse ranking.
344    ///
345    /// The index stores one posting per unique entity token, so term
346    /// frequency is binary. IDF and document-length normalization
347    /// still give ASK/text retrieval a real sparse ranker instead of
348    /// the legacy overlap fraction.
349    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    /// Search by field:value — direct lookup in the field-value index.
442    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    /// Return index statistics.
508    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
529// ============================================================================
530// Tokenization — extracts tokens from entities for indexing
531// ============================================================================
532
533/// Tokenize a query string into normalized tokens for searching.
534pub 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
540/// Tokenize a value exactly (no word splitting).
541pub 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
547/// Tokenize a field name (no word splitting).
548pub 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
554/// Generate sorted tokens for search comparison (used in global scan fallback).
555/// Sorted for O(log n) binary_search lookups.
556pub 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
566/// Extract all indexable tokens from an entity, along with the field they came from.
567fn 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    // Entity identifiers
572    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    // Kind-specific tokens
583    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    // Data field tokens
628    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    // Cross-reference tokens
700    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
716/// Extract field:value pairs for the field-value index.
717fn 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}