Skip to main content

akar_processor/physical/write_ops/
ddl_fts.rs

1//! Auto-extracted from physical_operator.rs
2use crate::physical::types::{OperatorResult, PhysicalOperatorExec};
3use akar_common::types::{PhysicalTypeID, Value};
4use akar_common::vector::{DataChunk, ValueVector};
5use akar_storage::table::TableCatalog;
6use std::sync::Arc;
7
8// ==================== DDL & FTS ====================
9
10/// Physical COUNT on rel table — optimized via CSR metadata (Ladybug).
11/// Instead of scanning all edges, directly reads the edge count from the RelTable.
12pub struct PhysicalCountRelTable {
13    pub table_name: String,
14    pub table_id: u64,
15    pub table_catalog: Option<Arc<TableCatalog>>,
16}
17
18impl PhysicalOperatorExec for PhysicalCountRelTable {
19    fn operator_type(&self) -> &str {
20        "count_rel_table"
21    }
22
23    fn execute(&self, _input: Vec<DataChunk>) -> OperatorResult {
24        let tc = self
25            .table_catalog
26            .as_ref()
27            .ok_or_else(|| "No table catalog for CountRelTable".to_string())?;
28
29        let count = if let Some(table) = tc.get_rel_table(self.table_id) {
30            table.num_rows as i64
31        } else {
32            0
33        };
34
35        let mut v = ValueVector::new(PhysicalTypeID::Int64, 1);
36        v.resize(1);
37        v.set_i64(0, count);
38        let arr = akar_common::arrow_vector::ArrowVector::from_legacy(&v).array;
39        Ok(vec![DataChunk::new(vec![arr], vec![PhysicalTypeID::Int64])])
40    }
41}
42
43/// Physical operator for `CREATE FTS INDEX` — builds 3 macro tables:
44/// 1. `fts_{idx}_docs`: node table (doc_id INT64, text STRING)
45/// 2. `fts_{idx}_terms`: node table (term_id INT64, term STRING, doc_freq INT64)
46/// 3. `fts_{idx}_appears_in`: rel table (FROM terms TO docs, term_freq INT64)
47pub struct PhysicalCreateFtsIndex {
48    pub index_name: String,
49    pub table_name: String,
50    pub column_name: String,
51    pub docs_table: String,
52    pub terms_table: String,
53    pub posting_table: String,
54    pub table_catalog: Arc<TableCatalog>,
55}
56
57impl PhysicalOperatorExec for PhysicalCreateFtsIndex {
58    fn operator_type(&self) -> &str {
59        "create_fts_index"
60    }
61
62    fn execute(&self, _input: Vec<DataChunk>) -> OperatorResult {
63        // Locate the source table and snapshot its schema + data. The DashMap
64        // `Ref` MUST be dropped before the write locks below: creating/updating
65        // the macro tables (create_node_table / get_*_mut) takes exclusive
66        // shard locks, and DashMap is not re-entrant — holding the read `Ref`
67        // while acquiring a write lock on the same shard self-deadlocks on this
68        // thread. Because DashMap's hasher is random-seeded per catalog, the
69        // shard collision is intermittent (the FTS test flake) (P53.x).
70        let (col_idx, num_rows, source_data) = {
71            let source_table = match self.table_catalog.get_node_table_by_name(&self.table_name) {
72                Some(t) => t,
73                None => return Err(format!("Table '{}' not found", self.table_name).into()),
74            };
75            let col_idx = source_table
76                .columns
77                .iter()
78                .position(|c| c.name == self.column_name)
79                .ok_or_else(|| format!("Column '{}' not found in '{}'", self.column_name, self.table_name))?;
80            (
81                col_idx,
82                source_table.num_rows as usize,
83                source_table.to_column_major_data(),
84            )
85        };
86
87        // Ensure macro tables exist; create if needed
88        if self.table_catalog.get_node_table_by_name(&self.docs_table).is_none() {
89            let docs_cols = vec![
90                akar_storage::table::ColumnDefinition {
91                    name: "doc_id".into(),
92                    logical_type: akar_common::types::LogicalTypeID::Int64,
93                    is_primary_key: true,
94                    compression: akar_common::enums::CompressionType::Uncompressed,
95                },
96                akar_storage::table::ColumnDefinition {
97                    name: "text".into(),
98                    logical_type: akar_common::types::LogicalTypeID::String,
99                    is_primary_key: false,
100                    compression: akar_common::enums::CompressionType::Uncompressed,
101                },
102            ];
103            self.table_catalog.create_node_table(self.docs_table.clone(), docs_cols);
104        }
105        if self.table_catalog.get_node_table_by_name(&self.terms_table).is_none() {
106            let terms_cols = vec![
107                akar_storage::table::ColumnDefinition {
108                    name: "term_id".into(),
109                    logical_type: akar_common::types::LogicalTypeID::Int64,
110                    is_primary_key: true,
111                    compression: akar_common::enums::CompressionType::Uncompressed,
112                },
113                akar_storage::table::ColumnDefinition {
114                    name: "term".into(),
115                    logical_type: akar_common::types::LogicalTypeID::String,
116                    is_primary_key: false,
117                    compression: akar_common::enums::CompressionType::Uncompressed,
118                },
119                akar_storage::table::ColumnDefinition {
120                    name: "doc_freq".into(),
121                    logical_type: akar_common::types::LogicalTypeID::Int64,
122                    is_primary_key: false,
123                    compression: akar_common::enums::CompressionType::Uncompressed,
124                },
125            ];
126            self.table_catalog
127                .create_node_table(self.terms_table.clone(), terms_cols);
128        }
129
130        // term -> (term_id, doc_freq)
131        let mut term_map: std::collections::HashMap<String, (i64, i64)> = std::collections::HashMap::new();
132        // (doc_id, text) rows
133        let mut doc_rows: Vec<Vec<Value>> = Vec::new();
134        // posting: (term_id, doc_id, term_freq)
135        let mut postings: Vec<(i64, i64, i64)> = Vec::new();
136
137        for row_idx in 0..num_rows {
138            let text = if let Some(col_data) = source_data.get(col_idx) {
139                if let Some(Value::String(s)) = col_data.get(row_idx) {
140                    s.clone()
141                } else {
142                    String::new()
143                }
144            } else {
145                String::new()
146            };
147
148            let doc_id = row_idx as i64;
149            doc_rows.push(vec![Value::Int64(doc_id), Value::String(text.clone())]);
150
151            // Tokenize using Akar-fts utilities
152            let tokens = akar_fts::tokenize(&text);
153            let mut freq_map: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
154            for token in tokens {
155                let stemmed = akar_fts::stem_word(&token);
156                if !akar_fts::STOP_WORDS.contains(&stemmed.as_str()) {
157                    *freq_map.entry(stemmed).or_insert(0) += 1;
158                }
159            }
160
161            for (term, freq) in freq_map {
162                let next_id = term_map.len() as i64;
163                let (term_id, doc_freq) = term_map.entry(term).or_insert((next_id, 0));
164                *doc_freq += 1;
165                postings.push((*term_id, doc_id, freq));
166            }
167        }
168
169        // Insert docs
170        {
171            let mut docs_table = self.table_catalog.get_node_table_by_name_mut(&self.docs_table).unwrap();
172            for row in doc_rows {
173                docs_table.insert_row(row)?;
174            }
175        }
176
177        // Insert terms
178        if self.table_catalog.get_node_table_by_name(&self.terms_table).is_some() {
179            let mut terms_table = self
180                .table_catalog
181                .get_node_table_by_name_mut(&self.terms_table)
182                .unwrap();
183            let mut term_list: Vec<(String, i64, i64)> =
184                term_map.into_iter().map(|(t, (id, df))| (t, id, df)).collect();
185            term_list.sort_by_key(|(_, id, _)| *id);
186            for (term, term_id, doc_freq) in term_list {
187                terms_table.insert_row(vec![Value::Int64(term_id), Value::String(term), Value::Int64(doc_freq)])?;
188            }
189        }
190
191        // Create and populate posting (appears_in) table
192        let docs_table_id = self
193            .table_catalog
194            .get_node_table_by_name(&self.docs_table)
195            .unwrap()
196            .table_id;
197        let terms_table_id = self
198            .table_catalog
199            .get_node_table_by_name(&self.terms_table)
200            .unwrap()
201            .table_id;
202
203        if self.table_catalog.get_rel_table_by_name(&self.posting_table).is_none() {
204            let posting_cols = vec![akar_storage::table::ColumnDefinition {
205                name: "term_freq".into(),
206                logical_type: akar_common::types::LogicalTypeID::Int64,
207                is_primary_key: false,
208                compression: akar_common::enums::CompressionType::Uncompressed,
209            }];
210            // FROM terms TO docs
211            self.table_catalog.create_rel_table(
212                self.posting_table.clone(),
213                terms_table_id,
214                docs_table_id,
215                posting_cols,
216            );
217        }
218
219        {
220            let mut posting_table = self
221                .table_catalog
222                .get_rel_table_by_name_mut(&self.posting_table)
223                .unwrap();
224            for (term_id, doc_id, freq) in postings {
225                posting_table.insert_rel(term_id as u64, doc_id as u64, vec![Value::Int64(freq)])?;
226            }
227        }
228
229        let mut result_vec = akar_common::vector::ValueVector::new(akar_common::types::PhysicalTypeID::String, 1);
230        result_vec.resize(1);
231        result_vec
232            .set_value(
233                0,
234                &Value::String(format!("FTS index '{}' built successfully.", self.index_name)),
235            )
236            .unwrap();
237        let arr = akar_common::arrow_vector::ArrowVector::from_legacy(&result_vec).array;
238        let mut result = DataChunk::new(vec![arr], vec![akar_common::types::PhysicalTypeID::String]);
239        result.size = 1;
240        result.field_names = vec!["result".to_string()];
241        Ok(vec![result])
242    }
243}
244
245/// Physical operator for `USING FTS INDEX` scan — queries the 3 macro tables
246/// and returns ranked (node_id, score) pairs using BM25 scoring.
247#[derive(Debug, Clone)]
248pub struct PhysicalFtsScan {
249    pub index_name: String,
250    pub query_string: String,
251    pub docs_table: String,
252    pub terms_table: String,
253    pub posting_table: String,
254    /// Source node table/column the index was created on (P52.39) — used to
255    /// catch up newly inserted rows and filter deleted ones at query time.
256    pub table_name: String,
257    pub column_name: String,
258    pub table_catalog: Arc<TableCatalog>,
259}
260
261impl PhysicalOperatorExec for PhysicalFtsScan {
262    fn operator_type(&self) -> &str {
263        "fts_scan"
264    }
265
266    fn execute(&self, _input: Vec<DataChunk>) -> OperatorResult {
267        // Keep the derived index in sync with the source table first: any
268        // rows inserted after CREATE FTS INDEX must be searchable (P52.39).
269        self.sync_index_with_source()?;
270
271        // Tokenize query
272        let query_tokens: Vec<String> = akar_fts::tokenize(&self.query_string)
273            .into_iter()
274            .map(|t| akar_fts::stem_word(&t))
275            .filter(|t| !akar_fts::STOP_WORDS.contains(&t.as_str()))
276            .collect();
277
278        // Lookup terms table for matching terms
279        let terms_table = match self.table_catalog.get_node_table_by_name(&self.terms_table) {
280            Some(t) => t,
281            None => {
282                return Err(format!(
283                    "FTS terms table '{}' not found. Has the index been created?",
284                    self.terms_table
285                )
286                .into());
287            }
288        };
289
290        // Get total doc count from docs table
291        let num_docs = self
292            .table_catalog
293            .get_node_table_by_name(&self.docs_table)
294            .map(|t| t.num_rows as f64)
295            .unwrap_or(1.0);
296
297        // Build map: term -> (term_id, doc_freq). One pass over the vocabulary,
298        // then O(1) lookups per query token — the old code scanned the whole
299        // terms table per token (O(vocab x tokens), P52.39).
300        let terms_data = terms_table.to_column_major_data();
301        let num_terms = terms_table.num_rows as usize;
302        let mut term_index: std::collections::HashMap<String, (i64, i64)> = std::collections::HashMap::new();
303        for row_idx in 0..num_terms {
304            let term_id = match terms_data.first().and_then(|d| d.get(row_idx)) {
305                Some(Value::Int64(id)) => *id,
306                _ => continue,
307            };
308            let term_str = match terms_data.get(1).and_then(|d| d.get(row_idx)) {
309                Some(Value::String(s)) => s.clone(),
310                _ => continue,
311            };
312            let doc_freq = match terms_data.get(2).and_then(|d| d.get(row_idx)) {
313                Some(Value::Int64(df)) => *df,
314                _ => 0,
315            };
316            term_index.insert(term_str, (term_id, doc_freq));
317        }
318        drop(terms_data);
319        drop(terms_table);
320
321        let mut matching_terms: Vec<(i64, i64)> = Vec::new(); // (term_id, doc_freq)
322        for token in &query_tokens {
323            if let Some(&(term_id, doc_freq)) = term_index.get(token.as_str()) {
324                matching_terms.push((term_id, doc_freq));
325            }
326        }
327
328        // Doc validity: a doc is searchable only while its source row still
329        // exists and its text column is non-NULL (soft-deleted rows are
330        // filtered out, P52.39).
331        let source_table = self.table_catalog.get_node_table_by_name(&self.table_name);
332        let source_col = source_table
333            .as_ref()
334            .and_then(|t| t.columns.iter().position(|c| c.name == self.column_name));
335        let doc_valid = |doc_id: i64| -> bool {
336            let Ok(r) = usize::try_from(doc_id) else {
337                return false;
338            };
339            match (&source_table, source_col) {
340                (Some(t), Some(ci)) => r < t.num_rows as usize && matches!(t.get_value(r, ci), Some(Value::String(_))),
341                _ => true, // no source info → keep everything
342            }
343        };
344
345        // Accumulate per-doc BM25 scores from posting table
346        let mut doc_scores: std::collections::HashMap<i64, f64> = std::collections::HashMap::new();
347
348        if let Some(posting_table) = self.table_catalog.get_rel_table_by_name(&self.posting_table) {
349            for &(term_id, doc_freq) in &matching_terms {
350                let idf = ((num_docs - doc_freq as f64 + 0.5) / (doc_freq as f64 + 0.5) + 1.0).ln();
351                // Scan posting table for this term using get_outgoing_edges(term_id)
352                let posting_rels = posting_table.get_outgoing_edges(term_id as u64);
353                for (doc_id, rel_vals) in posting_rels {
354                    if !doc_valid(doc_id as i64) {
355                        continue;
356                    }
357                    let tf = if let Some(Value::Int64(freq)) = rel_vals.first() {
358                        *freq as f64
359                    } else {
360                        1.0
361                    };
362                    // BM25: k1=1.5, b=0.75 (simplified, no avg doc len)
363                    let k1 = 1.5_f64;
364                    let score = idf * (tf * (k1 + 1.0)) / (tf + k1);
365                    *doc_scores.entry(doc_id as i64).or_insert(0.0) += score;
366                }
367            }
368        }
369
370        // Sort by score descending
371        let mut ranked: Vec<(i64, f64)> = doc_scores.into_iter().collect();
372        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
373
374        // Return (doc_id, score) data chunks
375        let n = ranked.len();
376        let mut id_vec = akar_common::vector::ValueVector::new(akar_common::types::PhysicalTypeID::Int64, n);
377        let mut score_vec = akar_common::vector::ValueVector::new(akar_common::types::PhysicalTypeID::Double, n);
378        id_vec.resize(n);
379        score_vec.resize(n);
380        for (i, (doc_id, score)) in ranked.into_iter().enumerate() {
381            id_vec.set_i64(i, doc_id);
382            score_vec.set_double(i, score);
383        }
384        let arr1 = akar_common::arrow_vector::ArrowVector::from_legacy(&id_vec).array;
385        let arr2 = akar_common::arrow_vector::ArrowVector::from_legacy(&score_vec).array;
386        let mut chunk = DataChunk::new(
387            vec![arr1, arr2],
388            vec![
389                akar_common::types::PhysicalTypeID::Int64,
390                akar_common::types::PhysicalTypeID::Double,
391            ],
392        );
393        chunk.size = n;
394        chunk.field_names = vec!["doc_id".to_string(), "score".to_string()];
395        Ok(vec![chunk])
396    }
397}
398
399impl PhysicalFtsScan {
400    /// Incrementally bring the derived FTS macro tables (docs/terms/postings)
401    /// in line with the source node table (P52.39).
402    ///
403    /// Rows appended to the source after `CREATE FTS INDEX` are tokenized and
404    /// added; existing terms get their `doc_freq` bumped. Rows that were
405    /// soft-deleted in the source are simply not re-inserted, and the scoring
406    /// pass filters them by source state, so no posting cleanup is required.
407    fn sync_index_with_source(&self) -> Result<(), String> {
408        let Some(source_table) = self.table_catalog.get_node_table_by_name(&self.table_name) else {
409            return Ok(());
410        };
411        let Some(col_idx) = source_table.columns.iter().position(|c| c.name == self.column_name) else {
412            return Ok(());
413        };
414        let source_count = source_table.num_rows as usize;
415
416        let already_indexed = match self.table_catalog.get_node_table_by_name(&self.docs_table) {
417            Some(docs) => docs.num_rows as usize,
418            None => return Ok(()),
419        };
420
421        if already_indexed >= source_count {
422            return Ok(());
423        }
424
425        // Collect the text of source rows not yet indexed.
426        let mut new_docs: Vec<(usize, String)> = Vec::new();
427        for row_id in already_indexed..source_count {
428            if let Some(Value::String(s)) = source_table.get_value(row_id, col_idx) {
429                new_docs.push((row_id, s.clone()));
430            }
431        }
432        drop(source_table);
433
434        if new_docs.is_empty() {
435            return Ok(());
436        }
437
438        // term -> (term_id, doc_freq, terms-table row) from the current terms.
439        let mut term_info: std::collections::HashMap<String, (i64, i64, usize)> = std::collections::HashMap::new();
440        let mut max_term_id: i64 = -1;
441        {
442            let terms = self
443                .table_catalog
444                .get_node_table_by_name(&self.terms_table)
445                .ok_or_else(|| format!("Terms table '{}' not found", self.terms_table))?;
446            let data = terms.to_column_major_data();
447            for row_idx in 0..terms.num_rows as usize {
448                let term_id = match data.first().and_then(|d| d.get(row_idx)) {
449                    Some(Value::Int64(id)) => *id,
450                    _ => continue,
451                };
452                let term = match data.get(1).and_then(|d| d.get(row_idx)) {
453                    Some(Value::String(s)) => s.clone(),
454                    _ => continue,
455                };
456                let df = match data.get(2).and_then(|d| d.get(row_idx)) {
457                    Some(Value::Int64(v)) => *v,
458                    _ => 0,
459                };
460                max_term_id = max_term_id.max(term_id);
461                term_info.insert(term, (term_id, df, row_idx));
462            }
463        }
464
465        let mut next_term_id = max_term_id + 1;
466        let mut new_postings: Vec<(i64, usize, i64)> = Vec::new();
467        let mut new_terms: Vec<(i64, String)> = Vec::new();
468        let mut df_updates: Vec<(usize, i64)> = Vec::new(); // (terms-table row, new doc_freq)
469
470        for (doc_id, text) in &new_docs {
471            let mut freq: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
472            for token in akar_fts::tokenize(text) {
473                let stemmed = akar_fts::stem_word(&token);
474                if !akar_fts::STOP_WORDS.contains(&stemmed.as_str()) {
475                    *freq.entry(stemmed).or_insert(0) += 1;
476                }
477            }
478            for (term, f) in freq {
479                if let Some((term_id, df, row_idx)) = term_info.get_mut(&term) {
480                    *df += 1;
481                    df_updates.push((*row_idx, *df));
482                    new_postings.push((*term_id, *doc_id, f));
483                } else {
484                    let tid = next_term_id;
485                    next_term_id += 1;
486                    term_info.insert(term.clone(), (tid, 1, usize::MAX));
487                    new_terms.push((tid, term));
488                    new_postings.push((tid, *doc_id, f));
489                }
490            }
491        }
492
493        // Apply writes to the macro tables.
494        {
495            let mut docs = self
496                .table_catalog
497                .get_node_table_by_name_mut(&self.docs_table)
498                .ok_or_else(|| format!("Docs table '{}' not found", self.docs_table))?;
499            for (doc_id, text) in &new_docs {
500                docs.insert_row(vec![Value::Int64(*doc_id as i64), Value::String(text.clone())])?;
501            }
502        }
503        {
504            let mut terms = self
505                .table_catalog
506                .get_node_table_by_name_mut(&self.terms_table)
507                .ok_or_else(|| format!("Terms table '{}' not found", self.terms_table))?;
508            for (term_id, term) in &new_terms {
509                terms.insert_row(vec![
510                    Value::Int64(*term_id),
511                    Value::String(term.clone()),
512                    Value::Int64(1),
513                ])?;
514            }
515            for (row_idx, df) in df_updates {
516                terms.update_cell(row_idx as u64, 2, Value::Int64(df))?;
517            }
518        }
519        {
520            let mut posting = self
521                .table_catalog
522                .get_rel_table_by_name_mut(&self.posting_table)
523                .ok_or_else(|| format!("Posting table '{}' not found", self.posting_table))?;
524            for (term_id, doc_id, f) in &new_postings {
525                posting.insert_rel(*term_id as u64, *doc_id as u64, vec![Value::Int64(*f)])?;
526            }
527        }
528        Ok(())
529    }
530}