1use arrow_array::{
4 Array, ArrayRef, FixedSizeListArray, Float32Array, RecordBatch, RecordBatchIterator,
5 RecordBatchReader, StringArray, UInt8Array, UInt16Array, UInt32Array, UInt64Array,
6};
7use arrow_schema::{DataType, Field, Schema};
8use async_trait::async_trait;
9use chrono::Utc;
10use futures::TryStreamExt;
11use lancedb::index::Index;
12use lancedb::index::scalar::{FtsIndexBuilder, FullTextSearchQuery};
13use lancedb::index::vector::IvfPqIndexBuilder;
14use lancedb::query::{ExecutableQuery, QueryBase, QueryExecutionOptions};
15use lancedb::table::{NewColumnTransform, OptimizeAction, OptimizeOptions};
16use lancedb::{Connection, DistanceType, Table, connect};
17use ragfs_core::{
18 Chunk, ChunkMetadata, ContentType, DirectoryScope, DistanceMetric, FileRecord, FileStatus,
19 SearchFilter, SearchQuery, SearchResult, StoreError, StoreStats, VectorStore,
20};
21use std::collections::{HashMap, HashSet};
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25use crate::schema::{
26 CHUNKS_SCHEMA_VERSION, SCHEMA_SIDECAR_FILENAME, SchemaSidecar, missing_scope_columns,
27};
28use tokio::sync::RwLock;
29use tracing::{debug, info, warn};
30use uuid::Uuid;
31
32const CHUNKS_TABLE: &str = "chunks";
33const FILES_TABLE: &str = "files";
34const MIN_ANN_ROWS: usize = 256;
36const ANN_INDEX_METRIC: DistanceMetric = DistanceMetric::Cosine;
38
39pub struct LanceStore {
41 db_path: PathBuf,
43 embedding_dim: usize,
45 embedding_model: Option<String>,
47 connection: RwLock<Option<Connection>>,
49 chunks_table: RwLock<Option<Table>>,
51 files_table: RwLock<Option<Table>>,
53}
54
55impl LanceStore {
56 #[must_use]
61 pub fn new(db_path: PathBuf, embedding_dim: usize) -> Self {
62 Self {
63 db_path,
64 embedding_dim,
65 embedding_model: None,
66 connection: RwLock::new(None),
67 chunks_table: RwLock::new(None),
68 files_table: RwLock::new(None),
69 }
70 }
71
72 #[must_use]
74 pub fn with_embedding_model(mut self, model: impl Into<String>) -> Self {
75 self.embedding_model = Some(model.into());
76 self
77 }
78
79 pub fn db_path(&self) -> &Path {
81 &self.db_path
82 }
83
84 pub fn embedding_dim(&self) -> usize {
86 self.embedding_dim
87 }
88
89 fn stored_embedding_dim(schema: &Schema) -> Result<usize, StoreError> {
91 let field = schema.field_with_name("vector").map_err(|_| {
92 StoreError::Schema("chunks table is missing the 'vector' column".into())
93 })?;
94 match field.data_type() {
95 DataType::FixedSizeList(_, size) => Ok(*size as usize),
96 other => Err(StoreError::Schema(format!(
97 "chunks 'vector' column has type {other:?}, expected FixedSizeList"
98 ))),
99 }
100 }
101
102 fn ensure_embedding_dim_matches(&self, schema: &Schema) -> Result<(), StoreError> {
103 let stored = Self::stored_embedding_dim(schema)?;
104 if stored != self.embedding_dim {
105 return Err(StoreError::Schema(format!(
106 "Index embedding dimension {stored} does not match embedder dimension {}",
107 self.embedding_dim
108 )));
109 }
110 Ok(())
111 }
112
113 pub(crate) fn should_build_ann_index(row_count: usize) -> bool {
115 row_count >= MIN_ANN_ROWS
116 }
117
118 pub(crate) fn should_refresh_ann_index(unindexed_rows: usize) -> bool {
120 unindexed_rows >= MIN_ANN_ROWS
121 }
122
123 pub(crate) fn ann_index_covers_metric(metric: DistanceMetric) -> bool {
125 metric == ANN_INDEX_METRIC
126 }
127
128 pub(crate) fn ann_index_is_cosine(distance_type: Option<DistanceType>) -> bool {
130 distance_type == Some(DistanceType::Cosine)
131 }
132
133 async fn vector_index_name(table: &Table) -> Option<String> {
134 match table.list_indices().await {
135 Ok(indices) => indices
136 .into_iter()
137 .find(|idx| idx.columns.iter().any(|col| col == "vector"))
138 .map(|idx| idx.name),
139 Err(_) => None,
140 }
141 }
142
143 async fn refresh_vector_index(table: &Table, index_name: &str) {
144 let unindexed = match table.index_stats(index_name).await {
145 Ok(Some(stats)) => stats.num_unindexed_rows,
146 Ok(None) => return,
147 Err(e) => {
148 debug!("Skipping ANN refresh; could not read index stats: {e}");
149 return;
150 }
151 };
152
153 if !Self::should_refresh_ann_index(unindexed) {
154 debug!("Skipping ANN refresh: {unindexed} unindexed rows < {MIN_ANN_ROWS}");
155 return;
156 }
157
158 info!("Refreshing IVF-PQ ANN index ({unindexed} unindexed rows)");
159 match table
160 .optimize(OptimizeAction::Index(
161 OptimizeOptions::append().index_names(vec![index_name.to_string()]),
162 ))
163 .await
164 {
165 Ok(_) => info!("IVF-PQ ANN index refreshed"),
166 Err(e) => warn!("IVF-PQ ANN index refresh failed (unindexed tail stays exact): {e}"),
167 }
168 }
169
170 async fn ensure_vector_index(&self) -> Result<(), StoreError> {
174 let table = self.get_chunks_table().await?;
175 if let Some(name) = Self::vector_index_name(&table).await {
176 match table.index_stats(&name).await {
177 Ok(Some(stats)) if Self::ann_index_is_cosine(stats.distance_type) => {
178 Self::refresh_vector_index(&table, &name).await;
179 return Ok(());
180 }
181 Ok(Some(stats)) => {
182 info!(
183 "Replacing vector index trained as {:?} with cosine IVF-PQ",
184 stats.distance_type
185 );
186 if let Err(e) = table.drop_index(&name).await {
187 warn!("Could not drop mismatched vector index (search may bypass): {e}");
188 return Ok(());
189 }
190 }
191 Ok(None) => {}
192 Err(e) => {
193 debug!("Skipping ANN reuse; could not read index stats: {e}");
194 return Ok(());
195 }
196 }
197 }
198
199 let count = match table.count_rows(None).await {
200 Ok(n) => n,
201 Err(e) => {
202 debug!("Skipping ANN index; could not count rows: {e}");
203 return Ok(());
204 }
205 };
206
207 if !Self::should_build_ann_index(count) {
208 debug!("Skipping IVF-PQ ANN index: {count} rows < {MIN_ANN_ROWS} (exact scan)");
209 return Ok(());
210 }
211
212 info!("Creating IVF-PQ ANN index on vector ({count} rows, cosine)");
213 match table
214 .create_index(
215 &["vector"],
216 Index::IvfPq(IvfPqIndexBuilder::default().distance_type(DistanceType::Cosine)),
217 )
218 .execute()
219 .await
220 {
221 Ok(()) => info!("IVF-PQ ANN index ready"),
222 Err(e) => warn!("IVF-PQ ANN index not created (search stays exact scan): {e}"),
223 }
224 Ok(())
225 }
226
227 async fn get_connection(&self) -> Result<Connection, StoreError> {
229 {
230 let conn = self.connection.read().await;
231 if let Some(ref c) = *conn {
232 return Ok(c.clone());
233 }
234 }
235
236 let mut conn = self.connection.write().await;
237 if conn.is_none() {
238 let db_path_str = self.db_path.to_string_lossy().to_string();
239 let new_conn = connect(&db_path_str)
240 .execute()
241 .await
242 .map_err(|e| StoreError::Init(format!("Failed to connect to LanceDB: {e}")))?;
243 *conn = Some(new_conn);
244 }
245 Ok(conn.as_ref().unwrap().clone())
246 }
247
248 fn chunks_schema(&self) -> Schema {
250 Schema::new(vec![
251 Field::new("chunk_id", DataType::Utf8, false),
252 Field::new("file_id", DataType::Utf8, false),
253 Field::new("file_path", DataType::Utf8, false),
254 Field::new("content", DataType::Utf8, false),
255 Field::new("content_type", DataType::Utf8, false),
256 Field::new("chunk_index", DataType::UInt32, false),
257 Field::new("start_byte", DataType::UInt64, false),
258 Field::new("end_byte", DataType::UInt64, false),
259 Field::new("start_line", DataType::UInt32, true),
260 Field::new("end_line", DataType::UInt32, true),
261 Field::new("parent_chunk_id", DataType::Utf8, true),
262 Field::new("depth", DataType::UInt8, false),
263 Field::new(
264 "vector",
265 DataType::FixedSizeList(
266 Arc::new(Field::new("item", DataType::Float32, true)),
267 self.embedding_dim as i32,
268 ),
269 false,
270 ),
271 Field::new("embedding_model", DataType::Utf8, true),
272 Field::new("indexed_at", DataType::Utf8, false),
273 Field::new("file_mime_type", DataType::Utf8, true),
274 Field::new("language", DataType::Utf8, true),
275 Field::new("symbol_type", DataType::Utf8, true),
276 Field::new("symbol_name", DataType::Utf8, true),
277 Field::new("dir_path", DataType::Utf8, false),
278 Field::new("dir_depth", DataType::UInt16, false),
279 Field::new("path_components", DataType::Utf8, false),
280 ])
281 }
282
283 fn files_schema(&self) -> Schema {
285 Schema::new(vec![
286 Field::new("file_id", DataType::Utf8, false),
287 Field::new("path", DataType::Utf8, false),
288 Field::new("size_bytes", DataType::UInt64, false),
289 Field::new("mime_type", DataType::Utf8, false),
290 Field::new("content_hash", DataType::Utf8, false),
291 Field::new("modified_at", DataType::Utf8, false),
292 Field::new("indexed_at", DataType::Utf8, true),
293 Field::new("chunk_count", DataType::UInt32, false),
294 Field::new("status", DataType::Utf8, false),
295 Field::new("error_message", DataType::Utf8, true),
296 ])
297 }
298
299 async fn get_chunks_table(&self) -> Result<Table, StoreError> {
301 {
302 let table = self.chunks_table.read().await;
303 if let Some(ref t) = *table {
304 return Ok(t.clone());
305 }
306 }
307
308 let conn = self.get_connection().await?;
309 let mut table_lock = self.chunks_table.write().await;
310
311 if table_lock.is_none() {
312 let t = conn
313 .open_table(CHUNKS_TABLE)
314 .execute()
315 .await
316 .map_err(|e| StoreError::Init(format!("Failed to open chunks table: {e}")))?;
317 *table_lock = Some(t);
318 }
319
320 Ok(table_lock.as_ref().unwrap().clone())
321 }
322
323 async fn get_files_table(&self) -> Result<Table, StoreError> {
325 {
326 let table = self.files_table.read().await;
327 if let Some(ref t) = *table {
328 return Ok(t.clone());
329 }
330 }
331
332 let conn = self.get_connection().await?;
333 let mut table_lock = self.files_table.write().await;
334
335 if table_lock.is_none() {
336 let t = conn
337 .open_table(FILES_TABLE)
338 .execute()
339 .await
340 .map_err(|e| StoreError::Init(format!("Failed to open files table: {e}")))?;
341 *table_lock = Some(t);
342 }
343
344 Ok(table_lock.as_ref().unwrap().clone())
345 }
346
347 fn schema_sidecar_path(&self) -> PathBuf {
348 self.db_path
349 .parent()
350 .unwrap_or(self.db_path.as_path())
351 .join(SCHEMA_SIDECAR_FILENAME)
352 }
353
354 async fn read_schema_sidecar(&self) -> Option<u32> {
355 let bytes = tokio::fs::read(self.schema_sidecar_path()).await.ok()?;
356 serde_json::from_slice::<SchemaSidecar>(&bytes)
357 .ok()
358 .map(|s| s.chunks_schema_version)
359 }
360
361 async fn write_schema_sidecar(&self, version: u32) -> Result<(), StoreError> {
362 let path = self.schema_sidecar_path();
363 if let Some(parent) = path.parent() {
364 tokio::fs::create_dir_all(parent).await.map_err(|e| {
365 StoreError::Schema(format!(
366 "Failed to create schema sidecar directory {}: {e}",
367 parent.display()
368 ))
369 })?;
370 }
371 let json = serde_json::to_string_pretty(&SchemaSidecar {
372 chunks_schema_version: version,
373 embedding_dim: Some(self.embedding_dim),
374 embedding_model: self.embedding_model.clone(),
375 })
376 .map_err(|e| StoreError::Schema(format!("Failed to serialize schema sidecar: {e}")))?;
377 tokio::fs::write(&path, json).await.map_err(|e| {
378 StoreError::Schema(format!(
379 "Failed to write schema sidecar {}: {e}",
380 path.display()
381 ))
382 })?;
383 Ok(())
384 }
385
386 fn schema_migration_failed_message(db_path: &Path, error: &impl std::fmt::Display) -> String {
387 let index_dir = db_path.parent().unwrap_or(db_path);
388 format!(
389 "Existing Lance index is missing directory-scope columns \
390 (dir_path, dir_depth, path_components) and automatic schema \
391 migration to v{CHUNKS_SCHEMA_VERSION} failed ({error}). \
392 The index was not deleted. Remove `{}` and run \
393 `ragfs index <dir> --force` to rebuild.",
394 index_dir.display()
395 )
396 }
397
398 async fn ensure_chunks_schema_v2(&self) -> Result<(), StoreError> {
403 let table = {
404 let conn = self.get_connection().await?;
405 conn.open_table(CHUNKS_TABLE)
406 .execute()
407 .await
408 .map_err(|e| StoreError::Init(format!("Failed to open chunks table: {e}")))?
409 };
410
411 let schema = table
412 .schema()
413 .await
414 .map_err(|e| StoreError::Schema(format!("Failed to read chunks schema: {e}")))?;
415 self.ensure_embedding_dim_matches(&schema)?;
416 let missing = missing_scope_columns(&schema);
417 let sidecar_version = self.read_schema_sidecar().await.unwrap_or(0);
418
419 if missing.is_empty() && sidecar_version >= CHUNKS_SCHEMA_VERSION {
420 return Ok(());
421 }
422
423 if !missing.is_empty() {
424 info!(
425 "Migrating chunks table to schema v{CHUNKS_SCHEMA_VERSION} \
426 (adding directory-scope columns)"
427 );
428 table
429 .add_columns()
430 .transform(NewColumnTransform::AllNulls(Arc::new(Schema::new(missing))))
431 .execute()
432 .await
433 .map_err(|e| {
434 StoreError::Schema(Self::schema_migration_failed_message(&self.db_path, &e))
435 })?;
436
437 *self.chunks_table.write().await = None;
439 }
440
441 self.backfill_scope_columns().await?;
442 self.write_schema_sidecar(CHUNKS_SCHEMA_VERSION).await?;
443 info!("Chunks table schema is v{CHUNKS_SCHEMA_VERSION}");
444 Ok(())
445 }
446
447 async fn backfill_scope_columns(&self) -> Result<(), StoreError> {
448 let table = self.get_chunks_table().await?;
449 let mut results = table
450 .query()
451 .only_if("dir_path IS NULL OR dir_path = ''")
452 .execute()
453 .await
454 .map_err(|e| {
455 StoreError::Schema(format!(
456 "Failed to scan chunks for directory-scope backfill: {e}"
457 ))
458 })?;
459
460 let mut paths = HashSet::new();
461 while let Some(batch) = results.try_next().await.map_err(|e| {
462 StoreError::Schema(format!(
463 "Failed to read chunks for directory-scope backfill: {e}"
464 ))
465 })? {
466 let Some(file_paths) = batch
467 .column_by_name("file_path")
468 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
469 else {
470 continue;
471 };
472 for i in 0..batch.num_rows() {
473 if !file_paths.is_null(i) {
474 paths.insert(file_paths.value(i).to_string());
475 }
476 }
477 }
478
479 if paths.is_empty() {
480 return Ok(());
481 }
482
483 info!(
484 "Backfilling directory-scope columns for {} file path(s)",
485 paths.len()
486 );
487 for path in paths {
488 let scope = DirectoryScope::from_file_path(Path::new(&path));
489 table
490 .update()
491 .only_if(format!("file_path = '{}'", escape_sql_literal(&path)))
492 .column(
493 "dir_path",
494 format!("'{}'", escape_sql_literal(&scope.dir_path)),
495 )
496 .column("dir_depth", scope.dir_depth.to_string())
497 .column(
498 "path_components",
499 format!("'{}'", escape_sql_literal(&scope.path_components)),
500 )
501 .execute()
502 .await
503 .map_err(|e| {
504 StoreError::Schema(format!(
505 "Failed to backfill directory-scope columns for '{path}': {e}"
506 ))
507 })?;
508 }
509 Ok(())
510 }
511
512 fn chunks_to_batch(&self, chunks: &[Chunk]) -> Result<RecordBatch, StoreError> {
514 let chunk_ids: Vec<_> = chunks.iter().map(|c| c.id.to_string()).collect();
515 let file_ids: Vec<_> = chunks.iter().map(|c| c.file_id.to_string()).collect();
516 let file_paths: Vec<_> = chunks
517 .iter()
518 .map(|c| c.file_path.to_string_lossy().to_string())
519 .collect();
520 let contents: Vec<_> = chunks.iter().map(|c| c.content.clone()).collect();
521 let content_types: Vec<_> = chunks
522 .iter()
523 .map(|c| content_type_to_string(&c.content_type))
524 .collect();
525 let chunk_indices: Vec<_> = chunks.iter().map(|c| c.chunk_index).collect();
526 let start_bytes: Vec<_> = chunks.iter().map(|c| c.byte_range.start).collect();
527 let end_bytes: Vec<_> = chunks.iter().map(|c| c.byte_range.end).collect();
528 let start_lines: Vec<_> = chunks
529 .iter()
530 .map(|c| c.line_range.as_ref().map(|r| r.start))
531 .collect();
532 let end_lines: Vec<_> = chunks
533 .iter()
534 .map(|c| c.line_range.as_ref().map(|r| r.end))
535 .collect();
536 let parent_ids: Vec<_> = chunks
537 .iter()
538 .map(|c| c.parent_chunk_id.map(|id| id.to_string()))
539 .collect();
540 let depths: Vec<_> = chunks.iter().map(|c| c.depth).collect();
541
542 let embeddings: Vec<Option<Vec<Option<f32>>>> = chunks
544 .iter()
545 .map(|c| {
546 c.embedding
547 .as_ref()
548 .map(|e| e.iter().map(|&v| Some(v)).collect())
549 })
550 .collect();
551
552 let embedding_models: Vec<_> = chunks
553 .iter()
554 .map(|c| c.metadata.embedding_model.clone())
555 .collect();
556 let indexed_ats: Vec<_> = chunks
557 .iter()
558 .map(|c| {
559 c.metadata
560 .indexed_at
561 .map_or_else(|| Utc::now().to_rfc3339(), |t| t.to_rfc3339())
562 })
563 .collect();
564
565 let languages: Vec<_> = chunks
567 .iter()
568 .map(|c| match &c.content_type {
569 ContentType::Code { language, .. } => Some(language.clone()),
570 _ => None,
571 })
572 .collect();
573 let symbol_types: Vec<_> = chunks
574 .iter()
575 .map(|c| match &c.content_type {
576 ContentType::Code { symbol, .. } => {
577 symbol.as_ref().map(|s| format!("{:?}", s.kind))
578 }
579 _ => None,
580 })
581 .collect();
582 let symbol_names: Vec<_> = chunks
583 .iter()
584 .map(|c| match &c.content_type {
585 ContentType::Code { symbol, .. } => symbol.as_ref().map(|s| s.name.clone()),
586 _ => None,
587 })
588 .collect();
589
590 let mime_types: Vec<Option<String>> = chunks.iter().map(|c| c.mime_type.clone()).collect();
591
592 let dir_paths: Vec<_> = chunks.iter().map(|c| c.dir_path.clone()).collect();
593 let dir_depths: Vec<_> = chunks.iter().map(|c| c.dir_depth).collect();
594 let path_components: Vec<_> = chunks.iter().map(|c| c.path_components.clone()).collect();
595
596 let schema = Arc::new(self.chunks_schema());
598
599 let vector_array = build_vector_array(&embeddings, self.embedding_dim)?;
600
601 let batch = RecordBatch::try_new(
602 schema,
603 vec![
604 Arc::new(StringArray::from(chunk_ids)),
605 Arc::new(StringArray::from(file_ids)),
606 Arc::new(StringArray::from(file_paths)),
607 Arc::new(StringArray::from(contents)),
608 Arc::new(StringArray::from(content_types)),
609 Arc::new(UInt32Array::from(chunk_indices)),
610 Arc::new(UInt64Array::from(start_bytes)),
611 Arc::new(UInt64Array::from(end_bytes)),
612 Arc::new(UInt32Array::from(start_lines)),
613 Arc::new(UInt32Array::from(end_lines)),
614 Arc::new(StringArray::from(parent_ids)),
615 Arc::new(UInt8Array::from(depths)),
616 vector_array,
617 Arc::new(StringArray::from(embedding_models)),
618 Arc::new(StringArray::from(indexed_ats)),
619 Arc::new(StringArray::from(mime_types.clone())),
620 Arc::new(StringArray::from(languages)),
621 Arc::new(StringArray::from(symbol_types)),
622 Arc::new(StringArray::from(symbol_names)),
623 Arc::new(StringArray::from(dir_paths)),
624 Arc::new(UInt16Array::from(dir_depths)),
625 Arc::new(StringArray::from(path_components)),
626 ],
627 )
628 .map_err(|e| StoreError::Insert(format!("Failed to create RecordBatch: {e}")))?;
629
630 Ok(batch)
631 }
632
633 fn file_to_batch(&self, record: &FileRecord) -> Result<RecordBatch, StoreError> {
635 let schema = Arc::new(self.files_schema());
636
637 let batch = RecordBatch::try_new(
638 schema,
639 vec![
640 Arc::new(StringArray::from(vec![record.id.to_string()])),
641 Arc::new(StringArray::from(vec![
642 record.path.to_string_lossy().to_string(),
643 ])),
644 Arc::new(UInt64Array::from(vec![record.size_bytes])),
645 Arc::new(StringArray::from(vec![record.mime_type.clone()])),
646 Arc::new(StringArray::from(vec![record.content_hash.clone()])),
647 Arc::new(StringArray::from(vec![record.modified_at.to_rfc3339()])),
648 Arc::new(StringArray::from(vec![
649 record.indexed_at.map(|t| t.to_rfc3339()),
650 ])),
651 Arc::new(UInt32Array::from(vec![record.chunk_count])),
652 Arc::new(StringArray::from(vec![status_to_string(&record.status)])),
653 Arc::new(StringArray::from(vec![record.error_message.clone()])),
654 ],
655 )
656 .map_err(|e| StoreError::Insert(format!("Failed to create file RecordBatch: {e}")))?;
657
658 Ok(batch)
659 }
660
661 async fn chunk_filter_sql(
663 &self,
664 filters: &[SearchFilter],
665 ) -> Result<Option<String>, StoreError> {
666 Ok(combine_predicates(
667 filters_to_sql(filters),
668 self.modified_at_path_predicate(filters).await?,
669 ))
670 }
671
672 async fn modified_at_path_predicate(
674 &self,
675 filters: &[SearchFilter],
676 ) -> Result<Option<String>, StoreError> {
677 let Some(date_sql) = file_date_filters_to_sql(filters) else {
678 return Ok(None);
679 };
680
681 let table = self.get_files_table().await?;
682 let mut results = table
683 .query()
684 .only_if(date_sql)
685 .execute()
686 .await
687 .map_err(|e| StoreError::Query(format!("Failed to apply modified_at filter: {e}")))?;
688
689 let mut paths = Vec::new();
690 while let Some(batch) = results.try_next().await.map_err(|e| {
691 StoreError::Query(format!("Failed to fetch files for modified_at filter: {e}"))
692 })? {
693 for record in batch_to_file_records(&batch)? {
694 paths.push(record.path.to_string_lossy().to_string());
695 }
696 }
697
698 if paths.is_empty() {
699 return Ok(Some("1 = 0".to_string()));
700 }
701
702 let in_list = paths
703 .iter()
704 .map(|p| format!("'{}'", escape_sql_literal(p)))
705 .collect::<Vec<_>>()
706 .join(", ");
707 Ok(Some(format!("file_path IN ({in_list})")))
708 }
709}
710
711#[async_trait]
712impl VectorStore for LanceStore {
713 async fn init(&self) -> Result<(), StoreError> {
714 info!("Initializing LanceDB at {:?}", self.db_path);
715
716 if let Some(parent) = self.db_path.parent() {
718 tokio::fs::create_dir_all(parent)
719 .await
720 .map_err(|e| StoreError::Init(format!("Failed to create db directory: {e}")))?;
721 }
722
723 let conn = self.get_connection().await?;
724
725 let tables = conn
727 .table_names()
728 .execute()
729 .await
730 .map_err(|e| StoreError::Init(format!("Failed to list tables: {e}")))?;
731
732 if tables.contains(&CHUNKS_TABLE.to_string()) {
733 self.ensure_chunks_schema_v2().await?;
734 } else {
735 info!("Creating chunks table");
736 let schema = Arc::new(self.chunks_schema());
737 conn.create_empty_table(CHUNKS_TABLE, schema)
738 .execute()
739 .await
740 .map_err(|e| StoreError::Init(format!("Failed to create chunks table: {e}")))?;
741
742 info!("Creating FTS index on content column");
744 let table = conn
745 .open_table(CHUNKS_TABLE)
746 .execute()
747 .await
748 .map_err(|e| StoreError::Init(format!("Failed to open chunks table: {e}")))?;
749
750 if let Err(e) = table
751 .create_index(&["content"], Index::FTS(FtsIndexBuilder::default()))
752 .execute()
753 .await
754 {
755 warn!("Failed to create FTS index (may already exist): {e}");
756 }
757
758 self.write_schema_sidecar(CHUNKS_SCHEMA_VERSION).await?;
759 }
760
761 if !tables.contains(&FILES_TABLE.to_string()) {
763 info!("Creating files table");
764 let schema = Arc::new(self.files_schema());
765 conn.create_empty_table(FILES_TABLE, schema)
766 .execute()
767 .await
768 .map_err(|e| StoreError::Init(format!("Failed to create files table: {e}")))?;
769 }
770
771 if let Err(e) = self.ensure_vector_index().await {
773 warn!("ANN index check on init failed: {e}");
774 }
775
776 info!("LanceDB initialized successfully");
777 Ok(())
778 }
779
780 async fn upsert_chunks(&self, chunks: &[Chunk]) -> Result<(), StoreError> {
781 if chunks.is_empty() {
782 return Ok(());
783 }
784
785 debug!("Upserting {} chunks", chunks.len());
786
787 let table = self.get_chunks_table().await?;
788 let batch = self.chunks_to_batch(chunks)?;
789 let schema = batch.schema();
790
791 let batches = RecordBatchIterator::new(vec![Ok(batch)], schema);
792
793 table
794 .add(Box::new(batches) as Box<dyn RecordBatchReader + Send>)
795 .execute()
796 .await
797 .map_err(|e| StoreError::Insert(format!("Failed to insert chunks: {e}")))?;
798
799 debug!("Successfully upserted {} chunks", chunks.len());
800 if let Err(e) = self.ensure_vector_index().await {
801 warn!("ANN index check after upsert failed: {e}");
802 }
803 Ok(())
804 }
805
806 async fn search(&self, query: SearchQuery) -> Result<Vec<SearchResult>, StoreError> {
807 debug!(
808 "Searching with limit {} metric {:?} filters {}",
809 query.limit,
810 query.metric,
811 query.filters.len()
812 );
813
814 let table = self.get_chunks_table().await?;
815 let filter_sql = combine_predicates(
816 self.chunk_filter_sql(&query.filters).await?,
817 query.scope_prefix.as_deref().and_then(scope_prefix_sql),
818 );
819
820 let mut search_q = table
821 .vector_search(query.embedding.clone())
822 .map_err(|e| StoreError::Query(format!("Failed to create search query: {e}")))?
823 .distance_type(distance_type_from_metric(query.metric))
824 .limit(query.limit);
825
826 if !Self::ann_index_covers_metric(query.metric) {
827 search_q = search_q.bypass_vector_index();
828 }
829
830 if let Some(ref filter) = filter_sql {
831 debug!("Applying search filter: {filter}");
832 search_q = search_q.only_if(filter);
833 }
834
835 let mut results = search_q
836 .execute()
837 .await
838 .map_err(|e| StoreError::Query(format!("Failed to execute search: {e}")))?;
839
840 let mut search_results = Vec::new();
841
842 while let Some(batch) = results
843 .try_next()
844 .await
845 .map_err(|e| StoreError::Query(format!("Failed to fetch results: {e}")))?
846 {
847 search_results.extend(batch_to_search_results(&batch)?);
848 }
849
850 debug!("Found {} results", search_results.len());
851 Ok(search_results)
852 }
853
854 async fn hybrid_search(&self, query: SearchQuery) -> Result<Vec<SearchResult>, StoreError> {
855 let query_text = match &query.text {
857 Some(text) if !text.is_empty() => text.clone(),
858 _ => return self.search(query).await,
859 };
860
861 debug!(
862 "Performing hybrid search with text: '{}' limit {} metric {:?} filters {}",
863 query_text,
864 query.limit,
865 query.metric,
866 query.filters.len()
867 );
868
869 let table = self.get_chunks_table().await?;
870 let filter_sql = combine_predicates(
871 self.chunk_filter_sql(&query.filters).await?,
872 query.scope_prefix.as_deref().and_then(scope_prefix_sql),
873 );
874
875 let fts_query = FullTextSearchQuery::new(query_text);
877
878 let mut search_q = table
879 .query()
880 .full_text_search(fts_query)
881 .nearest_to(query.embedding.clone())
882 .map_err(|e| StoreError::Query(format!("Failed to create hybrid query: {e}")))?
883 .distance_type(distance_type_from_metric(query.metric))
884 .limit(query.limit);
885
886 if !Self::ann_index_covers_metric(query.metric) {
887 search_q = search_q.bypass_vector_index();
888 }
889
890 if let Some(ref filter) = filter_sql {
891 debug!("Applying hybrid search filter: {filter}");
892 search_q = search_q.only_if(filter);
893 }
894
895 let mut results = search_q
896 .execute_hybrid(QueryExecutionOptions::default())
897 .await
898 .map_err(|e| StoreError::Query(format!("Failed to execute hybrid search: {e}")))?;
899
900 let mut search_results = Vec::new();
901
902 while let Some(batch) = results
903 .try_next()
904 .await
905 .map_err(|e| StoreError::Query(format!("Failed to fetch hybrid results: {e}")))?
906 {
907 search_results.extend(batch_to_search_results(&batch)?);
908 }
909
910 debug!("Hybrid search found {} results", search_results.len());
911 Ok(search_results)
912 }
913
914 async fn delete_by_file_path(&self, path: &Path) -> Result<u64, StoreError> {
915 let path_str = path.to_string_lossy().to_string();
916 debug!("Deleting chunks for file: {}", path_str);
917
918 let table = self.get_chunks_table().await?;
919
920 table
921 .delete(&format!("file_path = '{}'", path_str.replace('\'', "''")))
922 .await
923 .map_err(|e| StoreError::Delete(format!("Failed to delete chunks: {e}")))?;
924
925 let files_table = self.get_files_table().await?;
927 files_table
928 .delete(&format!("path = '{}'", path_str.replace('\'', "''")))
929 .await
930 .map_err(|e| StoreError::Delete(format!("Failed to delete file record: {e}")))?;
931
932 Ok(1) }
934
935 async fn update_file_path(&self, from: &Path, to: &Path) -> Result<u64, StoreError> {
936 debug!("Updating file path from {:?} to {:?}", from, to);
938
939 let mut chunks = self.get_chunks_for_file(from).await?;
941 if chunks.is_empty() {
942 debug!("No chunks found for path {:?}", from);
943 return Ok(0);
944 }
945
946 let chunk_count = chunks.len() as u64;
947
948 for chunk in &mut chunks {
950 let root = DirectoryScope::infer_root(&chunk.file_path, &chunk.dir_path);
951 let scope = DirectoryScope::from_paths(to, root.as_deref());
952 chunk.file_path = to.to_path_buf();
953 chunk.dir_path = scope.dir_path;
954 chunk.dir_depth = scope.dir_depth;
955 chunk.path_components = scope.path_components;
956 }
957
958 self.delete_by_file_path(from).await?;
960
961 self.upsert_chunks(&chunks).await?;
963
964 if let Ok(Some(mut file_record)) = self.get_file(from).await {
966 file_record.path = to.to_path_buf();
967 self.upsert_file(&file_record).await?;
968 }
969
970 info!("Updated {} chunks from {:?} to {:?}", chunk_count, from, to);
971 Ok(chunk_count)
972 }
973
974 async fn get_chunks_for_file(&self, path: &Path) -> Result<Vec<Chunk>, StoreError> {
975 let path_str = path.to_string_lossy().to_string();
976 debug!("Getting chunks for file: {}", path_str);
977
978 let table = self.get_chunks_table().await?;
979
980 let mut results = table
981 .query()
982 .only_if(format!("file_path = '{}'", path_str.replace('\'', "''")))
983 .execute()
984 .await
985 .map_err(|e| StoreError::Query(format!("Failed to query chunks: {e}")))?;
986
987 let mut chunks = Vec::new();
988
989 while let Some(batch) = results
990 .try_next()
991 .await
992 .map_err(|e| StoreError::Query(format!("Failed to fetch chunks: {e}")))?
993 {
994 chunks.extend(batch_to_chunks(&batch)?);
995 }
996
997 Ok(chunks)
998 }
999
1000 async fn get_file(&self, path: &Path) -> Result<Option<FileRecord>, StoreError> {
1001 let path_str = path.to_string_lossy().to_string();
1002 debug!("Getting file record: {}", path_str);
1003
1004 let table = self.get_files_table().await?;
1005
1006 let mut results = table
1007 .query()
1008 .only_if(format!("path = '{}'", path_str.replace('\'', "''")))
1009 .limit(1)
1010 .execute()
1011 .await
1012 .map_err(|e| StoreError::Query(format!("Failed to query file: {e}")))?;
1013
1014 if let Some(batch) = results
1015 .try_next()
1016 .await
1017 .map_err(|e| StoreError::Query(format!("Failed to fetch file: {e}")))?
1018 {
1019 let records = batch_to_file_records(&batch)?;
1020 return Ok(records.into_iter().next());
1021 }
1022
1023 Ok(None)
1024 }
1025
1026 async fn upsert_file(&self, record: &FileRecord) -> Result<(), StoreError> {
1027 debug!("Upserting file record: {:?}", record.path);
1028
1029 let path_str = record.path.to_string_lossy().to_string();
1030
1031 let files_table = self.get_files_table().await?;
1033 let _ = files_table
1034 .delete(&format!("path = '{}'", path_str.replace('\'', "''")))
1035 .await;
1036
1037 let batch = self.file_to_batch(record)?;
1039 let schema = batch.schema();
1040
1041 let batches = RecordBatchIterator::new(vec![Ok(batch)], schema);
1042
1043 files_table
1044 .add(Box::new(batches) as Box<dyn RecordBatchReader + Send>)
1045 .execute()
1046 .await
1047 .map_err(|e| StoreError::Insert(format!("Failed to insert file record: {e}")))?;
1048
1049 Ok(())
1050 }
1051
1052 async fn stats(&self) -> Result<StoreStats, StoreError> {
1053 let chunks_table = self.get_chunks_table().await?;
1054 let files_table = self.get_files_table().await?;
1055
1056 let mut chunk_count = 0u64;
1058 let mut results = chunks_table
1059 .query()
1060 .only_if("file_path LIKE '%'")
1061 .execute()
1062 .await
1063 .map_err(|e| StoreError::Query(format!("Failed to query chunks: {e}")))?;
1064
1065 while let Some(batch) = results
1066 .try_next()
1067 .await
1068 .map_err(|e| StoreError::Query(format!("Failed to count chunks: {e}")))?
1069 {
1070 chunk_count += batch.num_rows() as u64;
1071 }
1072
1073 let mut file_count = 0u64;
1075 let mut results = files_table
1076 .query()
1077 .only_if("size_bytes >= 0")
1078 .execute()
1079 .await
1080 .map_err(|e| StoreError::Query(format!("Failed to query files: {e}")))?;
1081
1082 while let Some(batch) = results
1083 .try_next()
1084 .await
1085 .map_err(|e| StoreError::Query(format!("Failed to count files: {e}")))?
1086 {
1087 file_count += batch.num_rows() as u64;
1088 }
1089
1090 let index_size_bytes = calculate_dir_size(&self.db_path);
1092
1093 Ok(StoreStats {
1094 total_chunks: chunk_count,
1095 total_files: file_count,
1096 index_size_bytes,
1097 last_updated: Some(Utc::now()),
1098 })
1099 }
1100
1101 async fn get_all_chunks(&self) -> Result<Vec<Chunk>, StoreError> {
1102 debug!("Getting all chunks");
1103
1104 let table = self.get_chunks_table().await?;
1105
1106 let mut results = table
1107 .query()
1108 .only_if("file_path LIKE '%'")
1109 .execute()
1110 .await
1111 .map_err(|e| StoreError::Query(format!("Failed to query all chunks: {e}")))?;
1112
1113 let mut chunks = Vec::new();
1114
1115 while let Some(batch) = results
1116 .try_next()
1117 .await
1118 .map_err(|e| StoreError::Query(format!("Failed to fetch chunks: {e}")))?
1119 {
1120 chunks.extend(batch_to_chunks(&batch)?);
1121 }
1122
1123 debug!("Retrieved {} chunks", chunks.len());
1124 Ok(chunks)
1125 }
1126
1127 async fn get_all_files(&self) -> Result<Vec<FileRecord>, StoreError> {
1128 debug!("Getting all file records");
1129
1130 let table = self.get_files_table().await?;
1131
1132 let mut results = table
1133 .query()
1134 .only_if("size_bytes >= 0")
1135 .execute()
1136 .await
1137 .map_err(|e| StoreError::Query(format!("Failed to query all files: {e}")))?;
1138
1139 let mut records = Vec::new();
1140
1141 while let Some(batch) = results
1142 .try_next()
1143 .await
1144 .map_err(|e| StoreError::Query(format!("Failed to fetch files: {e}")))?
1145 {
1146 records.extend(batch_to_file_records(&batch)?);
1147 }
1148
1149 debug!("Retrieved {} file records", records.len());
1150 Ok(records)
1151 }
1152}
1153
1154fn calculate_dir_size(path: &Path) -> u64 {
1160 if !path.exists() {
1161 return 0;
1162 }
1163
1164 let mut total_size = 0u64;
1165
1166 if let Ok(entries) = std::fs::read_dir(path) {
1167 for entry in entries.flatten() {
1168 let entry_path = entry.path();
1169 if entry_path.is_file() {
1170 if let Ok(metadata) = entry.metadata() {
1171 total_size += metadata.len();
1172 }
1173 } else if entry_path.is_dir() {
1174 total_size += calculate_dir_size(&entry_path);
1175 }
1176 }
1177 }
1178
1179 total_size
1180}
1181
1182fn distance_type_from_metric(metric: DistanceMetric) -> DistanceType {
1184 match metric {
1185 DistanceMetric::Cosine => DistanceType::Cosine,
1186 DistanceMetric::L2 => DistanceType::L2,
1187 DistanceMetric::Dot => DistanceType::Dot,
1188 }
1189}
1190
1191fn escape_sql_literal(value: &str) -> String {
1193 value.replace('\'', "''")
1194}
1195
1196fn escape_like_literal(value: &str) -> String {
1198 value
1199 .replace('\\', "\\\\")
1200 .replace('%', "\\%")
1201 .replace('_', "\\_")
1202}
1203
1204fn glob_to_regex(glob: &str) -> String {
1209 let mut regex = String::from("^");
1210 let chars: Vec<char> = glob.chars().collect();
1211 let mut i = 0;
1212
1213 while i < chars.len() {
1214 match chars[i] {
1215 '*' => {
1216 if i + 1 < chars.len() && chars[i + 1] == '*' {
1217 i += 2;
1218 if i < chars.len() && chars[i] == '/' {
1219 i += 1;
1220 regex.push_str("(?:.*/)?");
1221 } else {
1222 regex.push_str(".*");
1223 }
1224 } else {
1225 regex.push_str("[^/]*");
1226 i += 1;
1227 }
1228 }
1229 '?' => {
1230 regex.push_str("[^/]");
1231 i += 1;
1232 }
1233 c => {
1234 regex.push_str(®ex_escape_char(c));
1235 i += 1;
1236 }
1237 }
1238 }
1239
1240 regex.push('$');
1241 regex
1242}
1243
1244fn regex_escape_char(c: char) -> String {
1245 if matches!(
1246 c,
1247 '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\'
1248 ) {
1249 format!("\\{c}")
1250 } else {
1251 c.to_string()
1252 }
1253}
1254
1255fn filter_to_sql(filter: &SearchFilter) -> Option<String> {
1259 Some(match filter {
1260 SearchFilter::PathPrefix(prefix) => {
1261 let pattern = escape_sql_literal(&format!("{}%", escape_like_literal(prefix)));
1262 format!("file_path LIKE '{pattern}' ESCAPE '\\'")
1263 }
1264 SearchFilter::PathGlob(glob) => {
1265 let pattern = escape_sql_literal(&glob_to_regex(glob));
1266 format!("regexp_like(file_path, '{pattern}')")
1267 }
1268 SearchFilter::MimeType(value) => type_or_mime_sql(value),
1269 SearchFilter::Language(lang) => {
1270 let escaped = escape_sql_literal(&lang.to_lowercase());
1271 format!("(LOWER(language) = '{escaped}' OR LOWER(content_type) = 'code:{escaped}')")
1272 }
1273 SearchFilter::ModifiedAfter(_) | SearchFilter::ModifiedBefore(_) => return None,
1274 SearchFilter::MinDepth(depth) => format!("depth >= {depth}"),
1275 SearchFilter::MaxDepth(depth) => format!("depth <= {depth}"),
1276 })
1277}
1278
1279fn file_date_filters_to_sql(filters: &[SearchFilter]) -> Option<String> {
1281 let clauses: Vec<String> = filters
1282 .iter()
1283 .filter_map(|filter| match filter {
1284 SearchFilter::ModifiedAfter(ts) => {
1285 let escaped = escape_sql_literal(&ts.to_rfc3339());
1286 Some(format!("modified_at >= '{escaped}'"))
1287 }
1288 SearchFilter::ModifiedBefore(ts) => {
1289 let escaped = escape_sql_literal(&ts.to_rfc3339());
1290 Some(format!("modified_at <= '{escaped}'"))
1291 }
1292 _ => None,
1293 })
1294 .collect();
1295
1296 if clauses.is_empty() {
1297 None
1298 } else {
1299 Some(clauses.join(" AND "))
1300 }
1301}
1302
1303fn combine_predicates(left: Option<String>, right: Option<String>) -> Option<String> {
1304 match (left, right) {
1305 (Some(a), Some(b)) => Some(format!("{a} AND {b}")),
1306 (Some(a), None) | (None, Some(a)) => Some(a),
1307 (None, None) => None,
1308 }
1309}
1310
1311fn type_or_mime_sql(value: &str) -> String {
1313 let lowered = value.to_lowercase();
1314 let escaped = escape_sql_literal(&lowered);
1315
1316 if lowered.contains('/') {
1317 return format!("LOWER(file_mime_type) = '{escaped}'");
1318 }
1319
1320 match lowered.as_str() {
1321 "code" => "(LOWER(content_type) LIKE 'code:%' OR LOWER(content_type) = 'code')".to_string(),
1322 "text" => "LOWER(content_type) = 'text'".to_string(),
1323 "markdown" | "md" => "LOWER(content_type) = 'markdown'".to_string(),
1324 "pdf" => "(LOWER(content_type) LIKE 'pdf:%' OR LOWER(content_type) = 'pdf')".to_string(),
1325 "image" | "image_caption" => "LOWER(content_type) = 'image_caption'".to_string(),
1326 _ => format!(
1327 "(LOWER(content_type) = '{escaped}' OR LOWER(content_type) LIKE '{escaped}:%' OR LOWER(file_mime_type) = '{escaped}')"
1328 ),
1329 }
1330}
1331
1332fn scope_prefix_sql(scope: &str) -> Option<String> {
1337 let scope = DirectoryScope::normalize_prefix(scope);
1338 if scope.is_empty() || scope == "." {
1339 return None;
1340 }
1341 let exact = escape_sql_literal(&scope);
1342 let prefix = escape_sql_literal(&format!("{}/%", escape_like_literal(&scope)));
1343 let suffix = escape_sql_literal(&format!("%/{}", escape_like_literal(&scope)));
1344 let mid = escape_sql_literal(&format!("%/{}/%", escape_like_literal(&scope)));
1345 Some(format!(
1346 "dir_path = '{exact}' OR dir_path LIKE '{prefix}' ESCAPE '\\' \
1347 OR dir_path LIKE '{suffix}' ESCAPE '\\' OR dir_path LIKE '{mid}' ESCAPE '\\'"
1348 ))
1349}
1350
1351fn filters_to_sql(filters: &[SearchFilter]) -> Option<String> {
1353 if filters.is_empty() {
1354 return None;
1355 }
1356
1357 let clauses: Vec<String> = filters.iter().filter_map(filter_to_sql).collect();
1358 if clauses.is_empty() {
1359 None
1360 } else {
1361 Some(clauses.join(" AND "))
1362 }
1363}
1364
1365fn content_type_to_string(ct: &ContentType) -> String {
1366 match ct {
1367 ContentType::Text => "text".to_string(),
1368 ContentType::Code { language, .. } => format!("code:{language}"),
1369 ContentType::ImageCaption => "image_caption".to_string(),
1370 ContentType::PdfPage { page_num } => format!("pdf:{page_num}"),
1371 ContentType::Markdown => "markdown".to_string(),
1372 }
1373}
1374
1375fn string_to_content_type(s: &str) -> ContentType {
1376 if s == "text" {
1377 ContentType::Text
1378 } else if s == "markdown" {
1379 ContentType::Markdown
1380 } else if s == "image_caption" {
1381 ContentType::ImageCaption
1382 } else if let Some(lang) = s.strip_prefix("code:") {
1383 ContentType::Code {
1384 language: lang.to_string(),
1385 symbol: None,
1386 }
1387 } else if let Some(page) = s.strip_prefix("pdf:") {
1388 ContentType::PdfPage {
1389 page_num: page.parse().unwrap_or(1),
1390 }
1391 } else {
1392 ContentType::Text
1393 }
1394}
1395
1396fn status_to_string(status: &FileStatus) -> String {
1397 match status {
1398 FileStatus::Pending => "pending".to_string(),
1399 FileStatus::Indexing => "indexing".to_string(),
1400 FileStatus::Indexed => "indexed".to_string(),
1401 FileStatus::Error => "error".to_string(),
1402 FileStatus::Deleted => "deleted".to_string(),
1403 }
1404}
1405
1406fn string_to_status(s: &str) -> FileStatus {
1407 match s {
1408 "pending" => FileStatus::Pending,
1409 "indexing" => FileStatus::Indexing,
1410 "indexed" => FileStatus::Indexed,
1411 "error" => FileStatus::Error,
1412 "deleted" => FileStatus::Deleted,
1413 _ => FileStatus::Pending,
1414 }
1415}
1416
1417fn build_vector_array(
1418 embeddings: &[Option<Vec<Option<f32>>>],
1419 dim: usize,
1420) -> Result<ArrayRef, StoreError> {
1421 use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
1422
1423 let mut builder = FixedSizeListBuilder::new(Float32Builder::new(), dim as i32);
1424
1425 for emb in embeddings {
1426 if let Some(values) = emb {
1427 let values_builder = builder.values();
1428 for &v in values {
1429 values_builder.append_option(v);
1430 }
1431 builder.append(true);
1432 } else {
1433 let values_builder = builder.values();
1435 for _ in 0..dim {
1436 values_builder.append_value(0.0);
1437 }
1438 builder.append(true);
1439 }
1440 }
1441
1442 Ok(Arc::new(builder.finish()))
1443}
1444
1445fn batch_to_search_results(batch: &RecordBatch) -> Result<Vec<SearchResult>, StoreError> {
1446 let mut results = Vec::new();
1447
1448 let chunk_ids = batch
1449 .column_by_name("chunk_id")
1450 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1451 let file_paths = batch
1452 .column_by_name("file_path")
1453 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1454 let contents = batch
1455 .column_by_name("content")
1456 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1457 let start_bytes = batch
1458 .column_by_name("start_byte")
1459 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1460 let end_bytes = batch
1461 .column_by_name("end_byte")
1462 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1463 let start_lines = batch
1464 .column_by_name("start_line")
1465 .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1466 let end_lines = batch
1467 .column_by_name("end_line")
1468 .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1469 let distances = batch
1470 .column_by_name("_distance")
1471 .and_then(|c| c.as_any().downcast_ref::<Float32Array>());
1472
1473 let (Some(chunk_ids), Some(file_paths), Some(contents), Some(start_bytes), Some(end_bytes)) =
1474 (chunk_ids, file_paths, contents, start_bytes, end_bytes)
1475 else {
1476 return Err(StoreError::Query("Missing required columns".to_string()));
1477 };
1478
1479 for i in 0..batch.num_rows() {
1480 let chunk_id = chunk_ids.value(i);
1481 let file_path = file_paths.value(i);
1482 let content = contents.value(i);
1483 let start = start_bytes.value(i);
1484 let end = end_bytes.value(i);
1485
1486 let line_range = match (start_lines, end_lines) {
1487 (Some(sl), Some(el)) if !sl.is_null(i) && !el.is_null(i) => {
1488 Some(sl.value(i)..el.value(i))
1489 }
1490 _ => None,
1491 };
1492
1493 let score = distances.map_or(0.0, |d| 1.0 - d.value(i));
1494
1495 results.push(SearchResult {
1496 chunk_id: Uuid::parse_str(chunk_id).unwrap_or_default(),
1497 file_path: PathBuf::from(file_path),
1498 content: content.to_string(),
1499 score,
1500 byte_range: start..end,
1501 line_range,
1502 metadata: HashMap::new(),
1503 });
1504 }
1505
1506 Ok(results)
1507}
1508
1509fn batch_to_chunks(batch: &RecordBatch) -> Result<Vec<Chunk>, StoreError> {
1510 let mut chunks = Vec::new();
1511
1512 let chunk_ids = batch
1516 .column_by_name("chunk_id")
1517 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1518 let file_ids = batch
1519 .column_by_name("file_id")
1520 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1521 let file_paths = batch
1522 .column_by_name("file_path")
1523 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1524 let contents = batch
1525 .column_by_name("content")
1526 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1527 let content_types = batch
1528 .column_by_name("content_type")
1529 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1530 let chunk_indices = batch
1531 .column_by_name("chunk_index")
1532 .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1533 let start_bytes = batch
1534 .column_by_name("start_byte")
1535 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1536 let end_bytes = batch
1537 .column_by_name("end_byte")
1538 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1539 let depths = batch
1540 .column_by_name("depth")
1541 .and_then(|c| c.as_any().downcast_ref::<UInt8Array>());
1542 let mime_types = batch
1543 .column_by_name("mime_type")
1544 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1545 let start_lines = batch
1546 .column_by_name("start_line")
1547 .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1548 let end_lines = batch
1549 .column_by_name("end_line")
1550 .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1551 let embeddings = batch
1552 .column_by_name("embedding")
1553 .and_then(|c| c.as_any().downcast_ref::<FixedSizeListArray>());
1554 let dir_paths = batch
1555 .column_by_name("dir_path")
1556 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1557 let dir_depths = batch
1558 .column_by_name("dir_depth")
1559 .and_then(|c| c.as_any().downcast_ref::<UInt16Array>());
1560 let path_components_arr = batch
1561 .column_by_name("path_components")
1562 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1563
1564 let (
1565 Some(chunk_ids),
1566 Some(file_ids),
1567 Some(file_paths),
1568 Some(contents),
1569 Some(content_types),
1570 Some(chunk_indices),
1571 Some(start_bytes),
1572 Some(end_bytes),
1573 Some(depths),
1574 ) = (
1575 chunk_ids,
1576 file_ids,
1577 file_paths,
1578 contents,
1579 content_types,
1580 chunk_indices,
1581 start_bytes,
1582 end_bytes,
1583 depths,
1584 )
1585 else {
1586 return Err(StoreError::Query(
1587 "Missing required columns in chunks".to_string(),
1588 ));
1589 };
1590
1591 for i in 0..batch.num_rows() {
1592 let mime_type = mime_types.and_then(|m| {
1593 if m.is_null(i) {
1594 None
1595 } else {
1596 Some(m.value(i).to_string())
1597 }
1598 });
1599
1600 let line_range = match (start_lines, end_lines) {
1602 (Some(starts), Some(ends)) if !starts.is_null(i) && !ends.is_null(i) => {
1603 Some(starts.value(i)..ends.value(i))
1604 }
1605 _ => None,
1606 };
1607
1608 let embedding = embeddings.and_then(|emb_array| {
1610 if emb_array.is_null(i) {
1611 None
1612 } else {
1613 let values = emb_array.value(i);
1614 values
1615 .as_any()
1616 .downcast_ref::<Float32Array>()
1617 .map(|arr| arr.values().to_vec())
1618 }
1619 });
1620
1621 chunks.push(Chunk {
1622 id: Uuid::parse_str(chunk_ids.value(i)).unwrap_or_default(),
1623 file_id: Uuid::parse_str(file_ids.value(i)).unwrap_or_default(),
1624 file_path: PathBuf::from(file_paths.value(i)),
1625 content: contents.value(i).to_string(),
1626 content_type: string_to_content_type(content_types.value(i)),
1627 mime_type,
1628 chunk_index: chunk_indices.value(i),
1629 byte_range: start_bytes.value(i)..end_bytes.value(i),
1630 line_range,
1631 parent_chunk_id: None,
1632 depth: depths.value(i),
1633 embedding,
1634 dir_path: dir_paths.map_or_else(
1635 || DirectoryScope::from_file_path(&PathBuf::from(file_paths.value(i))).dir_path,
1636 |arr| {
1637 if arr.is_null(i) {
1638 String::new()
1639 } else {
1640 arr.value(i).to_string()
1641 }
1642 },
1643 ),
1644 dir_depth: dir_depths.map_or(0, |arr| if arr.is_null(i) { 0 } else { arr.value(i) }),
1645 path_components: path_components_arr.map_or_else(String::new, |arr| {
1646 if arr.is_null(i) {
1647 String::new()
1648 } else {
1649 arr.value(i).to_string()
1650 }
1651 }),
1652 metadata: ChunkMetadata::default(),
1653 });
1654 }
1655
1656 Ok(chunks)
1657}
1658
1659fn batch_to_file_records(batch: &RecordBatch) -> Result<Vec<FileRecord>, StoreError> {
1660 let mut records = Vec::new();
1661
1662 let file_ids = batch
1663 .column_by_name("file_id")
1664 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1665 let paths = batch
1666 .column_by_name("path")
1667 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1668 let sizes = batch
1669 .column_by_name("size_bytes")
1670 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1671 let mime_types = batch
1672 .column_by_name("mime_type")
1673 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1674 let hashes = batch
1675 .column_by_name("content_hash")
1676 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1677 let modified_ats = batch
1678 .column_by_name("modified_at")
1679 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1680 let chunk_counts = batch
1681 .column_by_name("chunk_count")
1682 .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1683 let statuses = batch
1684 .column_by_name("status")
1685 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1686 let indexed_ats = batch
1687 .column_by_name("indexed_at")
1688 .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1689
1690 let (
1691 Some(file_ids),
1692 Some(paths),
1693 Some(sizes),
1694 Some(mime_types),
1695 Some(hashes),
1696 Some(modified_ats),
1697 Some(chunk_counts),
1698 Some(statuses),
1699 ) = (
1700 file_ids,
1701 paths,
1702 sizes,
1703 mime_types,
1704 hashes,
1705 modified_ats,
1706 chunk_counts,
1707 statuses,
1708 )
1709 else {
1710 return Err(StoreError::Query(
1711 "Missing required columns in files".to_string(),
1712 ));
1713 };
1714
1715 for i in 0..batch.num_rows() {
1716 let modified_at = chrono::DateTime::parse_from_rfc3339(modified_ats.value(i))
1717 .map_or_else(|_| Utc::now(), |dt| dt.with_timezone(&Utc));
1718
1719 let indexed_at = indexed_ats.and_then(|arr| {
1721 if arr.is_null(i) {
1722 None
1723 } else {
1724 chrono::DateTime::parse_from_rfc3339(arr.value(i))
1725 .map(|dt| dt.with_timezone(&Utc))
1726 .ok()
1727 }
1728 });
1729
1730 records.push(FileRecord {
1731 id: Uuid::parse_str(file_ids.value(i)).unwrap_or_default(),
1732 path: PathBuf::from(paths.value(i)),
1733 size_bytes: sizes.value(i),
1734 mime_type: mime_types.value(i).to_string(),
1735 content_hash: hashes.value(i).to_string(),
1736 modified_at,
1737 indexed_at,
1738 chunk_count: chunk_counts.value(i),
1739 status: string_to_status(statuses.value(i)),
1740 error_message: None,
1741 });
1742 }
1743
1744 Ok(records)
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749 use super::*;
1750 use crate::schema::{CHUNKS_SCHEMA_VERSION, SCHEMA_SIDECAR_FILENAME, SchemaSidecar};
1751 use ragfs_core::{DistanceMetric, SearchFilter};
1752 use std::collections::HashMap;
1753 use tempfile::tempdir;
1754
1755 const TEST_DIM: usize = 384;
1756
1757 fn create_test_chunk(
1758 file_path: &Path,
1759 content: &str,
1760 embedding: Vec<f32>,
1761 chunk_index: u32,
1762 ) -> Chunk {
1763 Chunk {
1764 id: Uuid::new_v4(),
1765 file_id: Uuid::new_v4(),
1766 file_path: file_path.to_path_buf(),
1767 content: content.to_string(),
1768 content_type: ContentType::Text,
1769 mime_type: Some("text/plain".to_string()),
1770 chunk_index,
1771 byte_range: 0..content.len() as u64,
1772 line_range: Some(0..1),
1773 parent_chunk_id: None,
1774 depth: 0,
1775 embedding: Some(embedding),
1776 dir_path: DirectoryScope::from_file_path(file_path).dir_path,
1777 dir_depth: DirectoryScope::from_file_path(file_path).dir_depth,
1778 path_components: DirectoryScope::from_file_path(file_path).path_components,
1779 metadata: ChunkMetadata {
1780 indexed_at: Some(Utc::now()),
1781 embedding_model: Some("test-model".to_string()),
1782 token_count: None,
1783 extra: HashMap::new(),
1784 },
1785 }
1786 }
1787
1788 fn create_random_embedding(dim: usize) -> Vec<f32> {
1789 (0..dim).map(|i| (i as f32 * 0.001).sin()).collect()
1790 }
1791
1792 fn create_test_file_record(path: &Path) -> FileRecord {
1793 FileRecord {
1794 id: Uuid::new_v4(),
1795 path: path.to_path_buf(),
1796 size_bytes: 1024,
1797 mime_type: "text/plain".to_string(),
1798 content_hash: "abc123".to_string(),
1799 modified_at: Utc::now(),
1800 indexed_at: Some(Utc::now()),
1801 chunk_count: 1,
1802 status: FileStatus::Indexed,
1803 error_message: None,
1804 }
1805 }
1806
1807 #[tokio::test]
1808 async fn test_init_creates_tables() {
1809 let temp = tempdir().unwrap();
1810 let db_path = temp.path().join("test.lance");
1811 let store = LanceStore::new(db_path.clone(), TEST_DIM);
1812
1813 let result = store.init().await;
1814 assert!(result.is_ok(), "Init failed: {:?}", result.err());
1815
1816 let conn = store.get_connection().await.unwrap();
1818 let tables = conn.table_names().execute().await.unwrap();
1819 assert!(tables.contains(&"chunks".to_string()));
1820 assert!(tables.contains(&"files".to_string()));
1821 }
1822
1823 #[tokio::test]
1824 async fn test_init_idempotent() {
1825 let temp = tempdir().unwrap();
1826 let db_path = temp.path().join("test.lance");
1827 let store = LanceStore::new(db_path.clone(), TEST_DIM);
1828
1829 store.init().await.unwrap();
1831 let result = store.init().await;
1832 assert!(result.is_ok());
1833 }
1834
1835 #[tokio::test]
1836 async fn test_init_rejects_embedding_dim_mismatch() {
1837 let temp = tempdir().unwrap();
1838 let db_path = temp.path().join("test.lance");
1839 LanceStore::new(db_path.clone(), TEST_DIM)
1840 .init()
1841 .await
1842 .unwrap();
1843
1844 let err = LanceStore::new(db_path, 768).init().await.unwrap_err();
1845 let msg = err.to_string();
1846 assert!(
1847 matches!(err, StoreError::Schema(_)),
1848 "expected Schema, got {err:?}"
1849 );
1850 assert!(
1851 msg.contains("384") && msg.contains("768"),
1852 "mismatch message should name both dimensions: {msg}"
1853 );
1854 }
1855
1856 #[tokio::test]
1857 async fn test_upsert_and_get_chunks() {
1858 let temp = tempdir().unwrap();
1859 let db_path = temp.path().join("test.lance");
1860 let store = LanceStore::new(db_path.clone(), TEST_DIM);
1861 store.init().await.unwrap();
1862
1863 let file_path = PathBuf::from("/test/file.txt");
1864 let embedding = create_random_embedding(TEST_DIM);
1865 let chunk = create_test_chunk(&file_path, "Hello world", embedding, 0);
1866
1867 let result = store.upsert_chunks(&[chunk]).await;
1869 assert!(result.is_ok(), "Upsert failed: {:?}", result.err());
1870
1871 let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
1873 assert_eq!(chunks.len(), 1);
1874 assert_eq!(chunks[0].content, "Hello world");
1875 }
1876
1877 #[tokio::test]
1878 async fn test_upsert_multiple_chunks() {
1879 let temp = tempdir().unwrap();
1880 let db_path = temp.path().join("test.lance");
1881 let store = LanceStore::new(db_path.clone(), TEST_DIM);
1882 store.init().await.unwrap();
1883
1884 let file_path = PathBuf::from("/test/multi.txt");
1885 let chunks: Vec<Chunk> = (0..5)
1886 .map(|i| {
1887 create_test_chunk(
1888 &file_path,
1889 &format!("Chunk content {i}"),
1890 create_random_embedding(TEST_DIM),
1891 i,
1892 )
1893 })
1894 .collect();
1895
1896 store.upsert_chunks(&chunks).await.unwrap();
1897
1898 let retrieved = store.get_chunks_for_file(&file_path).await.unwrap();
1899 assert_eq!(retrieved.len(), 5);
1900 }
1901
1902 #[tokio::test]
1903 async fn test_upsert_empty_chunks() {
1904 let temp = tempdir().unwrap();
1905 let db_path = temp.path().join("test.lance");
1906 let store = LanceStore::new(db_path.clone(), TEST_DIM);
1907 store.init().await.unwrap();
1908
1909 let result = store.upsert_chunks(&[]).await;
1911 assert!(result.is_ok());
1912 }
1913
1914 #[tokio::test]
1915 async fn test_search_returns_results() {
1916 let temp = tempdir().unwrap();
1917 let db_path = temp.path().join("test.lance");
1918 let store = LanceStore::new(db_path.clone(), TEST_DIM);
1919 store.init().await.unwrap();
1920
1921 let file_path = PathBuf::from("/test/search.txt");
1923 let embedding = create_random_embedding(TEST_DIM);
1924 let chunk = create_test_chunk(&file_path, "Authentication logic", embedding.clone(), 0);
1925 store.upsert_chunks(&[chunk]).await.unwrap();
1926
1927 let query = SearchQuery {
1929 text: Some("auth".to_string()),
1930 embedding: embedding.clone(),
1931 limit: 10,
1932 filters: vec![],
1933 metric: DistanceMetric::Cosine,
1934 scope_prefix: None,
1935 };
1936
1937 let results = store.search(query).await.unwrap();
1938 assert!(!results.is_empty());
1939 assert_eq!(results[0].content, "Authentication logic");
1940 }
1941
1942 #[tokio::test]
1943 async fn test_search_respects_limit() {
1944 let temp = tempdir().unwrap();
1945 let db_path = temp.path().join("test.lance");
1946 let store = LanceStore::new(db_path.clone(), TEST_DIM);
1947 store.init().await.unwrap();
1948
1949 let file_path = PathBuf::from("/test/limit.txt");
1951 let chunks: Vec<Chunk> = (0..10)
1952 .map(|i| {
1953 create_test_chunk(
1954 &file_path,
1955 &format!("Content {i}"),
1956 create_random_embedding(TEST_DIM),
1957 i,
1958 )
1959 })
1960 .collect();
1961 store.upsert_chunks(&chunks).await.unwrap();
1962
1963 let query = SearchQuery {
1965 text: Some("test".to_string()),
1966 embedding: create_random_embedding(TEST_DIM),
1967 limit: 3,
1968 filters: vec![],
1969 metric: DistanceMetric::Cosine,
1970 scope_prefix: None,
1971 };
1972
1973 let results = store.search(query).await.unwrap();
1974 assert!(results.len() <= 3);
1975 }
1976
1977 #[tokio::test]
1978 async fn test_delete_by_file_path() {
1979 let temp = tempdir().unwrap();
1980 let db_path = temp.path().join("test.lance");
1981 let store = LanceStore::new(db_path.clone(), TEST_DIM);
1982 store.init().await.unwrap();
1983
1984 let file_path = PathBuf::from("/test/delete.txt");
1985 let chunk = create_test_chunk(
1986 &file_path,
1987 "To be deleted",
1988 create_random_embedding(TEST_DIM),
1989 0,
1990 );
1991 store.upsert_chunks(&[chunk]).await.unwrap();
1992
1993 let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
1995 assert_eq!(chunks.len(), 1);
1996
1997 store.delete_by_file_path(&file_path).await.unwrap();
1999
2000 let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
2002 assert_eq!(chunks.len(), 0);
2003 }
2004
2005 #[tokio::test]
2006 async fn test_upsert_and_get_file_record() {
2007 let temp = tempdir().unwrap();
2008 let db_path = temp.path().join("test.lance");
2009 let store = LanceStore::new(db_path.clone(), TEST_DIM);
2010 store.init().await.unwrap();
2011
2012 let file_path = PathBuf::from("/test/record.txt");
2013 let record = create_test_file_record(&file_path);
2014
2015 store.upsert_file(&record).await.unwrap();
2017
2018 let retrieved = store.get_file(&file_path).await.unwrap();
2020 assert!(retrieved.is_some());
2021 let retrieved = retrieved.unwrap();
2022 assert_eq!(retrieved.path, file_path);
2023 assert_eq!(retrieved.mime_type, "text/plain");
2024 }
2025
2026 #[tokio::test]
2027 async fn test_get_nonexistent_file() {
2028 let temp = tempdir().unwrap();
2029 let db_path = temp.path().join("test.lance");
2030 let store = LanceStore::new(db_path.clone(), TEST_DIM);
2031 store.init().await.unwrap();
2032
2033 let result = store
2034 .get_file(&PathBuf::from("/nonexistent"))
2035 .await
2036 .unwrap();
2037 assert!(result.is_none());
2038 }
2039
2040 #[tokio::test]
2041 async fn test_stats() {
2042 let temp = tempdir().unwrap();
2043 let db_path = temp.path().join("test.lance");
2044 let store = LanceStore::new(db_path.clone(), TEST_DIM);
2045 store.init().await.unwrap();
2046
2047 let stats = store.stats().await.unwrap();
2049 assert_eq!(stats.total_chunks, 0);
2050 assert_eq!(stats.total_files, 0);
2051
2052 let file_path = PathBuf::from("/test/stats.txt");
2054 let chunk = create_test_chunk(
2055 &file_path,
2056 "Stats test",
2057 create_random_embedding(TEST_DIM),
2058 0,
2059 );
2060 store.upsert_chunks(&[chunk]).await.unwrap();
2061
2062 let record = create_test_file_record(&file_path);
2064 store.upsert_file(&record).await.unwrap();
2065
2066 let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
2068 assert_eq!(
2069 chunks.len(),
2070 1,
2071 "Chunks should still exist after upsert_file"
2072 );
2073
2074 let file = store.get_file(&file_path).await.unwrap();
2076 assert!(file.is_some(), "File should be retrievable");
2077
2078 let stats = store.stats().await.unwrap();
2080 assert_eq!(stats.total_chunks, 1);
2081 assert_eq!(stats.total_files, 1);
2082 assert!(
2083 stats.index_size_bytes > 0,
2084 "index_size_bytes should be > 0, got {}",
2085 stats.index_size_bytes
2086 );
2087 }
2088
2089 #[tokio::test]
2090 async fn test_content_type_conversion() {
2091 assert_eq!(content_type_to_string(&ContentType::Text), "text");
2092 assert_eq!(content_type_to_string(&ContentType::Markdown), "markdown");
2093 assert_eq!(
2094 content_type_to_string(&ContentType::Code {
2095 language: "rust".to_string(),
2096 symbol: None
2097 }),
2098 "code:rust"
2099 );
2100 assert_eq!(
2101 content_type_to_string(&ContentType::PdfPage { page_num: 5 }),
2102 "pdf:5"
2103 );
2104
2105 assert!(matches!(string_to_content_type("text"), ContentType::Text));
2106 assert!(matches!(
2107 string_to_content_type("markdown"),
2108 ContentType::Markdown
2109 ));
2110 assert!(matches!(
2111 string_to_content_type("code:python"),
2112 ContentType::Code { language, .. } if language == "python"
2113 ));
2114 }
2115
2116 #[tokio::test]
2117 async fn test_file_status_conversion() {
2118 assert_eq!(status_to_string(&FileStatus::Pending), "pending");
2119 assert_eq!(status_to_string(&FileStatus::Indexed), "indexed");
2120 assert_eq!(status_to_string(&FileStatus::Error), "error");
2121
2122 assert!(matches!(string_to_status("pending"), FileStatus::Pending));
2123 assert!(matches!(string_to_status("indexed"), FileStatus::Indexed));
2124 assert!(matches!(string_to_status("error"), FileStatus::Error));
2125 assert!(matches!(string_to_status("unknown"), FileStatus::Pending));
2126 }
2127
2128 #[tokio::test]
2129 async fn test_chunks_with_code_content_type() {
2130 let temp = tempdir().unwrap();
2131 let db_path = temp.path().join("test.lance");
2132 let store = LanceStore::new(db_path.clone(), TEST_DIM);
2133 store.init().await.unwrap();
2134
2135 let file_path = PathBuf::from("/test/code.rs");
2136 let chunk = Chunk {
2137 id: Uuid::new_v4(),
2138 file_id: Uuid::new_v4(),
2139 file_path: file_path.clone(),
2140 content: "fn main() {}".to_string(),
2141 content_type: ContentType::Code {
2142 language: "rust".to_string(),
2143 symbol: None,
2144 },
2145 mime_type: Some("text/x-rust".to_string()),
2146 chunk_index: 0,
2147 byte_range: 0..12,
2148 line_range: Some(0..1),
2149 parent_chunk_id: None,
2150 depth: 0,
2151 embedding: Some(create_random_embedding(TEST_DIM)),
2152 dir_path: DirectoryScope::from_file_path(&file_path).dir_path,
2153 dir_depth: DirectoryScope::from_file_path(&file_path).dir_depth,
2154 path_components: DirectoryScope::from_file_path(&file_path).path_components,
2155 metadata: ChunkMetadata::default(),
2156 };
2157
2158 store.upsert_chunks(&[chunk]).await.unwrap();
2159
2160 let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
2161 assert_eq!(chunks.len(), 1);
2162 assert!(matches!(
2163 &chunks[0].content_type,
2164 ContentType::Code { language, .. } if language == "rust"
2165 ));
2166 }
2167
2168 #[tokio::test]
2169 async fn test_get_all_files_empty() {
2170 let temp = tempdir().unwrap();
2171 let db_path = temp.path().join("test.lance");
2172 let store = LanceStore::new(db_path.clone(), TEST_DIM);
2173 store.init().await.unwrap();
2174
2175 let files = store.get_all_files().await.unwrap();
2176 assert!(files.is_empty());
2177 }
2178
2179 #[tokio::test]
2180 async fn test_delete_nonexistent_file() {
2181 let temp = tempdir().unwrap();
2182 let db_path = temp.path().join("test.lance");
2183 let store = LanceStore::new(db_path.clone(), TEST_DIM);
2184 store.init().await.unwrap();
2185
2186 let path = PathBuf::from("/nonexistent/file.txt");
2187 let result = store.delete_by_file_path(&path).await;
2189 assert!(result.is_ok());
2190 }
2191
2192 #[test]
2193 fn test_ann_threshold() {
2194 assert!(!LanceStore::should_build_ann_index(0));
2195 assert!(!LanceStore::should_build_ann_index(255));
2196 assert!(LanceStore::should_build_ann_index(256));
2197 assert!(LanceStore::should_build_ann_index(10_000));
2198 }
2199
2200 #[test]
2201 fn test_should_refresh_ann_index_after_post_threshold_appends() {
2202 assert!(LanceStore::should_build_ann_index(300));
2204 assert!(!LanceStore::should_refresh_ann_index(0));
2205 assert!(!LanceStore::should_refresh_ann_index(255));
2206 assert!(LanceStore::should_refresh_ann_index(256));
2207 assert!(LanceStore::should_refresh_ann_index(700));
2208 assert!(LanceStore::should_refresh_ann_index(9_700));
2209 }
2210
2211 #[test]
2212 fn test_ann_index_covers_cosine_only() {
2213 assert!(LanceStore::ann_index_covers_metric(DistanceMetric::Cosine));
2214 assert!(!LanceStore::ann_index_covers_metric(DistanceMetric::L2));
2215 assert!(!LanceStore::ann_index_covers_metric(DistanceMetric::Dot));
2216 assert!(LanceStore::ann_index_is_cosine(Some(DistanceType::Cosine)));
2217 assert!(!LanceStore::ann_index_is_cosine(Some(DistanceType::L2)));
2218 assert!(!LanceStore::ann_index_is_cosine(Some(DistanceType::Dot)));
2219 assert!(!LanceStore::ann_index_is_cosine(None));
2220 }
2221
2222 #[tokio::test]
2223 async fn test_small_table_skips_ann_and_still_searches() {
2224 let temp = tempdir().unwrap();
2225 let db_path = temp.path().join("test.lance");
2226 let store = LanceStore::new(db_path, TEST_DIM);
2227 store.init().await.unwrap();
2228
2229 let file_path = PathBuf::from("/test/small.txt");
2230 let chunk = create_test_chunk(
2231 &file_path,
2232 "hello ann",
2233 create_random_embedding(TEST_DIM),
2234 0,
2235 );
2236 store.upsert_chunks(&[chunk]).await.unwrap();
2237 store.ensure_vector_index().await.unwrap();
2238
2239 let results = store
2240 .search(SearchQuery {
2241 text: None,
2242 embedding: create_random_embedding(TEST_DIM),
2243 limit: 5,
2244 filters: vec![],
2245 metric: DistanceMetric::Cosine,
2246 scope_prefix: None,
2247 })
2248 .await
2249 .unwrap();
2250 assert!(!results.is_empty());
2251 }
2252
2253 #[tokio::test]
2254 async fn test_replaces_preexisting_l2_vector_index() {
2255 let temp = tempdir().unwrap();
2256 let db_path = temp.path().join("test.lance");
2257 let store = LanceStore::new(db_path, TEST_DIM);
2258 store.init().await.unwrap();
2259
2260 let file_path = PathBuf::from("/test/l2.txt");
2261 let chunks: Vec<Chunk> = (0..16)
2262 .map(|i| {
2263 create_test_chunk(
2264 &file_path,
2265 &format!("legacy l2 chunk {i}"),
2266 create_random_embedding(TEST_DIM),
2267 i,
2268 )
2269 })
2270 .collect();
2271 store.upsert_chunks(&chunks).await.unwrap();
2272
2273 let table = store.get_chunks_table().await.unwrap();
2274 let created = table
2275 .create_index(
2276 &["vector"],
2277 Index::IvfPq(
2278 IvfPqIndexBuilder::default()
2279 .distance_type(DistanceType::L2)
2280 .num_partitions(1)
2281 .sample_rate(4),
2282 ),
2283 )
2284 .execute()
2285 .await;
2286 if created.is_err() {
2287 let results = store
2289 .search(SearchQuery {
2290 text: None,
2291 embedding: create_random_embedding(TEST_DIM),
2292 limit: 5,
2293 filters: vec![],
2294 metric: DistanceMetric::Cosine,
2295 scope_prefix: None,
2296 })
2297 .await
2298 .unwrap();
2299 assert!(!results.is_empty());
2300 return;
2301 }
2302
2303 let name = LanceStore::vector_index_name(&table)
2304 .await
2305 .expect("L2 fixture index");
2306 let before = table.index_stats(&name).await.unwrap().unwrap();
2307 assert_eq!(before.distance_type, Some(DistanceType::L2));
2308
2309 store.ensure_vector_index().await.unwrap();
2310
2311 if let Some(after_name) = LanceStore::vector_index_name(&table).await {
2312 let after = table.index_stats(&after_name).await.unwrap().unwrap();
2313 assert!(
2314 LanceStore::ann_index_is_cosine(after.distance_type),
2315 "mismatched L2 index must be replaced with cosine"
2316 );
2317 } else {
2318 }
2320
2321 let results = store
2322 .search(SearchQuery {
2323 text: None,
2324 embedding: create_random_embedding(TEST_DIM),
2325 limit: 5,
2326 filters: vec![],
2327 metric: DistanceMetric::Cosine,
2328 scope_prefix: None,
2329 })
2330 .await
2331 .unwrap();
2332 assert!(!results.is_empty());
2333 }
2334
2335 #[tokio::test]
2336 async fn test_get_file_not_found() {
2337 let temp = tempdir().unwrap();
2338 let db_path = temp.path().join("test.lance");
2339 let store = LanceStore::new(db_path.clone(), TEST_DIM);
2340 store.init().await.unwrap();
2341
2342 let path = PathBuf::from("/nonexistent/file.txt");
2343 let result = store.get_file(&path).await.unwrap();
2344 assert!(result.is_none());
2345 }
2346
2347 fn create_chunk_with_meta(
2348 file_path: &Path,
2349 content: &str,
2350 embedding: Vec<f32>,
2351 content_type: ContentType,
2352 mime_type: Option<String>,
2353 depth: u8,
2354 ) -> Chunk {
2355 Chunk {
2356 id: Uuid::new_v4(),
2357 file_id: Uuid::new_v4(),
2358 file_path: file_path.to_path_buf(),
2359 content: content.to_string(),
2360 content_type,
2361 mime_type,
2362 chunk_index: 0,
2363 byte_range: 0..content.len() as u64,
2364 line_range: Some(0..1),
2365 parent_chunk_id: None,
2366 depth,
2367 embedding: Some(embedding),
2368 dir_path: DirectoryScope::from_file_path(file_path).dir_path,
2369 dir_depth: DirectoryScope::from_file_path(file_path).dir_depth,
2370 path_components: DirectoryScope::from_file_path(file_path).path_components,
2371 metadata: ChunkMetadata {
2372 indexed_at: Some(Utc::now()),
2373 embedding_model: Some("test-model".to_string()),
2374 token_count: None,
2375 extra: HashMap::new(),
2376 },
2377 }
2378 }
2379
2380 async fn seeded_filter_store() -> (tempfile::TempDir, LanceStore, Vec<f32>) {
2381 let temp = tempdir().unwrap();
2382 let db_path = temp.path().join("test.lance");
2383 let store = LanceStore::new(db_path, TEST_DIM);
2384 store.init().await.unwrap();
2385
2386 let embedding = create_random_embedding(TEST_DIM);
2387 let chunks = vec![
2388 create_chunk_with_meta(
2389 Path::new("src/lib.rs"),
2390 "fn rust_auth() {}",
2391 embedding.clone(),
2392 ContentType::Code {
2393 language: "rust".to_string(),
2394 symbol: None,
2395 },
2396 Some("text/x-rust".to_string()),
2397 0,
2398 ),
2399 create_chunk_with_meta(
2400 Path::new("src/app.py"),
2401 "def python_auth(): pass",
2402 embedding.clone(),
2403 ContentType::Code {
2404 language: "python".to_string(),
2405 symbol: None,
2406 },
2407 Some("text/x-python".to_string()),
2408 1,
2409 ),
2410 create_chunk_with_meta(
2411 Path::new("docs/readme.md"),
2412 "authentication notes",
2413 embedding.clone(),
2414 ContentType::Markdown,
2415 Some("text/markdown".to_string()),
2416 3,
2417 ),
2418 create_chunk_with_meta(
2419 Path::new("notes/plain.txt"),
2420 "plain authentication text",
2421 embedding.clone(),
2422 ContentType::Text,
2423 Some("text/plain".to_string()),
2424 2,
2425 ),
2426 ];
2427 store.upsert_chunks(&chunks).await.unwrap();
2428 (temp, store, embedding)
2429 }
2430
2431 fn search_query(
2432 embedding: Vec<f32>,
2433 filters: Vec<SearchFilter>,
2434 metric: DistanceMetric,
2435 ) -> SearchQuery {
2436 SearchQuery {
2437 text: Some("authentication".to_string()),
2438 embedding,
2439 limit: 10,
2440 filters,
2441 metric,
2442 scope_prefix: None,
2443 }
2444 }
2445
2446 #[test]
2447 fn test_filters_to_sql_language_path_type_depth() {
2448 let sql = filters_to_sql(&[
2449 SearchFilter::Language("Rust".to_string()),
2450 SearchFilter::PathPrefix("src/".to_string()),
2451 SearchFilter::MimeType("code".to_string()),
2452 SearchFilter::MaxDepth(2),
2453 ])
2454 .unwrap();
2455
2456 assert!(sql.contains("LOWER(language) = 'rust'"));
2457 assert!(sql.contains("LOWER(content_type) = 'code:rust'"));
2458 assert!(sql.contains("file_path LIKE 'src/%' ESCAPE '\\'"));
2459 assert!(sql.contains("LOWER(content_type) LIKE 'code:%'"));
2460 assert!(sql.contains("depth <= 2"));
2461 assert!(sql.contains(" AND "));
2462 }
2463
2464 #[test]
2465 fn test_filters_to_sql_path_glob_and_mime() {
2466 let sql = filters_to_sql(&[
2467 SearchFilter::PathGlob("src/**/*.rs".to_string()),
2468 SearchFilter::MimeType("text/x-rust".to_string()),
2469 SearchFilter::MinDepth(1),
2470 ])
2471 .unwrap();
2472
2473 assert!(sql.contains("regexp_like(file_path, '^src/(?:.*/)?[^/]*\\.rs$')"));
2474 assert!(sql.contains("LOWER(file_mime_type) = 'text/x-rust'"));
2475 assert!(sql.contains("depth >= 1"));
2476 }
2477
2478 #[test]
2479 fn test_filters_to_sql_escapes_quotes() {
2480 let sql = filters_to_sql(&[SearchFilter::PathPrefix("o'brien".to_string())]).unwrap();
2481 assert!(sql.contains("o''brien"));
2482 }
2483
2484 #[test]
2485 fn test_filters_to_sql_empty() {
2486 assert!(filters_to_sql(&[]).is_none());
2487 }
2488
2489 #[test]
2490 fn test_glob_to_regex_preserves_path_segments() {
2491 assert_eq!(glob_to_regex("src/*.rs"), r"^src/[^/]*\.rs$");
2492 assert_eq!(glob_to_regex("src/**/mod.rs"), r"^src/(?:.*/)?mod\.rs$");
2493 assert_eq!(glob_to_regex("src/**"), r"^src/.*$");
2494 assert_eq!(glob_to_regex("file?.txt"), r"^file[^/]\.txt$");
2495 assert!(!glob_to_regex("src/*.rs").contains(".*"));
2496 assert!(!glob_to_regex("src/**/mod.rs").contains("%mod"));
2497 }
2498
2499 #[test]
2500 fn test_file_date_filters_use_modified_at() {
2501 let after = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00+00:00")
2502 .unwrap()
2503 .with_timezone(&Utc);
2504 let sql = file_date_filters_to_sql(&[SearchFilter::ModifiedAfter(after)]).unwrap();
2505 assert!(sql.contains("modified_at >= "));
2506 assert!(!sql.contains("indexed_at"));
2507 assert!(filters_to_sql(&[SearchFilter::ModifiedAfter(after)]).is_none());
2508 }
2509
2510 #[test]
2511 fn test_distance_type_from_metric() {
2512 assert_eq!(
2513 distance_type_from_metric(DistanceMetric::Cosine),
2514 DistanceType::Cosine
2515 );
2516 assert_eq!(
2517 distance_type_from_metric(DistanceMetric::L2),
2518 DistanceType::L2
2519 );
2520 assert_eq!(
2521 distance_type_from_metric(DistanceMetric::Dot),
2522 DistanceType::Dot
2523 );
2524 }
2525
2526 #[tokio::test]
2527 async fn test_search_filters_by_language() {
2528 let (_temp, store, embedding) = seeded_filter_store().await;
2529 let results = store
2530 .search(search_query(
2531 embedding,
2532 vec![SearchFilter::Language("rust".to_string())],
2533 DistanceMetric::Cosine,
2534 ))
2535 .await
2536 .unwrap();
2537
2538 assert_eq!(results.len(), 1);
2539 assert_eq!(results[0].file_path, PathBuf::from("src/lib.rs"));
2540 }
2541
2542 #[tokio::test]
2543 async fn test_search_filters_by_path_prefix() {
2544 let (_temp, store, embedding) = seeded_filter_store().await;
2545 let results = store
2546 .search(search_query(
2547 embedding,
2548 vec![SearchFilter::PathPrefix("src/".to_string())],
2549 DistanceMetric::Cosine,
2550 ))
2551 .await
2552 .unwrap();
2553
2554 let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2555 assert_eq!(results.len(), 2);
2556 assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2557 assert!(paths.contains(&PathBuf::from("src/app.py")));
2558 assert!(!paths.contains(&PathBuf::from("docs/readme.md")));
2559 }
2560
2561 #[tokio::test]
2562 async fn test_search_filters_by_path_glob() {
2563 let (_temp, store, embedding) = seeded_filter_store().await;
2564 let results = store
2565 .search(search_query(
2566 embedding,
2567 vec![SearchFilter::PathGlob("src/**".to_string())],
2568 DistanceMetric::Cosine,
2569 ))
2570 .await
2571 .unwrap();
2572
2573 let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2574 assert_eq!(results.len(), 2);
2575 assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2576 assert!(paths.contains(&PathBuf::from("src/app.py")));
2577 }
2578
2579 #[tokio::test]
2580 async fn test_search_filters_by_type_code() {
2581 let (_temp, store, embedding) = seeded_filter_store().await;
2582 let results = store
2583 .search(search_query(
2584 embedding,
2585 vec![SearchFilter::MimeType("code".to_string())],
2586 DistanceMetric::Cosine,
2587 ))
2588 .await
2589 .unwrap();
2590
2591 let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2592 assert_eq!(results.len(), 2);
2593 assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2594 assert!(paths.contains(&PathBuf::from("src/app.py")));
2595 assert!(!paths.contains(&PathBuf::from("notes/plain.txt")));
2596 }
2597
2598 #[tokio::test]
2599 async fn test_search_filters_by_mime_type() {
2600 let (_temp, store, embedding) = seeded_filter_store().await;
2601 let results = store
2602 .search(search_query(
2603 embedding,
2604 vec![SearchFilter::MimeType("text/plain".to_string())],
2605 DistanceMetric::Cosine,
2606 ))
2607 .await
2608 .unwrap();
2609
2610 assert_eq!(results.len(), 1);
2611 assert_eq!(results[0].file_path, PathBuf::from("notes/plain.txt"));
2612 }
2613
2614 #[tokio::test]
2615 async fn test_search_filters_by_max_depth() {
2616 let (_temp, store, embedding) = seeded_filter_store().await;
2617 let results = store
2618 .search(search_query(
2619 embedding,
2620 vec![SearchFilter::MaxDepth(1)],
2621 DistanceMetric::Cosine,
2622 ))
2623 .await
2624 .unwrap();
2625
2626 let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2627 assert_eq!(results.len(), 2);
2628 assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2629 assert!(paths.contains(&PathBuf::from("src/app.py")));
2630 assert!(!paths.contains(&PathBuf::from("docs/readme.md")));
2631 assert!(!paths.contains(&PathBuf::from("notes/plain.txt")));
2632 }
2633
2634 #[tokio::test]
2635 async fn test_search_combines_filters() {
2636 let (_temp, store, embedding) = seeded_filter_store().await;
2637 let results = store
2638 .search(search_query(
2639 embedding,
2640 vec![
2641 SearchFilter::PathPrefix("src/".to_string()),
2642 SearchFilter::Language("python".to_string()),
2643 SearchFilter::MaxDepth(2),
2644 ],
2645 DistanceMetric::Cosine,
2646 ))
2647 .await
2648 .unwrap();
2649
2650 assert_eq!(results.len(), 1);
2651 assert_eq!(results[0].file_path, PathBuf::from("src/app.py"));
2652 }
2653
2654 #[tokio::test]
2655 async fn test_search_applies_l2_and_dot_metrics() {
2656 let (_temp, store, embedding) = seeded_filter_store().await;
2657 let filters = vec![SearchFilter::Language("rust".to_string())];
2658
2659 for metric in [
2660 DistanceMetric::L2,
2661 DistanceMetric::Dot,
2662 DistanceMetric::Cosine,
2663 ] {
2664 let results = store
2665 .search(search_query(embedding.clone(), filters.clone(), metric))
2666 .await
2667 .unwrap();
2668 assert_eq!(
2669 results.len(),
2670 1,
2671 "metric {metric:?} should still honor language filter"
2672 );
2673 assert_eq!(results[0].file_path, PathBuf::from("src/lib.rs"));
2674 }
2675 }
2676
2677 #[tokio::test]
2678 async fn test_hybrid_search_applies_filters_and_metric() {
2679 let (_temp, store, embedding) = seeded_filter_store().await;
2680 let results = store
2681 .hybrid_search(search_query(
2682 embedding,
2683 vec![
2684 SearchFilter::Language("rust".to_string()),
2685 SearchFilter::MimeType("code".to_string()),
2686 SearchFilter::MaxDepth(1),
2687 ],
2688 DistanceMetric::Cosine,
2689 ))
2690 .await
2691 .unwrap();
2692
2693 assert_eq!(results.len(), 1);
2694 assert_eq!(results[0].file_path, PathBuf::from("src/lib.rs"));
2695 assert!(results[0].content.contains("rust_auth"));
2696 }
2697
2698 async fn seeded_glob_boundary_store() -> (tempfile::TempDir, LanceStore, Vec<f32>) {
2699 let temp = tempdir().unwrap();
2700 let store = LanceStore::new(temp.path().join("test.lance"), TEST_DIM);
2701 store.init().await.unwrap();
2702 let embedding = create_random_embedding(TEST_DIM);
2703 store
2704 .upsert_chunks(&[
2705 create_test_chunk(Path::new("src/lib.rs"), "lib", embedding.clone(), 0),
2706 create_test_chunk(
2707 Path::new("src/nested/file.rs"),
2708 "nested",
2709 embedding.clone(),
2710 0,
2711 ),
2712 create_test_chunk(Path::new("src/mod.rs"), "mod file", embedding.clone(), 0),
2713 create_test_chunk(Path::new("src/notmod.rs"), "not mod", embedding.clone(), 0),
2714 ])
2715 .await
2716 .unwrap();
2717 (temp, store, embedding)
2718 }
2719
2720 #[tokio::test]
2721 async fn test_search_path_glob_star_does_not_cross_slash() {
2722 let (_temp, store, embedding) = seeded_glob_boundary_store().await;
2723 let results = store
2724 .search(search_query(
2725 embedding,
2726 vec![SearchFilter::PathGlob("src/*.rs".to_string())],
2727 DistanceMetric::Cosine,
2728 ))
2729 .await
2730 .unwrap();
2731
2732 let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2733 assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2734 assert!(paths.contains(&PathBuf::from("src/mod.rs")));
2735 assert!(paths.contains(&PathBuf::from("src/notmod.rs")));
2736 assert!(!paths.contains(&PathBuf::from("src/nested/file.rs")));
2737 }
2738
2739 #[tokio::test]
2740 async fn test_search_path_glob_doublestar_keeps_separator() {
2741 let (_temp, store, embedding) = seeded_glob_boundary_store().await;
2742 let results = store
2743 .search(search_query(
2744 embedding,
2745 vec![SearchFilter::PathGlob("src/**/mod.rs".to_string())],
2746 DistanceMetric::Cosine,
2747 ))
2748 .await
2749 .unwrap();
2750
2751 let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2752 assert_eq!(results.len(), 1);
2753 assert!(paths.contains(&PathBuf::from("src/mod.rs")));
2754 assert!(!paths.contains(&PathBuf::from("src/notmod.rs")));
2755 assert!(!paths.contains(&PathBuf::from("src/lib.rs")));
2756 }
2757
2758 #[tokio::test]
2759 async fn test_search_filters_by_source_modified_at() {
2760 let temp = tempdir().unwrap();
2761 let store = LanceStore::new(temp.path().join("test.lance"), TEST_DIM);
2762 store.init().await.unwrap();
2763 let embedding = create_random_embedding(TEST_DIM);
2764
2765 let old_path = PathBuf::from("old.txt");
2766 let new_path = PathBuf::from("new.txt");
2767 store
2768 .upsert_chunks(&[
2769 create_test_chunk(&old_path, "old file", embedding.clone(), 0),
2770 create_test_chunk(&new_path, "new file", embedding.clone(), 0),
2771 ])
2772 .await
2773 .unwrap();
2774
2775 let old_modified = chrono::DateTime::parse_from_rfc3339("2020-01-01T00:00:00+00:00")
2776 .unwrap()
2777 .with_timezone(&Utc);
2778 let new_modified = chrono::DateTime::parse_from_rfc3339("2024-06-01T00:00:00+00:00")
2779 .unwrap()
2780 .with_timezone(&Utc);
2781 let cutoff = chrono::DateTime::parse_from_rfc3339("2022-01-01T00:00:00+00:00")
2782 .unwrap()
2783 .with_timezone(&Utc);
2784
2785 let mut old_record = create_test_file_record(&old_path);
2786 old_record.modified_at = old_modified;
2787 let mut new_record = create_test_file_record(&new_path);
2788 new_record.modified_at = new_modified;
2789 store.upsert_file(&old_record).await.unwrap();
2790 store.upsert_file(&new_record).await.unwrap();
2791
2792 let results = store
2793 .search(search_query(
2794 embedding,
2795 vec![SearchFilter::ModifiedAfter(cutoff)],
2796 DistanceMetric::Cosine,
2797 ))
2798 .await
2799 .unwrap();
2800
2801 assert_eq!(results.len(), 1);
2802 assert_eq!(results[0].file_path, new_path);
2803 }
2804
2805 fn create_chunk_with_scope(
2806 file_path: &Path,
2807 root: Option<&Path>,
2808 content: &str,
2809 embedding: Vec<f32>,
2810 ) -> Chunk {
2811 let scope = DirectoryScope::from_paths(file_path, root);
2812 let mut chunk = create_test_chunk(file_path, content, embedding, 0);
2813 chunk.dir_path = scope.dir_path;
2814 chunk.dir_depth = scope.dir_depth;
2815 chunk.path_components = scope.path_components;
2816 chunk
2817 }
2818
2819 async fn seeded_scope_store() -> (tempfile::TempDir, LanceStore, Vec<f32>) {
2820 let temp = tempdir().unwrap();
2821 let db_path = temp.path().join("test.lance");
2822 let store = LanceStore::new(db_path, TEST_DIM);
2823 store.init().await.unwrap();
2824
2825 let root = Path::new("/project");
2826 let embedding = create_random_embedding(TEST_DIM);
2827 let chunks = vec![
2828 create_chunk_with_scope(
2829 Path::new("/project/src/auth/login.rs"),
2830 Some(root),
2831 "login handler",
2832 embedding.clone(),
2833 ),
2834 create_chunk_with_scope(
2835 Path::new("/project/src/auth/oauth/token.rs"),
2836 Some(root),
2837 "oauth token",
2838 embedding.clone(),
2839 ),
2840 create_chunk_with_scope(
2841 Path::new("/project/src/db.rs"),
2842 Some(root),
2843 "database pool",
2844 embedding.clone(),
2845 ),
2846 create_chunk_with_scope(
2847 Path::new("/project/docs/readme.md"),
2848 Some(root),
2849 "project readme",
2850 embedding.clone(),
2851 ),
2852 ];
2853 store.upsert_chunks(&chunks).await.unwrap();
2854 (temp, store, embedding)
2855 }
2856
2857 fn scoped_query(embedding: Vec<f32>, scope: &str) -> SearchQuery {
2858 SearchQuery {
2859 text: None,
2860 embedding,
2861 limit: 10,
2862 filters: vec![],
2863 metric: DistanceMetric::Cosine,
2864 scope_prefix: Some(scope.to_string()),
2865 }
2866 }
2867
2868 #[test]
2869 fn test_scope_prefix_sql_exact_or_subdirectory() {
2870 let sql = scope_prefix_sql("src/auth/").unwrap();
2871 assert!(sql.contains("dir_path = 'src/auth'"));
2872 assert!(sql.contains("dir_path LIKE 'src/auth/%'"));
2873 assert!(sql.contains("dir_path LIKE '%/src/auth'"));
2874 assert!(sql.contains("dir_path LIKE '%/src/auth/%'"));
2875 assert!(scope_prefix_sql("").is_none());
2876 assert!(scope_prefix_sql(".").is_none());
2877 }
2878
2879 #[test]
2880 fn test_scope_prefix_sql_escapes_quotes() {
2881 let sql = scope_prefix_sql("o'brien").unwrap();
2882 assert!(sql.contains("o''brien"));
2883 }
2884
2885 #[tokio::test]
2886 async fn test_scoped_search_stores_relative_dir_path() {
2887 let (_temp, store, _) = seeded_scope_store().await;
2888 let chunks = store
2889 .get_chunks_for_file(Path::new("/project/src/auth/login.rs"))
2890 .await
2891 .unwrap();
2892 assert_eq!(chunks.len(), 1);
2893 assert_eq!(chunks[0].dir_path, "src/auth");
2894 assert_eq!(chunks[0].dir_depth, 2);
2895 assert_eq!(chunks[0].path_components, "src,auth,login.rs");
2896 assert!(!chunks[0].dir_path.starts_with('/'));
2897 }
2898
2899 #[tokio::test]
2900 async fn test_scoped_search_exact_directory() {
2901 let (_temp, store, embedding) = seeded_scope_store().await;
2902 let results = store
2903 .search(scoped_query(embedding, "src/auth"))
2904 .await
2905 .unwrap();
2906 let paths: Vec<_> = results
2907 .iter()
2908 .map(|r| r.file_path.to_string_lossy().to_string())
2909 .collect();
2910 assert!(paths.iter().any(|p| p.ends_with("src/auth/login.rs")));
2911 assert!(paths.iter().any(|p| p.ends_with("src/auth/oauth/token.rs")));
2912 assert!(!paths.iter().any(|p| p.ends_with("src/db.rs")));
2913 assert!(!paths.iter().any(|p| p.ends_with("docs/readme.md")));
2914 }
2915
2916 #[tokio::test]
2917 async fn test_scoped_search_includes_subdirectory() {
2918 let (_temp, store, embedding) = seeded_scope_store().await;
2919 let results = store.search(scoped_query(embedding, "src")).await.unwrap();
2920 let paths: Vec<_> = results
2921 .iter()
2922 .map(|r| r.file_path.to_string_lossy().to_string())
2923 .collect();
2924 assert!(paths.iter().any(|p| p.ends_with("src/auth/login.rs")));
2925 assert!(paths.iter().any(|p| p.ends_with("src/auth/oauth/token.rs")));
2926 assert!(paths.iter().any(|p| p.ends_with("src/db.rs")));
2927 assert!(!paths.iter().any(|p| p.ends_with("docs/readme.md")));
2928 }
2929
2930 #[tokio::test]
2931 async fn test_scoped_search_excludes_outside_directory() {
2932 let (_temp, store, embedding) = seeded_scope_store().await;
2933 let results = store.search(scoped_query(embedding, "docs")).await.unwrap();
2934 assert_eq!(results.len(), 1);
2935 assert!(
2936 results[0]
2937 .file_path
2938 .to_string_lossy()
2939 .ends_with("docs/readme.md")
2940 );
2941 }
2942
2943 fn v1_chunks_schema(dim: usize) -> Schema {
2944 Schema::new(vec![
2945 Field::new("chunk_id", DataType::Utf8, false),
2946 Field::new("file_id", DataType::Utf8, false),
2947 Field::new("file_path", DataType::Utf8, false),
2948 Field::new("content", DataType::Utf8, false),
2949 Field::new("content_type", DataType::Utf8, false),
2950 Field::new("chunk_index", DataType::UInt32, false),
2951 Field::new("start_byte", DataType::UInt64, false),
2952 Field::new("end_byte", DataType::UInt64, false),
2953 Field::new("start_line", DataType::UInt32, true),
2954 Field::new("end_line", DataType::UInt32, true),
2955 Field::new("parent_chunk_id", DataType::Utf8, true),
2956 Field::new("depth", DataType::UInt8, false),
2957 Field::new(
2958 "vector",
2959 DataType::FixedSizeList(
2960 Arc::new(Field::new("item", DataType::Float32, true)),
2961 dim as i32,
2962 ),
2963 false,
2964 ),
2965 Field::new("embedding_model", DataType::Utf8, true),
2966 Field::new("indexed_at", DataType::Utf8, false),
2967 Field::new("file_mime_type", DataType::Utf8, true),
2968 Field::new("language", DataType::Utf8, true),
2969 Field::new("symbol_type", DataType::Utf8, true),
2970 Field::new("symbol_name", DataType::Utf8, true),
2971 ])
2972 }
2973
2974 async fn seed_v1_chunks_table(
2975 db_path: &Path,
2976 file_path: &str,
2977 content: &str,
2978 embedding: &[f32],
2979 ) {
2980 let conn = connect(db_path.to_str().unwrap()).execute().await.unwrap();
2981 let schema = Arc::new(v1_chunks_schema(TEST_DIM));
2982 conn.create_empty_table(CHUNKS_TABLE, schema.clone())
2983 .execute()
2984 .await
2985 .unwrap();
2986
2987 let embeddings = vec![Some(embedding.iter().copied().map(Some).collect())];
2988 let vector = build_vector_array(&embeddings, TEST_DIM).unwrap();
2989 let batch = RecordBatch::try_new(
2990 schema,
2991 vec![
2992 Arc::new(StringArray::from(vec![Uuid::new_v4().to_string()])),
2993 Arc::new(StringArray::from(vec![Uuid::new_v4().to_string()])),
2994 Arc::new(StringArray::from(vec![file_path.to_string()])),
2995 Arc::new(StringArray::from(vec![content.to_string()])),
2996 Arc::new(StringArray::from(vec!["text".to_string()])),
2997 Arc::new(UInt32Array::from(vec![0u32])),
2998 Arc::new(UInt64Array::from(vec![0u64])),
2999 Arc::new(UInt64Array::from(vec![content.len() as u64])),
3000 Arc::new(UInt32Array::from(vec![Some(0u32)])),
3001 Arc::new(UInt32Array::from(vec![Some(1u32)])),
3002 Arc::new(StringArray::from(vec![None::<String>])),
3003 Arc::new(UInt8Array::from(vec![0u8])),
3004 vector,
3005 Arc::new(StringArray::from(vec![Some("test-model".to_string())])),
3006 Arc::new(StringArray::from(vec![Utc::now().to_rfc3339()])),
3007 Arc::new(StringArray::from(vec![Some("text/plain".to_string())])),
3008 Arc::new(StringArray::from(vec![None::<String>])),
3009 Arc::new(StringArray::from(vec![None::<String>])),
3010 Arc::new(StringArray::from(vec![None::<String>])),
3011 ],
3012 )
3013 .unwrap();
3014
3015 let table = conn.open_table(CHUNKS_TABLE).execute().await.unwrap();
3016 let schema = batch.schema();
3017 let batches = RecordBatchIterator::new(vec![Ok(batch)], schema);
3018 table
3019 .add(Box::new(batches) as Box<dyn RecordBatchReader + Send>)
3020 .execute()
3021 .await
3022 .unwrap();
3023 }
3024
3025 #[tokio::test]
3026 async fn test_schema_v2_migrates_existing_index() {
3027 let temp = tempdir().unwrap();
3028 let db_path = temp.path().join("test.lance");
3029 let file_path = "/project/src/auth/login.rs";
3030 let embedding = create_random_embedding(TEST_DIM);
3031 seed_v1_chunks_table(&db_path, file_path, "login handler", &embedding).await;
3032
3033 assert!(!temp.path().join(SCHEMA_SIDECAR_FILENAME).exists());
3034
3035 let store = LanceStore::new(db_path, TEST_DIM);
3036 store.init().await.unwrap();
3037
3038 let sidecar: SchemaSidecar = serde_json::from_slice(
3039 &std::fs::read(temp.path().join(SCHEMA_SIDECAR_FILENAME)).unwrap(),
3040 )
3041 .unwrap();
3042 assert_eq!(sidecar.chunks_schema_version, CHUNKS_SCHEMA_VERSION);
3043 assert_eq!(sidecar.embedding_dim, Some(TEST_DIM));
3044
3045 let chunks = store
3046 .get_chunks_for_file(Path::new(file_path))
3047 .await
3048 .unwrap();
3049 assert_eq!(chunks.len(), 1);
3050 assert_eq!(chunks[0].dir_path, "project/src/auth");
3051 assert_eq!(chunks[0].dir_depth, 3);
3052 assert_eq!(chunks[0].path_components, "project,src,auth,login.rs");
3053
3054 let results = store
3055 .search(scoped_query(embedding, "src/auth"))
3056 .await
3057 .unwrap();
3058 assert_eq!(results.len(), 1);
3059 assert!(
3060 results[0]
3061 .file_path
3062 .to_string_lossy()
3063 .ends_with("src/auth/login.rs")
3064 );
3065
3066 store.init().await.unwrap();
3068 let again = store
3069 .get_chunks_for_file(Path::new(file_path))
3070 .await
3071 .unwrap();
3072 assert_eq!(again[0].dir_path, "project/src/auth");
3073 }
3074
3075 #[tokio::test]
3076 async fn test_new_index_writes_schema_v2_sidecar() {
3077 let temp = tempdir().unwrap();
3078 let db_path = temp.path().join("test.lance");
3079 let store = LanceStore::new(db_path, TEST_DIM).with_embedding_model("thenlper/gte-small");
3080 store.init().await.unwrap();
3081
3082 let sidecar: SchemaSidecar = serde_json::from_slice(
3083 &std::fs::read(temp.path().join(SCHEMA_SIDECAR_FILENAME)).unwrap(),
3084 )
3085 .unwrap();
3086 assert_eq!(sidecar.chunks_schema_version, CHUNKS_SCHEMA_VERSION);
3087 assert_eq!(sidecar.embedding_dim, Some(TEST_DIM));
3088 assert_eq!(
3089 sidecar.embedding_model.as_deref(),
3090 Some("thenlper/gte-small")
3091 );
3092 }
3093}