Skip to main content

a3s_code_core/workspace/retrieval/
lexical.rs

1use 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
60/// One session-local A3S Vec FTS projection.
61///
62/// The caller owns admission, chunking, and source verification. This helper
63/// owns only the token postings and BM25 score calculation. It is shared by
64/// the incremental catalog and the bounded query-time compatibility path so
65/// there is no second Code-local BM25 implementation.
66pub(crate) struct VecLexicalIndex {
67    collection: Collection,
68    // Keep the temporary directory alive for the collection handle. The
69    // collection field is declared first so it is released before the
70    // directory during normal Rust drop order.
71    _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    /// Build an index from stable keys and source text.
80    ///
81    /// `K` and `T` deliberately accept both borrowed and owned values. The
82    /// collection copies normalized tokens into its own documents, while the
83    /// caller can retain its source text without coupling it to the index.
84    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            // Keep the caller ordinal dense over indexed documents. Empty
127            // source chunks are intentionally omitted from the FTS
128            // collection, so using the input position here would make a
129            // later non-empty chunk resolve to the wrong source chunk.
130            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    /// Search and return `(caller_ordinal, score)` pairs.
172    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    /// Build the catalog's lexical partition through the A3S Vec FTS API.
206    ///
207    /// Code still owns chunk admission and path policy, while tokenization,
208    /// postings, BM25 statistics, and deterministic score ordering are owned
209    /// by the same engine used by the standalone Vec crate. The collection is
210    /// deliberately temporary: workspace source remains authoritative and no
211    /// durable SQLite/sqlite-vec path is introduced by lexical search.
212    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}