zeph_index/retriever.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Hybrid code retrieval: query classification, semantic search, budget packing.
5//!
6//! # Retrieval strategy
7//!
8//! [`classify_query`] inspects the free-text query for heuristic signals:
9//!
10//! | Signal | Examples | Strategy |
11//! |--------|----------|----------|
12//! | Symbol patterns only | `"fn my_fn"`, `"SkillMatcher::match"`, `"my_snake_func"` | [`RetrievalStrategy::Grep`] |
13//! | Conceptual patterns only | `"how does auth work?"`, `"explain the retry logic"` | [`RetrievalStrategy::Semantic`] |
14//! | Both | `"where is SkillMatcher used?"` | [`RetrievalStrategy::Hybrid`] |
15//!
16//! For `Grep` queries, [`CodeRetriever::retrieve`] returns an empty chunk list and
17//! the agent falls back to its shell grep tool. For `Semantic` and `Hybrid` queries
18//! an embedding round-trip is made and the top-scoring Qdrant results are packed
19//! within a token budget.
20//!
21//! # Token budget
22//!
23//! [`RetrievalConfig::budget_ratio`] controls what fraction of the caller's available
24//! context window is allocated to code chunks. The packing loop stops before adding a
25//! chunk that would exceed the budget, so the retrieved set always fits the window.
26
27use std::fmt::Write;
28use std::sync::Arc;
29
30use crate::error::Result;
31use crate::store::{CodeStore, SearchHit};
32use zeph_common::{EmbeddingVector, Unnormalized};
33use zeph_llm::any::AnyProvider;
34use zeph_llm::provider::LlmProvider;
35use zeph_memory::TokenCounter;
36
37/// The retrieval strategy selected by [`classify_query`] for a given query.
38///
39/// # Examples
40///
41/// ```
42/// use zeph_index::retriever::{RetrievalStrategy, classify_query};
43///
44/// assert_eq!(classify_query("how does authentication work?"), RetrievalStrategy::Semantic);
45/// assert_eq!(classify_query("fn my_handler"), RetrievalStrategy::Grep);
46/// assert_eq!(classify_query("where is MyHandler used?"), RetrievalStrategy::Hybrid);
47/// ```
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum RetrievalStrategy {
51 /// Vector similarity search for conceptual or descriptive queries.
52 ///
53 /// The query is embedded and the top-K chunks from Qdrant are returned.
54 Semantic,
55 /// Exact symbol lookup — the retriever returns an empty chunk list.
56 ///
57 /// The caller (agent) is expected to use a `grep` or `symbol_definition` tool
58 /// instead of the vector store for precise symbol lookups.
59 Grep,
60 /// Both semantic search **and** a hint that grep may also help.
61 ///
62 /// Semantic results are still returned, but the caller can additionally
63 /// perform a textual search for the identified symbol names.
64 Hybrid,
65}
66
67/// Configuration for [`CodeRetriever`].
68///
69/// # Examples
70///
71/// ```
72/// use zeph_index::retriever::RetrievalConfig;
73///
74/// let cfg = RetrievalConfig::default();
75/// assert_eq!(cfg.max_chunks, 12);
76/// assert!(cfg.score_threshold > 0.0);
77/// assert!(cfg.budget_ratio > 0.0 && cfg.budget_ratio < 1.0);
78/// ```
79#[derive(Debug, Clone)]
80pub struct RetrievalConfig {
81 /// Maximum number of chunks to fetch from Qdrant before applying score and budget filters.
82 pub max_chunks: usize,
83 /// Minimum cosine similarity score to accept (chunks below this are dropped).
84 pub score_threshold: f32,
85 /// Maximum fraction of `available_tokens` allocated to code chunks (0.0–1.0).
86 pub budget_ratio: f32,
87 /// Maximum seconds to wait for `provider.embed()` before returning
88 /// [`crate::error::IndexError::EmbedTimeout`]. Defaults to `10`.
89 pub embed_timeout_secs: u64,
90 /// Configured `[index] embedding_provider` name (record-keeping only).
91 ///
92 /// Stores the raw name from config, regardless of whether resolution succeeded or
93 /// fell back to the main provider. This field is **not** read by `zeph-index` —
94 /// resolution happens in the binary bootstrap before this struct is constructed.
95 /// Setting this field does not change which provider is used.
96 pub embedding_provider: String,
97}
98
99impl Default for RetrievalConfig {
100 fn default() -> Self {
101 Self {
102 max_chunks: 12,
103 score_threshold: 0.25,
104 budget_ratio: 0.40,
105 embed_timeout_secs: 10,
106 embedding_provider: String::new(),
107 }
108 }
109}
110
111/// The result of a single retrieval operation.
112///
113/// Returned by [`CodeRetriever::retrieve`] and [`CodeRetriever::retrieve_filtered`].
114/// Pass to [`format_as_context`] to produce an XML snippet for injection into the
115/// agent message.
116#[derive(Debug)]
117pub struct RetrievedCode {
118 /// Ordered list of matching chunks (highest score first, budget-capped).
119 pub chunks: Vec<SearchHit>,
120 /// Estimated total tokens consumed by `chunks` (including a small per-chunk overhead).
121 pub total_tokens: usize,
122 /// Strategy that was used to produce this result.
123 pub strategy: RetrievalStrategy,
124}
125
126/// Budget-aware code retriever with automatic query classification.
127///
128/// Wraps a [`CodeStore`] and an LLM provider (for embedding) and exposes a single
129/// high-level [`CodeRetriever::retrieve`] method.
130///
131/// # Examples
132///
133/// ```no_run
134/// use std::sync::Arc;
135/// use zeph_index::retriever::{CodeRetriever, RetrievalConfig, format_as_context};
136/// use zeph_index::store::CodeStore;
137/// # async fn example() -> zeph_index::Result<()> {
138/// # let store: CodeStore = panic!("placeholder");
139/// # let provider: Arc<zeph_llm::any::AnyProvider> = panic!("placeholder");
140///
141/// let retriever = CodeRetriever::new(store, provider, RetrievalConfig::default());
142/// let result = retriever.retrieve("explain how authentication works", 8_000).await?;
143/// let xml = format_as_context(&result);
144/// println!("{xml}");
145/// # Ok(())
146/// # }
147/// ```
148pub struct CodeRetriever {
149 store: CodeStore,
150 provider: Arc<AnyProvider>,
151 config: RetrievalConfig,
152 token_counter: Arc<TokenCounter>,
153}
154
155impl CodeRetriever {
156 /// Create a new `CodeRetriever`.
157 ///
158 /// `store` must have its Qdrant collection already created (see
159 /// [`CodeStore::ensure_collection`]).
160 #[must_use]
161 pub fn new(store: CodeStore, provider: Arc<AnyProvider>, config: RetrievalConfig) -> Self {
162 Self {
163 store,
164 provider,
165 config,
166 token_counter: Arc::new(TokenCounter::new()),
167 }
168 }
169
170 /// Retrieve relevant code chunks for a free-text query.
171 ///
172 /// Classifies `query` via [`classify_query`], then:
173 ///
174 /// * For [`RetrievalStrategy::Grep`] queries — returns an empty [`RetrievedCode`]
175 /// so the agent falls back to its shell `grep` or `symbol_definition` tools.
176 /// * For [`RetrievalStrategy::Semantic`] / [`RetrievalStrategy::Hybrid`] — embeds
177 /// the query, searches Qdrant, applies the score threshold, and packs results
178 /// within `available_tokens * budget_ratio`.
179 ///
180 /// # Errors
181 ///
182 /// Returns an error if the embedding call or Qdrant search fails.
183 #[tracing::instrument(name = "index.retriever.retrieve", skip(self), fields(%query, available_tokens))]
184 pub async fn retrieve(&self, query: &str, available_tokens: usize) -> Result<RetrievedCode> {
185 let strategy = classify_query(query);
186
187 let token_budget = budget_tokens(available_tokens, self.config.budget_ratio);
188
189 match strategy {
190 RetrievalStrategy::Grep => Ok(RetrievedCode {
191 chunks: vec![],
192 total_tokens: 0,
193 strategy,
194 }),
195 RetrievalStrategy::Semantic | RetrievalStrategy::Hybrid => {
196 let chunks = self
197 .semantic_search(query, token_budget, None::<String>)
198 .await?;
199 let total_tokens: usize = chunks
200 .iter()
201 .map(|c| self.token_counter.count_tokens(&c.code) + 20)
202 .sum();
203 Ok(RetrievedCode {
204 chunks,
205 total_tokens,
206 strategy,
207 })
208 }
209 }
210 }
211
212 /// Retrieve relevant code, restricting results to a single language.
213 ///
214 /// Behaves like [`CodeRetriever::retrieve`] but adds a Qdrant payload filter so
215 /// only chunks whose `language` field matches `language` are returned.
216 ///
217 /// Useful when the user or agent has already established the relevant language
218 /// (e.g. "show me the Python error handling" should not return Rust results).
219 ///
220 /// # Arguments
221 ///
222 /// * `language` — the language identifier as returned by [`crate::languages::Lang::id`]
223 /// (e.g. `"rust"`, `"python"`).
224 ///
225 /// # Errors
226 ///
227 /// Returns an error if embedding or Qdrant search fails.
228 #[tracing::instrument(name = "index.retriever.retrieve_filtered", skip(self), fields(%query, available_tokens, %language))]
229 pub async fn retrieve_filtered(
230 &self,
231 query: &str,
232 available_tokens: usize,
233 language: &str,
234 ) -> Result<RetrievedCode> {
235 let strategy = classify_query(query);
236
237 let token_budget = budget_tokens(available_tokens, self.config.budget_ratio);
238
239 let chunks = self
240 .semantic_search(query, token_budget, Some(language.to_string()))
241 .await?;
242 let total_tokens: usize = chunks
243 .iter()
244 .map(|c| self.token_counter.count_tokens(&c.code) + 20)
245 .sum();
246
247 Ok(RetrievedCode {
248 chunks,
249 total_tokens,
250 strategy,
251 })
252 }
253
254 #[tracing::instrument(name = "index.retriever.semantic_search", skip(self), fields(%query, token_budget))]
255 async fn semantic_search(
256 &self,
257 query: &str,
258 token_budget: usize,
259 language_filter: Option<String>,
260 ) -> Result<Vec<SearchHit>> {
261 let timeout = std::time::Duration::from_secs(self.config.embed_timeout_secs);
262 let raw_vector = tokio::time::timeout(timeout, self.provider.embed(query))
263 .await
264 .map_err(|_| {
265 tracing::warn!(
266 embed_timeout_secs = self.config.embed_timeout_secs,
267 "embedding timed out"
268 );
269 crate::error::IndexError::EmbedTimeout(self.config.embed_timeout_secs)
270 })??;
271
272 // Normalize to unit length so Qdrant gRPC cosine search returns correct scores.
273 // Qdrant gRPC silently returns near-zero scores for unnormalized vectors (#3421).
274 let query_vector = EmbeddingVector::<Unnormalized>::new(raw_vector).normalize();
275
276 let mut hits = self
277 .store
278 .search(query_vector, self.config.max_chunks, language_filter)
279 .await?;
280
281 hits.retain(|h| h.score >= self.config.score_threshold);
282
283 let mut packed = Vec::new();
284 let mut used_tokens = 0;
285
286 for hit in hits {
287 let cost = self.token_counter.count_tokens(&hit.code) + 20;
288 if used_tokens + cost > token_budget {
289 break;
290 }
291 used_tokens += cost;
292 packed.push(hit);
293 }
294
295 Ok(packed)
296 }
297}
298
299/// Format retrieved code chunks as an XML `<code_context>` block.
300///
301/// The output is suitable for direct injection into the agent's user or assistant
302/// message. Each chunk is wrapped in a `<chunk>` element with `file`, `lines`,
303/// `name`, and `score` attributes.
304///
305/// Returns an empty string when `result.chunks` is empty so callers can append
306/// without adding unnecessary whitespace.
307///
308/// # Examples
309///
310/// ```
311/// use zeph_index::retriever::{RetrievedCode, RetrievalStrategy, format_as_context};
312/// use zeph_index::store::SearchHit;
313///
314/// let result = RetrievedCode {
315/// chunks: vec![SearchHit {
316/// code: "fn hello() {}".to_string(),
317/// file_path: "src/lib.rs".to_string(),
318/// line_range: (1, 1),
319/// score: 0.9,
320/// node_type: zeph_index::store::NodeKind::from("function_item"),
321/// language: zeph_index::languages::Lang::Rust,
322/// entity_name: Some("hello".to_string()),
323/// scope_chain: String::new(),
324/// }],
325/// total_tokens: 10,
326/// strategy: RetrievalStrategy::Semantic,
327/// };
328///
329/// let xml = format_as_context(&result);
330/// assert!(xml.starts_with("<code_context>"));
331/// assert!(xml.contains("file=\"src/lib.rs\""));
332/// assert!(xml.ends_with("</code_context>"));
333/// ```
334#[must_use]
335pub fn format_as_context(result: &RetrievedCode) -> String {
336 if result.chunks.is_empty() {
337 return String::new();
338 }
339
340 let mut out = String::from("<code_context>\n");
341
342 for chunk in &result.chunks {
343 let name = chunk
344 .entity_name
345 .as_deref()
346 .unwrap_or(chunk.node_type.as_ref());
347 let _ = writeln!(
348 out,
349 " <chunk file=\"{}\" lines=\"{}-{}\" name=\"{}\" score=\"{:.2}\">",
350 chunk.file_path, chunk.line_range.0, chunk.line_range.1, name, chunk.score,
351 );
352 out.push_str(&chunk.code);
353 out.push_str("\n </chunk>\n");
354 }
355
356 out.push_str("</code_context>");
357 out
358}
359
360/// Classify a free-text query to select the best retrieval strategy.
361///
362/// The heuristic looks for symbol-like patterns (Rust path syntax, `fn`/`struct`/`impl`
363/// keywords, `CamelCase` type names, `snake_case` identifiers) and conceptual signal
364/// words (`"how"`, `"explain"`, `"where"`, …).
365///
366/// | Signals present | Returned strategy |
367/// |-----------------|-------------------|
368/// | Symbol only | [`RetrievalStrategy::Grep`] |
369/// | Conceptual only | [`RetrievalStrategy::Semantic`] |
370/// | Both | [`RetrievalStrategy::Hybrid`] |
371/// | Neither | [`RetrievalStrategy::Semantic`] |
372///
373/// # Examples
374///
375/// ```
376/// use zeph_index::retriever::{RetrievalStrategy, classify_query};
377///
378/// assert_eq!(classify_query("how does retry logic work?"), RetrievalStrategy::Semantic);
379/// assert_eq!(classify_query("fn handle_request"), RetrievalStrategy::Grep);
380/// assert_eq!(classify_query("where is MyRouter defined?"), RetrievalStrategy::Hybrid);
381/// ```
382#[must_use]
383pub fn classify_query(query: &str) -> RetrievalStrategy {
384 let has_symbol_pattern = query.contains("::")
385 || query.contains("fn ")
386 || query.contains("struct ")
387 || query.contains("impl ")
388 || query.contains("trait ")
389 || query.contains("mod ")
390 || query.contains("class ")
391 || query.contains("def ")
392 || has_camel_case(query)
393 || has_snake_case_identifier(query);
394
395 let has_conceptual = query.contains("how")
396 || query.contains("where")
397 || query.contains("why")
398 || query.contains("find all")
399 || query.contains("explain")
400 || query.contains("what does")
401 || query.contains("show me");
402
403 match (has_symbol_pattern, has_conceptual) {
404 (true, true) => RetrievalStrategy::Hybrid,
405 (true, false) => RetrievalStrategy::Grep,
406 (false, _) => RetrievalStrategy::Semantic,
407 }
408}
409
410fn has_camel_case(text: &str) -> bool {
411 text.split_whitespace().any(|word| {
412 let chars: Vec<char> = word.chars().collect();
413 chars.len() >= 3
414 && chars[0].is_uppercase()
415 && chars.iter().any(|c| c.is_lowercase())
416 && chars.iter().skip(1).any(|c| c.is_uppercase())
417 })
418}
419
420fn has_snake_case_identifier(text: &str) -> bool {
421 text.split_whitespace().any(|word| {
422 word.len() >= 3
423 && word.contains('_')
424 && word.chars().all(|c| c.is_alphanumeric() || c == '_')
425 && word.starts_with(|c: char| c.is_lowercase())
426 })
427}
428
429fn budget_tokens(available: usize, ratio: f32) -> usize {
430 // Scale to per-mille to stay in integer arithmetic.
431 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
432 let per_mille = (ratio * 1000.0) as usize;
433 available.saturating_mul(per_mille) / 1000
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::store::{NodeKind, SearchHit};
440
441 #[test]
442 fn classify_symbol_query_rust() {
443 assert_eq!(
444 classify_query("find SkillMatcher::match_skills"),
445 RetrievalStrategy::Grep
446 );
447 }
448
449 #[test]
450 fn classify_conceptual_query() {
451 assert_eq!(
452 classify_query("how does skill matching work?"),
453 RetrievalStrategy::Semantic
454 );
455 }
456
457 #[test]
458 fn classify_mixed_query() {
459 assert_eq!(
460 classify_query("where is SkillMatcher used?"),
461 RetrievalStrategy::Hybrid
462 );
463 }
464
465 #[test]
466 fn classify_default_is_semantic() {
467 assert_eq!(classify_query("help"), RetrievalStrategy::Semantic);
468 }
469
470 #[test]
471 fn classify_snake_case_identifier() {
472 assert_eq!(classify_query("my_function"), RetrievalStrategy::Grep);
473 }
474
475 #[test]
476 fn camel_case_detection() {
477 assert!(has_camel_case("HttpClient"));
478 assert!(has_camel_case("find MyStruct"));
479 assert!(!has_camel_case("simple word"));
480 assert!(!has_camel_case("HTTP"));
481 assert!(!has_camel_case("ab"));
482 }
483
484 #[test]
485 fn snake_case_detection() {
486 assert!(has_snake_case_identifier("my_function"));
487 assert!(has_snake_case_identifier("call some_method here"));
488 assert!(!has_snake_case_identifier("NoSnake"));
489 assert!(has_snake_case_identifier("a_b"));
490 }
491
492 #[test]
493 fn format_as_context_empty() {
494 let result = RetrievedCode {
495 chunks: vec![],
496 total_tokens: 0,
497 strategy: RetrievalStrategy::Semantic,
498 };
499 assert_eq!(format_as_context(&result), "");
500 }
501
502 #[test]
503 fn format_as_context_xml() {
504 let result = RetrievedCode {
505 chunks: vec![SearchHit {
506 code: "fn hello() {}".to_string(),
507 file_path: "src/lib.rs".to_string(),
508 line_range: (1, 3),
509 score: 0.85,
510 node_type: NodeKind::from("function_item"),
511 language: crate::languages::Lang::Rust,
512 entity_name: Some("hello".to_string()),
513 scope_chain: String::new(),
514 }],
515 total_tokens: 10,
516 strategy: RetrievalStrategy::Semantic,
517 };
518 let xml = format_as_context(&result);
519 assert!(xml.contains("<code_context>"));
520 assert!(xml.contains("</code_context>"));
521 assert!(xml.contains("file=\"src/lib.rs\""));
522 assert!(xml.contains("name=\"hello\""));
523 assert!(xml.contains("score=\"0.85\""));
524 assert!(xml.contains("fn hello() {}"));
525 }
526
527 #[test]
528 fn snake_case_a_b_three_chars_passes() {
529 assert!(has_snake_case_identifier("a_b"));
530 }
531
532 #[test]
533 fn budget_tokens_ratio_zero() {
534 assert_eq!(budget_tokens(10_000, 0.0), 0);
535 }
536
537 #[test]
538 fn budget_tokens_ratio_one() {
539 assert_eq!(budget_tokens(10_000, 1.0), 10_000);
540 }
541
542 #[test]
543 fn budget_tokens_ratio_half() {
544 assert_eq!(budget_tokens(8_000, 0.5), 4_000);
545 }
546
547 #[test]
548 fn budget_tokens_zero_available() {
549 assert_eq!(budget_tokens(0, 0.4), 0);
550 }
551
552 #[test]
553 fn format_as_context_uses_node_type_when_no_entity_name() {
554 let result = RetrievedCode {
555 chunks: vec![SearchHit {
556 code: "struct Foo {}".to_string(),
557 file_path: "src/foo.rs".to_string(),
558 line_range: (1, 2),
559 score: 0.75,
560 node_type: NodeKind::from("struct_item"),
561 language: crate::languages::Lang::Rust,
562 entity_name: None,
563 scope_chain: String::new(),
564 }],
565 total_tokens: 5,
566 strategy: RetrievalStrategy::Semantic,
567 };
568 let xml = format_as_context(&result);
569 assert!(xml.contains("name=\"struct_item\""));
570 }
571
572 #[test]
573 fn classify_fn_keyword_is_grep() {
574 assert_eq!(classify_query("fn my_func"), RetrievalStrategy::Grep);
575 }
576
577 #[test]
578 fn classify_struct_keyword_is_grep() {
579 assert_eq!(classify_query("struct MyType"), RetrievalStrategy::Grep);
580 }
581
582 #[test]
583 fn classify_explain_conceptual_is_semantic() {
584 assert_eq!(
585 classify_query("explain the architecture"),
586 RetrievalStrategy::Semantic
587 );
588 }
589
590 #[test]
591 fn retrieval_strategy_debug() {
592 assert_eq!(format!("{:?}", RetrievalStrategy::Semantic), "Semantic");
593 assert_eq!(format!("{:?}", RetrievalStrategy::Grep), "Grep");
594 assert_eq!(format!("{:?}", RetrievalStrategy::Hybrid), "Hybrid");
595 }
596
597 #[test]
598 fn retrieval_config_defaults() {
599 let cfg = RetrievalConfig::default();
600 assert_eq!(cfg.max_chunks, 12);
601 assert!(cfg.score_threshold > 0.0);
602 assert!(cfg.budget_ratio > 0.0 && cfg.budget_ratio < 1.0);
603 assert_eq!(cfg.embedding_provider, "");
604 }
605}