Skip to main content

a3s_code_core/workspace/retrieval/
lexical.rs

1use 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}