@@ -785,6 +785,42 @@ fn process_streaming_line(
785785 }
786786}
787787
788+ /// Name of the metadata file (inside `.ck`) recording the corpus fingerprint
789+ /// the tantivy index was built from, so staleness is detectable.
790+ const TANTIVY_META_FILE : & str = "tantivy_index.meta" ;
791+
792+ /// Fingerprint of the file set a tantivy index covers: path, mtime and size
793+ /// of every corpus file. Any added, removed, or modified file changes the
794+ /// fingerprint, as does a different exclude-pattern set (it changes the
795+ /// collected file list).
796+ fn lexical_corpus_fingerprint ( files : & [ PathBuf ] ) -> String {
797+ let mut entries: Vec < String > = files
798+ . iter ( )
799+ . map ( |f| {
800+ let ( mtime, size) = fs:: metadata ( f)
801+ . map ( |m| {
802+ let mtime = m
803+ . modified ( )
804+ . ok ( )
805+ . and_then ( |t| t. duration_since ( std:: time:: UNIX_EPOCH ) . ok ( ) )
806+ . map ( |d| d. as_nanos ( ) )
807+ . unwrap_or ( 0 ) ;
808+ ( mtime, m. len ( ) )
809+ } )
810+ . unwrap_or ( ( 0 , 0 ) ) ;
811+ format ! ( "{}\x00 {}\x00 {}" , f. display( ) , mtime, size)
812+ } )
813+ . collect ( ) ;
814+ entries. sort_unstable ( ) ;
815+
816+ let mut hasher = blake3:: Hasher :: new ( ) ;
817+ for entry in & entries {
818+ hasher. update ( entry. as_bytes ( ) ) ;
819+ hasher. update ( b"\n " ) ;
820+ }
821+ hasher. finalize ( ) . to_hex ( ) . to_string ( )
822+ }
823+
788824async fn lexical_search ( options : & SearchOptions ) -> Result < Vec < SearchResult > > {
789825 // Handle both files and directories and reuse nearest existing .ck index up the tree
790826 let index_root = find_nearest_index_root ( & options. path ) . unwrap_or_else ( || {
@@ -802,8 +838,47 @@ async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
802838
803839 let tantivy_index_path = index_dir. join ( "tantivy_index" ) ;
804840
805- if !tantivy_index_path. exists ( ) {
806- return build_tantivy_index ( options) . await ;
841+ // The tantivy index always covers the whole index root (include patterns
842+ // are applied per result at search time below), so corpus membership only
843+ // depends on the root and the exclusion rules.
844+ //
845+ // Collection goes through ck_index::collect_files — the same walker the
846+ // regex and semantic paths use — so gitignore/.ckignore semantics match
847+ // and exclude patterns apply relative to the walk root. The engine-local
848+ // collect_files matched exclude globs against every *absolute* path
849+ // component, so a corpus under e.g. /tmp on Linux matched the default
850+ // "tmp" exclude and silently produced an empty lexical index.
851+ let file_options = ck_core:: FileCollectionOptions {
852+ respect_gitignore : options. respect_gitignore ,
853+ use_ckignore : options. use_ckignore ,
854+ exclude_patterns : options. exclude_patterns . clone ( ) ,
855+ } ;
856+ let corpus = ck_index:: collect_files ( & index_root, & file_options) ?;
857+ let fingerprint = lexical_corpus_fingerprint ( & corpus) ;
858+ let meta_path = index_dir. join ( TANTIVY_META_FILE ) ;
859+ let is_fresh = tantivy_index_path. exists ( )
860+ && fs:: read_to_string ( & meta_path)
861+ . map ( |stored| stored. trim ( ) == fingerprint)
862+ . unwrap_or ( false ) ;
863+
864+ if !is_fresh {
865+ // Serialize with index mutations (and concurrent lexical rebuilds);
866+ // re-check freshness after acquiring in case another process just
867+ // rebuilt the same corpus.
868+ let _lock = ck_index:: acquire_index_write_lock ( & index_dir) ?;
869+ let still_stale = !tantivy_index_path. exists ( )
870+ || fs:: read_to_string ( & meta_path)
871+ . map ( |stored| stored. trim ( ) != fingerprint)
872+ . unwrap_or ( true ) ;
873+ if still_stale {
874+ tracing:: info!(
875+ "Lexical index stale or missing for {}; rebuilding from {} files" ,
876+ index_root. display( ) ,
877+ corpus. len( )
878+ ) ;
879+ build_tantivy_index ( & tantivy_index_path, & corpus) ?;
880+ fs:: write ( & meta_path, & fingerprint) ?;
881+ }
807882 }
808883
809884 let mut schema_builder = Schema :: builder ( ) ;
@@ -903,37 +978,33 @@ async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
903978 Ok ( results)
904979}
905980
906- async fn build_tantivy_index ( options : & SearchOptions ) -> Result < Vec < SearchResult > > {
907- // Handle both files and directories by finding the appropriate directory for indexing
908- let index_root = if options. path . is_file ( ) {
909- options. path . parent ( ) . unwrap_or ( & options. path )
910- } else {
911- & options. path
912- } ;
913-
914- let index_dir = index_root. join ( ".ck" ) ;
915- let tantivy_index_path = index_dir. join ( "tantivy_index" ) ;
916-
917- fs:: create_dir_all ( & tantivy_index_path) ?;
981+ /// (Re)build the tantivy index at `tantivy_index_path` over `files`.
982+ /// Callers must hold the index write lock. Any existing index is replaced —
983+ /// tantivy has no cheap way to diff segments against a changed corpus, and a
984+ /// full text-only rebuild is fast relative to embedding work.
985+ ///
986+ /// Searching the result happens in [`lexical_search`]; this function builds
987+ /// only (its previous incarnation duplicated the entire search/read path,
988+ /// which had already drifted — the rebuilt-path copy lost include filtering).
989+ fn build_tantivy_index ( tantivy_index_path : & Path , files : & [ PathBuf ] ) -> Result < ( ) > {
990+ if tantivy_index_path. exists ( ) {
991+ fs:: remove_dir_all ( tantivy_index_path) ?;
992+ }
993+ fs:: create_dir_all ( tantivy_index_path) ?;
918994
919995 let mut schema_builder = Schema :: builder ( ) ;
920996 let content_field = schema_builder. add_text_field ( "content" , TEXT | STORED ) ;
921997 let path_field = schema_builder. add_text_field ( "path" , TEXT | STORED ) ;
922998 let schema = schema_builder. build ( ) ;
923999
924- let index = Index :: create_in_dir ( & tantivy_index_path, schema. clone ( ) )
1000+ let index = Index :: create_in_dir ( tantivy_index_path, schema)
9251001 . map_err ( |e| CkError :: Index ( format ! ( "Failed to create tantivy index: {e}" ) ) ) ?;
9261002
9271003 let mut index_writer = index
9281004 . writer ( 50_000_000 )
9291005 . map_err ( |e| CkError :: Index ( format ! ( "Failed to create index writer: {e}" ) ) ) ?;
9301006
931- let files = filter_files_by_include (
932- collect_files ( index_root, true , & options. exclude_patterns ) ?,
933- & options. include_patterns ,
934- ) ;
935-
936- for file_path in & files {
1007+ for file_path in files {
9371008 if let Ok ( content) = fs:: read_to_string ( file_path) {
9381009 let doc = doc ! (
9391010 content_field => content,
@@ -947,100 +1018,7 @@ async fn build_tantivy_index(options: &SearchOptions) -> Result<Vec<SearchResult
9471018 . commit ( )
9481019 . map_err ( |e| CkError :: Index ( format ! ( "Failed to commit index: {e}" ) ) ) ?;
9491020
950- // After building, search again with the same options
951- let tantivy_index_path = index_root. join ( ".ck" ) . join ( "tantivy_index" ) ;
952- let mut schema_builder = Schema :: builder ( ) ;
953- let content_field = schema_builder. add_text_field ( "content" , TEXT | STORED ) ;
954- let path_field = schema_builder. add_text_field ( "path" , TEXT | STORED ) ;
955- let _schema = schema_builder. build ( ) ;
956-
957- let index = Index :: open_in_dir ( & tantivy_index_path)
958- . map_err ( |e| CkError :: Index ( format ! ( "Failed to open tantivy index: {e}" ) ) ) ?;
959-
960- let reader = index
961- . reader_builder ( )
962- . reload_policy ( ReloadPolicy :: OnCommitWithDelay )
963- . try_into ( )
964- . map_err ( |e| CkError :: Index ( format ! ( "Failed to create index reader: {e}" ) ) ) ?;
965-
966- let searcher = reader. searcher ( ) ;
967- let query_parser = QueryParser :: for_index ( & index, vec ! [ content_field] ) ;
968-
969- let query = query_parser
970- . parse_query ( & options. query )
971- . map_err ( |e| CkError :: Search ( format ! ( "Failed to parse query: {e}" ) ) ) ?;
972-
973- let top_docs = if let Some ( top_k) = options. top_k {
974- searcher. search ( & query, & TopDocs :: with_limit ( top_k) ) ?
975- } else {
976- searcher. search ( & query, & TopDocs :: with_limit ( 100 ) ) ?
977- } ;
978-
979- // First, collect all results with raw scores
980- let mut raw_results = Vec :: new ( ) ;
981- for ( _score, doc_address) in top_docs {
982- let retrieved_doc: TantivyDocument = searcher. doc ( doc_address) ?;
983- let path_text = retrieved_doc
984- . get_first ( path_field)
985- . map ( |field_value| field_value. as_str ( ) . unwrap_or ( "" ) )
986- . unwrap_or ( "" ) ;
987- let content_text = retrieved_doc
988- . get_first ( content_field)
989- . map ( |field_value| field_value. as_str ( ) . unwrap_or ( "" ) )
990- . unwrap_or ( "" ) ;
991-
992- let file_path = PathBuf :: from ( path_text) ;
993- let preview = if options. full_section {
994- content_text. to_string ( )
995- } else {
996- content_text. lines ( ) . take ( 3 ) . collect :: < Vec < _ > > ( ) . join ( "\n " )
997- } ;
998-
999- raw_results. push ( (
1000- _score,
1001- SearchResult {
1002- file : file_path,
1003- span : Span {
1004- byte_start : 0 ,
1005- byte_end : content_text. len ( ) ,
1006- line_start : 1 ,
1007- line_end : content_text. lines ( ) . count ( ) ,
1008- } ,
1009- score : _score,
1010- preview,
1011- lang : ck_core:: Language :: from_path ( & PathBuf :: from ( path_text) ) ,
1012- symbol : None ,
1013- chunk_hash : None ,
1014- index_epoch : None ,
1015- } ,
1016- ) ) ;
1017- }
1018-
1019- // Normalize scores to 0-1 range and apply threshold
1020- let mut results = Vec :: new ( ) ;
1021- if !raw_results. is_empty ( ) {
1022- let max_score = raw_results
1023- . iter ( )
1024- . map ( |( score, _) | * score)
1025- . fold ( 0.0f32 , f32:: max) ;
1026- if max_score > 0.0 {
1027- for ( raw_score, mut result) in raw_results {
1028- let normalized_score = raw_score / max_score;
1029-
1030- // Apply threshold filtering with normalized score
1031- if let Some ( threshold) = options. threshold
1032- && normalized_score < threshold
1033- {
1034- continue ;
1035- }
1036-
1037- result. score = normalized_score;
1038- results. push ( result) ;
1039- }
1040- }
1041- }
1042-
1043- Ok ( results)
1021+ Ok ( ( ) )
10441022}
10451023
10461024#[ allow( dead_code) ]
@@ -1595,6 +1573,34 @@ mod tests {
15951573 assert ! ( ( fused[ 0 ] . score - expected) . abs( ) < 1e-6 ) ;
15961574 }
15971575
1576+ #[ test]
1577+ fn test_lexical_corpus_fingerprint_tracks_changes ( ) {
1578+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1579+ let a = temp_dir. path ( ) . join ( "a.txt" ) ;
1580+ let b = temp_dir. path ( ) . join ( "b.txt" ) ;
1581+ fs:: write ( & a, "one" ) . unwrap ( ) ;
1582+ fs:: write ( & b, "two" ) . unwrap ( ) ;
1583+
1584+ let original = lexical_corpus_fingerprint ( & [ a. clone ( ) , b. clone ( ) ] ) ;
1585+
1586+ // Order-insensitive
1587+ assert_eq ! (
1588+ original,
1589+ lexical_corpus_fingerprint( & [ b. clone( ) , a. clone( ) ] )
1590+ ) ;
1591+
1592+ // Content change (different size) changes the fingerprint
1593+ fs:: write ( & a, "one but longer" ) . unwrap ( ) ;
1594+ assert_ne ! (
1595+ original,
1596+ lexical_corpus_fingerprint( & [ a. clone( ) , b. clone( ) ] )
1597+ ) ;
1598+
1599+ // Removing a file changes the fingerprint
1600+ let shrunk = lexical_corpus_fingerprint ( std:: slice:: from_ref ( & a) ) ;
1601+ assert_ne ! ( shrunk, lexical_corpus_fingerprint( & [ a, b] ) ) ;
1602+ }
1603+
15981604 fn create_test_files ( dir : & std:: path:: Path ) -> Vec < PathBuf > {
15991605 let files = vec ! [
16001606 ( "test1.txt" , "hello world rust programming" ) ,
0 commit comments