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