1use super::catalog::ChunkCatalogSnapshot;
2use super::types::{
3 WorkspaceChunk, WorkspaceIndexError, WorkspaceIndexResult, WorkspaceLexicalEngine,
4};
5use crate::workspace::WorkspacePath;
6use rayon::prelude::*;
7use std::collections::{HashMap, HashSet};
8use std::mem::size_of;
9use std::sync::Arc;
10
11const DEFAULT_QUERY_TERM_LIMIT: usize = 32;
12const DEFAULT_CANDIDATE_FILE_LIMIT: usize = 256;
13const DEFAULT_RESULT_LIMIT: usize = 10;
14const MAX_RESULT_LIMIT: usize = 25;
15const MAX_QUERY_BYTES: usize = 2_048;
16const DEFAULT_RESULTS_PER_FILE: usize = 2;
17const PARALLEL_BUILD_MIN_DOCUMENTS: usize = 128;
18const PARALLEL_BUILD_MIN_BYTES: usize = 64 * 1024;
19
20pub(crate) fn should_parallelize_build(document_count: usize, text_bytes: usize) -> bool {
21 document_count >= PARALLEL_BUILD_MIN_DOCUMENTS
22 && text_bytes >= PARALLEL_BUILD_MIN_BYTES
23 && std::thread::available_parallelism()
24 .map(|workers| workers.get() > 1)
25 .unwrap_or(true)
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct LexicalSearchRequest {
30 pub query: String,
31 pub path: WorkspacePath,
32 pub glob: Option<String>,
33 pub limit: usize,
34 pub max_candidate_files: usize,
35 pub max_results_per_file: usize,
36}
37
38impl LexicalSearchRequest {
39 pub fn new(query: impl Into<String>) -> Self {
40 Self {
41 query: query.into(),
42 path: WorkspacePath::root(),
43 glob: None,
44 limit: DEFAULT_RESULT_LIMIT,
45 max_candidate_files: DEFAULT_CANDIDATE_FILE_LIMIT,
46 max_results_per_file: DEFAULT_RESULTS_PER_FILE,
47 }
48 }
49}
50
51#[derive(Clone, Debug)]
52pub struct LexicalSearchHit {
53 pub chunk: Arc<WorkspaceChunk>,
54 pub score: f64,
55}
56
57#[derive(Clone, Debug)]
58pub struct LexicalSearchResult {
59 pub catalog_revision: u64,
60 pub source_revision: u64,
61 pub lexical_engine: WorkspaceLexicalEngine,
62 pub query_terms: Vec<String>,
63 pub matching_files: usize,
64 pub selected_files: usize,
65 pub scored_chunks: usize,
66 pub candidate_truncated: bool,
67 pub hits: Vec<LexicalSearchHit>,
68}
69
70#[derive(Debug, Clone)]
72struct PortableDocument {
73 term_frequencies: HashMap<String, u32>,
74 length: usize,
75}
76
77impl PortableDocument {
78 fn from_tokens(tokens: &[String]) -> Self {
79 let mut term_frequencies = HashMap::new();
80 for token in tokens {
81 *term_frequencies.entry(token.clone()).or_insert(0) += 1;
82 }
83 Self {
84 term_frequencies,
85 length: tokens.len(),
86 }
87 }
88}
89
90#[derive(Debug, Clone)]
91struct PortablePosting {
92 document: usize,
93 term_frequency: u32,
94}
95
96pub(crate) struct PortableLexicalIndex {
100 documents: Vec<PortableDocument>,
101 postings: HashMap<String, Vec<PortablePosting>>,
102 terms: HashSet<String>,
103 estimated_bytes: usize,
104}
105
106impl PortableLexicalIndex {
107 fn build(documents: &[(String, String)]) -> WorkspaceIndexResult<Self> {
108 let mut seen_keys = HashSet::new();
109 for (key, _) in documents {
110 if key.is_empty() || key.contains('\0') {
111 return Err(WorkspaceIndexError::InvalidConfig(
112 "lexical document key must be non-empty and contain no NUL byte".to_owned(),
113 ));
114 }
115 if !seen_keys.insert(key) {
116 return Err(WorkspaceIndexError::InvalidConfig(
117 "lexical document keys must be unique".to_owned(),
118 ));
119 }
120 }
121 let parallel = should_parallelize_build(
127 documents.len(),
128 documents
129 .iter()
130 .fold(0usize, |total, (_, text)| total.saturating_add(text.len())),
131 );
132 let tokenized = if parallel {
133 documents
134 .par_iter()
135 .filter_map(|(_, text)| {
136 let tokens = tokenize(text);
137 (!tokens.is_empty()).then_some(tokens)
138 })
139 .collect::<Vec<_>>()
140 } else {
141 documents
142 .iter()
143 .filter_map(|(_, text)| {
144 let tokens = tokenize(text);
145 (!tokens.is_empty()).then_some(tokens)
146 })
147 .collect::<Vec<_>>()
148 };
149 let indexed = if parallel {
150 tokenized
151 .par_iter()
152 .map(|tokens| PortableDocument::from_tokens(tokens))
153 .collect::<Vec<_>>()
154 } else {
155 tokenized
156 .iter()
157 .map(|tokens| PortableDocument::from_tokens(tokens))
158 .collect::<Vec<_>>()
159 };
160 let terms = if parallel {
161 tokenized
162 .par_iter()
163 .flat_map_iter(|tokens| tokens.iter().cloned())
164 .collect::<HashSet<_>>()
165 } else {
166 tokenized
167 .iter()
168 .flat_map(|tokens| tokens.iter().cloned())
169 .collect::<HashSet<_>>()
170 };
171 let mut postings = if parallel {
172 indexed
173 .par_iter()
174 .enumerate()
175 .fold(
176 HashMap::<String, Vec<PortablePosting>>::new,
177 |mut postings, (document, stats)| {
178 for (term, frequency) in &stats.term_frequencies {
179 postings
180 .entry(term.clone())
181 .or_default()
182 .push(PortablePosting {
183 document,
184 term_frequency: *frequency,
185 });
186 }
187 postings
188 },
189 )
190 .reduce(
191 HashMap::<String, Vec<PortablePosting>>::new,
192 |mut left, mut right| {
193 for (term, mut values) in right.drain() {
194 left.entry(term).or_default().append(&mut values);
195 }
196 left
197 },
198 )
199 } else {
200 let mut postings = HashMap::<String, Vec<PortablePosting>>::new();
201 for (document, stats) in indexed.iter().enumerate() {
202 for (term, frequency) in &stats.term_frequencies {
203 postings
204 .entry(term.clone())
205 .or_default()
206 .push(PortablePosting {
207 document,
208 term_frequency: *frequency,
209 });
210 }
211 }
212 postings
213 };
214 if parallel {
215 for values in postings.values_mut() {
219 values.sort_unstable_by_key(|posting| posting.document);
220 }
221 }
222 let estimated_bytes = size_of::<Self>()
223 .saturating_add(indexed.len().saturating_mul(size_of::<PortableDocument>()))
224 .saturating_add(
225 indexed
226 .iter()
227 .flat_map(|document| document.term_frequencies.keys())
228 .map(|term| term.capacity())
229 .sum::<usize>(),
230 )
231 .saturating_add(
232 postings
233 .iter()
234 .map(|(term, values)| {
235 term.capacity().saturating_add(
236 values.len().saturating_mul(size_of::<PortablePosting>()),
237 )
238 })
239 .sum::<usize>(),
240 );
241 Ok(Self {
242 documents: indexed,
243 postings,
244 terms,
245 estimated_bytes,
246 })
247 }
248
249 fn document_count(&self) -> usize {
250 self.documents.len()
251 }
252
253 fn estimated_bytes(&self) -> usize {
254 self.estimated_bytes
255 }
256
257 fn has_any_term(&self, terms: &[String]) -> bool {
258 terms.iter().any(|term| self.terms.contains(term))
259 }
260
261 fn search(&self, terms: &[String], limit: usize) -> WorkspaceIndexResult<Vec<(usize, f64)>> {
262 if terms.is_empty() || limit == 0 || self.documents.is_empty() {
263 return Ok(Vec::new());
264 }
265 const K1: f64 = 1.2;
266 const B: f64 = 0.75;
267 let document_count = self.documents.len() as f64;
268 let average_length = (self
269 .documents
270 .iter()
271 .map(|document| document.length)
272 .sum::<usize>() as f64
273 / document_count)
274 .max(1.0);
275 let mut scores = vec![0.0; self.documents.len()];
276 let mut seen = HashSet::new();
277 for term in terms {
278 if !seen.insert(term.as_str()) {
279 continue;
280 }
281 let Some(postings) = self.postings.get(term) else {
282 continue;
283 };
284 let document_frequency = postings.len() as f64;
285 let idf = (1.0
286 + (document_count - document_frequency + 0.5) / (document_frequency + 0.5))
287 .ln();
288 for posting in postings {
289 let length_ratio = self.documents[posting.document].length as f64 / average_length;
290 let frequency = posting.term_frequency as f64;
291 let denominator = frequency + K1 * (1.0 - B + B * length_ratio);
292 scores[posting.document] +=
293 idf * (frequency * (K1 + 1.0) / denominator.max(f64::EPSILON));
294 }
295 }
296 let mut hits = scores
297 .into_iter()
298 .enumerate()
299 .filter(|(_, score)| score.is_finite() && *score > 0.0)
300 .collect::<Vec<_>>();
301 hits.sort_by(|left, right| {
302 right
303 .1
304 .total_cmp(&left.1)
305 .then_with(|| left.0.cmp(&right.0))
306 });
307 hits.truncate(limit);
308 Ok(hits)
309 }
310}
311
312pub(crate) enum LexicalIndex {
315 Portable(PortableLexicalIndex),
316 #[cfg(feature = "zvec-rust-fts")]
317 ZvecRust(super::zvec_rust::ZvecRustLexicalIndex),
318}
319
320impl LexicalIndex {
321 fn build(
322 documents: &[(String, String)],
323 engine: WorkspaceLexicalEngine,
324 ) -> WorkspaceIndexResult<Self> {
325 match engine {
326 WorkspaceLexicalEngine::Portable => {
327 PortableLexicalIndex::build(documents).map(Self::Portable)
328 }
329 WorkspaceLexicalEngine::ZvecRust => {
330 #[cfg(feature = "zvec-rust-fts")]
331 {
332 super::zvec_rust::ZvecRustLexicalIndex::build(
333 documents
334 .iter()
335 .map(|(key, text)| (key.as_str(), text.as_str())),
336 )
337 .map(Self::ZvecRust)
338 .map_err(|error| {
339 WorkspaceIndexError::InvalidConfig(format!(
340 "zvec-rust lexical index failed: {error}"
341 ))
342 })
343 }
344 #[cfg(not(feature = "zvec-rust-fts"))]
345 {
346 Err(WorkspaceIndexError::InvalidConfig(
347 "WorkspaceLexicalEngine::ZvecRust requires the zvec-rust-fts feature"
348 .to_owned(),
349 ))
350 }
351 }
352 }
353 }
354
355 pub(crate) fn document_count(&self) -> usize {
356 match self {
357 Self::Portable(index) => index.document_count(),
358 #[cfg(feature = "zvec-rust-fts")]
359 Self::ZvecRust(index) => index.document_count(),
360 }
361 }
362
363 pub(crate) fn estimated_bytes(&self) -> usize {
364 match self {
365 Self::Portable(index) => index.estimated_bytes(),
366 #[cfg(feature = "zvec-rust-fts")]
367 Self::ZvecRust(index) => index.estimated_bytes(),
368 }
369 }
370
371 pub(crate) fn has_any_term(&self, terms: &[String]) -> bool {
372 match self {
373 Self::Portable(index) => index.has_any_term(terms),
374 #[cfg(feature = "zvec-rust-fts")]
375 Self::ZvecRust(index) => index.has_any_term(terms),
376 }
377 }
378
379 pub(crate) fn search(
380 &self,
381 terms: &[String],
382 limit: usize,
383 ) -> WorkspaceIndexResult<Vec<(usize, f64)>> {
384 match self {
385 Self::Portable(index) => index.search(terms, limit),
386 #[cfg(feature = "zvec-rust-fts")]
387 Self::ZvecRust(index) => index.search(terms, limit).map_err(|error| {
388 WorkspaceIndexError::InvalidQuery(format!("zvec-rust FTS search failed: {error}"))
389 }),
390 }
391 }
392}
393
394pub(crate) fn build_lexical_index<I, K, T>(
399 documents: I,
400 engine: WorkspaceLexicalEngine,
401) -> WorkspaceIndexResult<LexicalIndex>
402where
403 I: IntoIterator<Item = (K, T)>,
404 K: AsRef<str>,
405 T: AsRef<str>,
406{
407 let documents = documents
408 .into_iter()
409 .map(|(key, text)| (key.as_ref().to_owned(), text.as_ref().to_owned()))
410 .collect::<Vec<_>>();
411 LexicalIndex::build(&documents, engine)
412}
413
414pub(crate) struct LexicalPartition {
415 index: LexicalIndex,
416 chunks: Arc<[Arc<WorkspaceChunk>]>,
417 pub(crate) document_count: usize,
418}
419
420impl LexicalPartition {
421 pub(crate) fn build(
428 chunks: Arc<[Arc<WorkspaceChunk>]>,
429 engine: WorkspaceLexicalEngine,
430 ) -> WorkspaceIndexResult<Self> {
431 let indexed_chunks: Arc<[Arc<WorkspaceChunk>]> = Arc::from(
432 chunks
433 .iter()
434 .filter(|chunk| !tokenize(chunk.text.as_ref()).is_empty())
435 .cloned()
436 .collect::<Vec<_>>(),
437 );
438 let index = build_lexical_index(
439 indexed_chunks
440 .iter()
441 .map(|chunk| (chunk.id.as_str(), chunk.text.as_ref())),
442 engine,
443 )?;
444 let document_count = index.document_count();
445 Ok(Self {
446 index,
447 chunks: indexed_chunks,
448 document_count,
449 })
450 }
451
452 fn has_any_term(&self, terms: &[String]) -> bool {
453 self.index.has_any_term(terms)
454 }
455
456 pub(crate) fn estimated_bytes(&self) -> usize {
457 self.index.estimated_bytes()
458 }
459
460 fn search(
461 &self,
462 terms: &[String],
463 limit: usize,
464 ) -> WorkspaceIndexResult<Vec<(Arc<WorkspaceChunk>, f64)>> {
465 if terms.is_empty() || limit == 0 {
466 return Ok(Vec::new());
467 }
468 self.index.search(terms, limit).map(|hits| {
469 hits.into_iter()
470 .filter_map(|(ordinal, score)| {
471 self.chunks
472 .get(ordinal)
473 .cloned()
474 .map(|chunk| (chunk, score))
475 })
476 .collect()
477 })
478 }
479}
480
481pub(crate) fn search_catalog(
482 snapshot: &ChunkCatalogSnapshot,
483 request: &LexicalSearchRequest,
484) -> Result<LexicalSearchResult, WorkspaceIndexError> {
485 validate_request(request)?;
486 let terms = query_terms(request.query.trim(), DEFAULT_QUERY_TERM_LIMIT);
487 if terms.is_empty() {
488 return Err(WorkspaceIndexError::InvalidQuery(
489 "query must contain a letter, number, underscore, or CJK character".to_owned(),
490 ));
491 }
492 let glob = request
493 .glob
494 .as_deref()
495 .map(glob::Pattern::new)
496 .transpose()
497 .map_err(|error| WorkspaceIndexError::InvalidQuery(error.to_string()))?;
498
499 let matching = snapshot
500 .state
501 .files
502 .iter()
503 .filter(|(path, file)| {
504 path_matches(path, &request.path, glob.as_ref()) && file.lexical.has_any_term(&terms)
505 })
506 .collect::<Vec<_>>();
507 let matching_files = matching.len();
508 let candidate_truncated = matching_files > request.max_candidate_files;
509 let selected = matching
510 .into_iter()
511 .take(request.max_candidate_files)
512 .collect::<Vec<_>>();
513 let selected_files = selected.len();
514 let document_count = selected
515 .iter()
516 .map(|(_, file)| file.lexical.document_count)
517 .sum::<usize>();
518 if document_count == 0 {
519 return Ok(LexicalSearchResult {
520 catalog_revision: snapshot.revision(),
521 source_revision: snapshot.source_revision(),
522 lexical_engine: snapshot.lexical_engine(),
523 query_terms: terms,
524 matching_files,
525 selected_files: selected.len(),
526 scored_chunks: 0,
527 candidate_truncated,
528 hits: Vec::new(),
529 });
530 }
531 let mut ranked = Vec::new();
532 let per_file_limit = request.limit.min(request.max_results_per_file);
537 for (_, file) in selected {
538 for (chunk, score) in file.lexical.search(&terms, per_file_limit)? {
539 if score.is_finite() && score > 0.0 {
540 ranked.push(LexicalSearchHit { chunk, score });
541 }
542 }
543 }
544 ranked.sort_by(|left, right| {
545 right
546 .score
547 .total_cmp(&left.score)
548 .then_with(|| left.chunk.path.cmp(&right.chunk.path))
549 .then_with(|| left.chunk.start_byte.cmp(&right.chunk.start_byte))
550 .then_with(|| left.chunk.id.cmp(&right.chunk.id))
551 });
552
553 let mut per_file = HashMap::<Arc<str>, usize>::new();
554 let hits = ranked
555 .into_iter()
556 .filter(|hit| {
557 let count = per_file.entry(Arc::clone(&hit.chunk.path)).or_default();
558 if *count >= request.max_results_per_file {
559 return false;
560 }
561 *count += 1;
562 true
563 })
564 .take(request.limit)
565 .collect();
566
567 Ok(LexicalSearchResult {
568 catalog_revision: snapshot.revision(),
569 source_revision: snapshot.source_revision(),
570 lexical_engine: snapshot.lexical_engine(),
571 query_terms: terms,
572 matching_files,
573 selected_files,
574 scored_chunks: document_count,
575 candidate_truncated,
576 hits,
577 })
578}
579
580pub(crate) fn validate_request(request: &LexicalSearchRequest) -> Result<(), WorkspaceIndexError> {
581 if request.query.trim().is_empty() {
582 return Err(WorkspaceIndexError::InvalidQuery(
583 "query must not be empty".to_owned(),
584 ));
585 }
586 if request.query.len() > MAX_QUERY_BYTES {
587 return Err(WorkspaceIndexError::InvalidQuery(format!(
588 "query exceeds the {MAX_QUERY_BYTES}-byte limit"
589 )));
590 }
591 if request.limit == 0 || request.limit > MAX_RESULT_LIMIT {
592 return Err(WorkspaceIndexError::InvalidQuery(format!(
593 "limit must be from 1 to {MAX_RESULT_LIMIT}"
594 )));
595 }
596 if request.max_candidate_files == 0 || request.max_results_per_file == 0 {
597 return Err(WorkspaceIndexError::InvalidQuery(
598 "candidate and per-file limits must be greater than zero".to_owned(),
599 ));
600 }
601 Ok(())
602}
603
604pub(crate) fn path_matches(path: &str, base: &WorkspacePath, glob: Option<&glob::Pattern>) -> bool {
605 let relative = if base.is_root() {
606 path
607 } else if path == base.as_str() {
608 path.rsplit('/').next().unwrap_or(path)
609 } else {
610 let Some(relative) = path
611 .strip_prefix(base.as_str())
612 .and_then(|path| path.strip_prefix('/'))
613 else {
614 return false;
615 };
616 relative
617 };
618 glob.is_none_or(|pattern| pattern.matches(relative) || pattern.matches(path))
619}
620
621pub(crate) fn query_terms(query: &str, limit: usize) -> Vec<String> {
622 let mut seen = HashSet::new();
623 tokenize(query)
624 .into_iter()
625 .filter(|term| seen.insert(term.clone()))
626 .take(limit)
627 .collect()
628}
629
630pub(crate) fn tokenize(text: &str) -> Vec<String> {
631 let mut tokens = Vec::new();
632 let mut word = String::new();
633 let mut previous_cjk = None;
634
635 for ch in text.chars() {
636 if is_cjk(ch) {
637 flush_word(&mut word, &mut tokens);
638 tokens.push(ch.to_string());
639 if let Some(previous) = previous_cjk {
640 tokens.push(format!("{previous}{ch}"));
641 }
642 previous_cjk = Some(ch);
643 } else {
644 previous_cjk = None;
645 if ch.is_alphanumeric() || ch == '_' {
646 word.push(ch);
647 } else {
648 flush_word(&mut word, &mut tokens);
649 }
650 }
651 }
652 flush_word(&mut word, &mut tokens);
653 tokens
654}
655
656fn flush_word(word: &mut String, tokens: &mut Vec<String>) {
657 if word.is_empty() {
658 return;
659 }
660 if !word.chars().any(char::is_alphanumeric) {
661 word.clear();
662 return;
663 }
664 let mut variants = vec![word.to_lowercase()];
665 for segment in word.split('_').filter(|segment| !segment.is_empty()) {
666 variants.push(segment.to_lowercase());
667 variants.extend(split_identifier(segment));
668 }
669 let mut seen = HashSet::new();
670 tokens.extend(
671 variants
672 .into_iter()
673 .filter(|variant| !variant.is_empty() && seen.insert(variant.clone())),
674 );
675 word.clear();
676}
677
678fn split_identifier(identifier: &str) -> Vec<String> {
679 let chars = identifier.chars().collect::<Vec<_>>();
680 if chars.is_empty() {
681 return Vec::new();
682 }
683 let mut parts = Vec::new();
684 let mut start = 0usize;
685 for index in 1..chars.len() {
686 let previous = chars[index - 1];
687 let current = chars[index];
688 let next = chars.get(index + 1).copied();
689 let at_case_boundary = previous.is_lowercase() && current.is_uppercase();
690 let at_acronym_boundary = previous.is_uppercase()
691 && current.is_uppercase()
692 && next.is_some_and(char::is_lowercase);
693 let at_numeric_boundary = previous.is_numeric() != current.is_numeric()
694 && previous.is_alphanumeric()
695 && current.is_alphanumeric();
696 if at_case_boundary || at_acronym_boundary || at_numeric_boundary {
697 parts.push(
698 chars[start..index]
699 .iter()
700 .collect::<String>()
701 .to_lowercase(),
702 );
703 start = index;
704 }
705 }
706 parts.push(chars[start..].iter().collect::<String>().to_lowercase());
707 parts
708}
709
710fn is_cjk(ch: char) -> bool {
711 matches!(
712 ch as u32,
713 0x3400..=0x4dbf
714 | 0x4e00..=0x9fff
715 | 0xf900..=0xfaff
716 | 0x20000..=0x2fa1f
717 | 0x3040..=0x30ff
718 | 0xac00..=0xd7af
719 )
720}
721
722#[cfg(test)]
723mod tests {
724 use super::PortableLexicalIndex;
725
726 #[test]
727 fn large_portable_build_keeps_dense_ordinals_after_empty_documents() {
728 let documents = (0..256)
729 .map(|index| {
730 let text = if index % 13 == 0 {
731 " \n\t".to_owned()
732 } else {
733 format!(
734 "{} {}",
735 if index == 129 {
736 "parallelneedle"
737 } else {
738 "common"
739 },
740 "workspace payload ".repeat(64)
741 )
742 };
743 (format!("doc-{index:03}"), text)
744 })
745 .collect::<Vec<_>>();
746 let index = PortableLexicalIndex::build(&documents).expect("portable index");
747 let hits = index
748 .search(&["parallelneedle".to_owned()], 1)
749 .expect("portable query");
750 let expected_ordinal = (0..129).filter(|index| index % 13 != 0).count();
751 assert_eq!(hits.first().map(|hit| hit.0), Some(expected_ordinal));
752 assert_eq!(index.document_count(), 236);
753 }
754}