a3s_code_core/workspace/retrieval/
lexical.rs1use super::catalog::ChunkCatalogSnapshot;
2use super::types::{WorkspaceChunk, WorkspaceIndexError, WorkspaceIndexResult};
3use crate::workspace::WorkspacePath;
4use a3s_vec::{
5 Collection, CollectionOptions, CollectionSchema, DataType, Doc, Durability, FieldSchema, Fts,
6 IndexParams, SearchQuery,
7};
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10use tempfile::TempDir;
11
12const DEFAULT_QUERY_TERM_LIMIT: usize = 32;
13const DEFAULT_CANDIDATE_FILE_LIMIT: usize = 256;
14const DEFAULT_RESULT_LIMIT: usize = 10;
15const MAX_RESULT_LIMIT: usize = 25;
16const MAX_QUERY_BYTES: usize = 2_048;
17const DEFAULT_RESULTS_PER_FILE: usize = 2;
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct LexicalSearchRequest {
21 pub query: String,
22 pub path: WorkspacePath,
23 pub glob: Option<String>,
24 pub limit: usize,
25 pub max_candidate_files: usize,
26 pub max_results_per_file: usize,
27}
28
29impl LexicalSearchRequest {
30 pub fn new(query: impl Into<String>) -> Self {
31 Self {
32 query: query.into(),
33 path: WorkspacePath::root(),
34 glob: None,
35 limit: DEFAULT_RESULT_LIMIT,
36 max_candidate_files: DEFAULT_CANDIDATE_FILE_LIMIT,
37 max_results_per_file: DEFAULT_RESULTS_PER_FILE,
38 }
39 }
40}
41
42#[derive(Clone, Debug)]
43pub struct LexicalSearchHit {
44 pub chunk: Arc<WorkspaceChunk>,
45 pub score: f64,
46}
47
48#[derive(Clone, Debug)]
49pub struct LexicalSearchResult {
50 pub catalog_revision: u64,
51 pub source_revision: u64,
52 pub query_terms: Vec<String>,
53 pub matching_files: usize,
54 pub selected_files: usize,
55 pub scored_chunks: usize,
56 pub candidate_truncated: bool,
57 pub hits: Vec<LexicalSearchHit>,
58}
59
60pub(crate) struct VecLexicalIndex {
67 collection: Collection,
68 _temp_dir: TempDir,
72 terms: HashSet<String>,
73 ordinals: HashMap<String, usize>,
74 document_count: usize,
75 estimated_bytes: usize,
76}
77
78impl VecLexicalIndex {
79 pub(crate) fn build<I, K, T>(documents: I) -> Result<Self, a3s_vec::Error>
85 where
86 I: IntoIterator<Item = (K, T)>,
87 K: AsRef<str>,
88 T: AsRef<str>,
89 {
90 let mut body = FieldSchema::new("body", DataType::String, false, 0)?;
91 let fts = IndexParams::fts(Some("whitespace"), None, None)?;
92 body.set_index_params(&fts)?;
93 let schema = CollectionSchema::builder("workspace_lexical")
94 .add_field(body)
95 .build()?;
96 let temp_dir = tempfile::tempdir()?;
97 let collection_path = temp_dir
98 .path()
99 .join("collection")
100 .to_str()
101 .ok_or_else(|| a3s_vec::Error::invalid_argument("lexical path is not UTF-8"))?
102 .to_owned();
103 let mut options = CollectionOptions::new()?;
104 options.set_durability(Durability::Manual)?;
105 let collection = Collection::create(&collection_path, &schema, Some(&options))?;
106
107 let mut docs = Vec::new();
108 let mut terms = HashSet::new();
109 let mut ordinals = HashMap::new();
110 for (key, text) in documents {
111 let key = key.as_ref();
112 if key.is_empty() || key.contains('\0') {
113 return Err(a3s_vec::Error::invalid_argument(
114 "lexical document key must be non-empty and contain no NUL byte",
115 ));
116 }
117 if ordinals.contains_key(key) {
118 return Err(a3s_vec::Error::invalid_argument(
119 "lexical document keys must be unique",
120 ));
121 }
122 let tokens = tokenize(text.as_ref());
123 if tokens.is_empty() {
124 continue;
125 }
126 let indexed_ordinal = ordinals.len();
131 ordinals.insert(key.to_owned(), indexed_ordinal);
132 terms.extend(tokens.iter().cloned());
133 let mut document = Doc::with_pk(key)?;
134 document.add_string("body", &tokens.join(" "))?;
135 docs.push(document);
136 }
137 if !docs.is_empty() {
138 let references = docs.iter().collect::<Vec<_>>();
139 let result = collection.insert(&references)?;
140 if result.error_count != 0 {
141 return Err(a3s_vec::Error::failed_precondition(format!(
142 "lexical document insert rejected {} document(s)",
143 result.error_count
144 )));
145 }
146 }
147 let estimated_bytes =
148 usize::try_from(collection.stats()?.accounted_bytes).unwrap_or(usize::MAX);
149 Ok(Self {
150 collection,
151 _temp_dir: temp_dir,
152 terms,
153 document_count: ordinals.len(),
154 ordinals,
155 estimated_bytes,
156 })
157 }
158
159 pub(crate) fn document_count(&self) -> usize {
160 self.document_count
161 }
162
163 pub(crate) fn estimated_bytes(&self) -> usize {
164 self.estimated_bytes
165 }
166
167 pub(crate) fn has_any_term(&self, terms: &[String]) -> bool {
168 terms.iter().any(|term| self.terms.contains(term))
169 }
170
171 pub(crate) fn search(
173 &self,
174 terms: &[String],
175 limit: usize,
176 ) -> Result<Vec<(usize, f64)>, a3s_vec::Error> {
177 if terms.is_empty() || limit == 0 {
178 return Ok(Vec::new());
179 }
180 let mut fts = Fts::new()?;
181 fts.set_match_string(&terms.join(" "))?;
182 let topk = i32::try_from(limit)
183 .map_err(|_| a3s_vec::Error::invalid_argument("lexical result limit exceeds i32"))?;
184 let mut query = SearchQuery::fts("body", &fts, topk)?;
185 query.set_output_fields(&[])?;
186 let documents = self.collection.query(&query)?;
187 Ok(documents
188 .into_iter()
189 .filter_map(|document| {
190 let key = document.get_pk()?;
191 let ordinal = *self.ordinals.get(key)?;
192 Some((ordinal, f64::from(document.get_score())))
193 })
194 .collect())
195 }
196}
197
198pub(crate) struct LexicalPartition {
199 index: VecLexicalIndex,
200 chunks: Arc<[Arc<WorkspaceChunk>]>,
201 pub(crate) document_count: usize,
202}
203
204impl LexicalPartition {
205 pub(crate) fn build(chunks: Arc<[Arc<WorkspaceChunk>]>) -> WorkspaceIndexResult<Self> {
213 let indexed_chunks: Arc<[Arc<WorkspaceChunk>]> = Arc::from(
214 chunks
215 .iter()
216 .filter(|chunk| !tokenize(chunk.text.as_ref()).is_empty())
217 .cloned()
218 .collect::<Vec<_>>(),
219 );
220 let index = VecLexicalIndex::build(
221 indexed_chunks
222 .iter()
223 .map(|chunk| (chunk.id.as_str(), chunk.text.as_ref())),
224 )
225 .map_err(|error| lexical_build_error("index", error))?;
226 let document_count = index.document_count();
227 Ok(Self {
228 index,
229 chunks: indexed_chunks,
230 document_count,
231 })
232 }
233
234 fn has_any_term(&self, terms: &[String]) -> bool {
235 self.index.has_any_term(terms)
236 }
237
238 pub(crate) fn estimated_bytes(&self) -> usize {
239 self.index.estimated_bytes()
240 }
241
242 fn search(
243 &self,
244 terms: &[String],
245 limit: usize,
246 ) -> WorkspaceIndexResult<Vec<(Arc<WorkspaceChunk>, f64)>> {
247 if terms.is_empty() || limit == 0 {
248 return Ok(Vec::new());
249 }
250 self.index
251 .search(terms, limit)
252 .map_err(|error| lexical_query_error("FTS search", error))
253 .map(|hits| {
254 hits.into_iter()
255 .filter_map(|(ordinal, score)| {
256 self.chunks
257 .get(ordinal)
258 .cloned()
259 .map(|chunk| (chunk, score))
260 })
261 .collect()
262 })
263 }
264}
265
266fn lexical_build_error(context: &str, error: a3s_vec::Error) -> WorkspaceIndexError {
267 WorkspaceIndexError::InvalidConfig(format!("A3S Vec lexical {context} failed: {error}"))
268}
269
270fn lexical_query_error(context: &str, error: a3s_vec::Error) -> WorkspaceIndexError {
271 WorkspaceIndexError::InvalidQuery(format!("A3S Vec lexical {context} failed: {error}"))
272}
273
274pub(crate) fn search_catalog(
275 snapshot: &ChunkCatalogSnapshot,
276 request: &LexicalSearchRequest,
277) -> Result<LexicalSearchResult, WorkspaceIndexError> {
278 validate_request(request)?;
279 let terms = query_terms(request.query.trim(), DEFAULT_QUERY_TERM_LIMIT);
280 if terms.is_empty() {
281 return Err(WorkspaceIndexError::InvalidQuery(
282 "query must contain a letter, number, underscore, or CJK character".to_owned(),
283 ));
284 }
285 let glob = request
286 .glob
287 .as_deref()
288 .map(glob::Pattern::new)
289 .transpose()
290 .map_err(|error| WorkspaceIndexError::InvalidQuery(error.to_string()))?;
291
292 let matching = snapshot
293 .state
294 .files
295 .iter()
296 .filter(|(path, file)| {
297 path_matches(path, &request.path, glob.as_ref()) && file.lexical.has_any_term(&terms)
298 })
299 .collect::<Vec<_>>();
300 let matching_files = matching.len();
301 let candidate_truncated = matching_files > request.max_candidate_files;
302 let selected = matching
303 .into_iter()
304 .take(request.max_candidate_files)
305 .collect::<Vec<_>>();
306 let selected_files = selected.len();
307 let document_count = selected
308 .iter()
309 .map(|(_, file)| file.lexical.document_count)
310 .sum::<usize>();
311 if document_count == 0 {
312 return Ok(LexicalSearchResult {
313 catalog_revision: snapshot.revision(),
314 source_revision: snapshot.source_revision(),
315 query_terms: terms,
316 matching_files,
317 selected_files: selected.len(),
318 scored_chunks: 0,
319 candidate_truncated,
320 hits: Vec::new(),
321 });
322 }
323 let mut ranked = Vec::new();
324 for (_, file) in selected {
325 for (chunk, score) in file.lexical.search(&terms, request.limit)? {
326 if score.is_finite() && score > 0.0 {
327 ranked.push(LexicalSearchHit { chunk, score });
328 }
329 }
330 }
331 ranked.sort_by(|left, right| {
332 right
333 .score
334 .total_cmp(&left.score)
335 .then_with(|| left.chunk.path.cmp(&right.chunk.path))
336 .then_with(|| left.chunk.start_byte.cmp(&right.chunk.start_byte))
337 .then_with(|| left.chunk.id.cmp(&right.chunk.id))
338 });
339
340 let mut per_file = HashMap::<Arc<str>, usize>::new();
341 let hits = ranked
342 .into_iter()
343 .filter(|hit| {
344 let count = per_file.entry(Arc::clone(&hit.chunk.path)).or_default();
345 if *count >= request.max_results_per_file {
346 return false;
347 }
348 *count += 1;
349 true
350 })
351 .take(request.limit)
352 .collect();
353
354 Ok(LexicalSearchResult {
355 catalog_revision: snapshot.revision(),
356 source_revision: snapshot.source_revision(),
357 query_terms: terms,
358 matching_files,
359 selected_files,
360 scored_chunks: document_count,
361 candidate_truncated,
362 hits,
363 })
364}
365
366fn validate_request(request: &LexicalSearchRequest) -> Result<(), WorkspaceIndexError> {
367 if request.query.trim().is_empty() {
368 return Err(WorkspaceIndexError::InvalidQuery(
369 "query must not be empty".to_owned(),
370 ));
371 }
372 if request.query.len() > MAX_QUERY_BYTES {
373 return Err(WorkspaceIndexError::InvalidQuery(format!(
374 "query exceeds the {MAX_QUERY_BYTES}-byte limit"
375 )));
376 }
377 if request.limit == 0 || request.limit > MAX_RESULT_LIMIT {
378 return Err(WorkspaceIndexError::InvalidQuery(format!(
379 "limit must be from 1 to {MAX_RESULT_LIMIT}"
380 )));
381 }
382 if request.max_candidate_files == 0 || request.max_results_per_file == 0 {
383 return Err(WorkspaceIndexError::InvalidQuery(
384 "candidate and per-file limits must be greater than zero".to_owned(),
385 ));
386 }
387 Ok(())
388}
389
390fn path_matches(path: &str, base: &WorkspacePath, glob: Option<&glob::Pattern>) -> bool {
391 let relative = if base.is_root() {
392 path
393 } else if path == base.as_str() {
394 path.rsplit('/').next().unwrap_or(path)
395 } else {
396 let Some(relative) = path
397 .strip_prefix(base.as_str())
398 .and_then(|path| path.strip_prefix('/'))
399 else {
400 return false;
401 };
402 relative
403 };
404 glob.is_none_or(|pattern| pattern.matches(relative) || pattern.matches(path))
405}
406
407pub(crate) fn query_terms(query: &str, limit: usize) -> Vec<String> {
408 let mut seen = HashSet::new();
409 tokenize(query)
410 .into_iter()
411 .filter(|term| seen.insert(term.clone()))
412 .take(limit)
413 .collect()
414}
415
416pub(crate) fn tokenize(text: &str) -> Vec<String> {
417 let mut tokens = Vec::new();
418 let mut word = String::new();
419 let mut previous_cjk = None;
420
421 for ch in text.chars() {
422 if is_cjk(ch) {
423 flush_word(&mut word, &mut tokens);
424 tokens.push(ch.to_string());
425 if let Some(previous) = previous_cjk {
426 tokens.push(format!("{previous}{ch}"));
427 }
428 previous_cjk = Some(ch);
429 } else {
430 previous_cjk = None;
431 if ch.is_alphanumeric() || ch == '_' {
432 word.push(ch);
433 } else {
434 flush_word(&mut word, &mut tokens);
435 }
436 }
437 }
438 flush_word(&mut word, &mut tokens);
439 tokens
440}
441
442fn flush_word(word: &mut String, tokens: &mut Vec<String>) {
443 if word.is_empty() {
444 return;
445 }
446 if !word.chars().any(char::is_alphanumeric) {
447 word.clear();
448 return;
449 }
450 let mut variants = vec![word.to_lowercase()];
451 for segment in word.split('_').filter(|segment| !segment.is_empty()) {
452 variants.push(segment.to_lowercase());
453 variants.extend(split_identifier(segment));
454 }
455 let mut seen = HashSet::new();
456 tokens.extend(
457 variants
458 .into_iter()
459 .filter(|variant| !variant.is_empty() && seen.insert(variant.clone())),
460 );
461 word.clear();
462}
463
464fn split_identifier(identifier: &str) -> Vec<String> {
465 let chars = identifier.chars().collect::<Vec<_>>();
466 if chars.is_empty() {
467 return Vec::new();
468 }
469 let mut parts = Vec::new();
470 let mut start = 0usize;
471 for index in 1..chars.len() {
472 let previous = chars[index - 1];
473 let current = chars[index];
474 let next = chars.get(index + 1).copied();
475 let at_case_boundary = previous.is_lowercase() && current.is_uppercase();
476 let at_acronym_boundary = previous.is_uppercase()
477 && current.is_uppercase()
478 && next.is_some_and(char::is_lowercase);
479 let at_numeric_boundary = previous.is_numeric() != current.is_numeric()
480 && previous.is_alphanumeric()
481 && current.is_alphanumeric();
482 if at_case_boundary || at_acronym_boundary || at_numeric_boundary {
483 parts.push(
484 chars[start..index]
485 .iter()
486 .collect::<String>()
487 .to_lowercase(),
488 );
489 start = index;
490 }
491 }
492 parts.push(chars[start..].iter().collect::<String>().to_lowercase());
493 parts
494}
495
496fn is_cjk(ch: char) -> bool {
497 matches!(
498 ch as u32,
499 0x3400..=0x4dbf
500 | 0x4e00..=0x9fff
501 | 0xf900..=0xfaff
502 | 0x20000..=0x2fa1f
503 | 0x3040..=0x30ff
504 | 0xac00..=0xd7af
505 )
506}