Skip to content

Commit e583086

Browse files
committed
feat(search): add LLM-first search with direct TOC-based location
Add new LLM-first search capability that attempts to directly locate relevant nodes using the table of contents before falling back to tree traversal algorithms. The search stage now accepts an LLM client and implements direct TOC-based node location with structured JSON responses containing top-3 most relevant entries. The feature includes: - New `with_llm_client()` method to configure LLM-based search - TOC flattening utility for LLM consumption with numbered entries - Structured JSON prompting for precise node selection - Proper fallback to beam/greedy search when LLM fails - Metrics tracking for LLM calls and search performance refactor(indexer): change default summary strategy to full Change the default summary strategy from selective to full generation for improved content indexing quality. debug: add debug logging throughout indexing and retrieval Add comprehensive debug logging to track indexing flow, pipeline options building, summary evaluation, and search operations including pilot interventions for better debugging visibility.
1 parent 5d2757d commit e583086

9 files changed

Lines changed: 227 additions & 5 deletions

File tree

rust/src/client/engine.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ impl Engine {
214214
/// # }
215215
/// ```
216216
pub async fn index(&self, ctx: IndexContext) -> Result<String> {
217+
println!("Indexing...");
218+
println!("ctx: {:?}", ctx);
219+
217220
let doc = self.indexer.index(ctx).await?;
218221
let persisted = self.indexer.to_persisted(doc);
219222

rust/src/client/indexer.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ impl IndexerClient {
259259
options: &IndexOptions,
260260
format: DocumentFormat,
261261
) -> PipelineOptions {
262+
println!("[DEBUG] Building pipeline options for format: {:?} with options: {:?}", format, options);
263+
262264
PipelineOptions {
263265
mode: match format {
264266
DocumentFormat::Markdown => IndexMode::Markdown,
@@ -268,7 +270,8 @@ impl IndexerClient {
268270
},
269271
generate_ids: options.generate_ids,
270272
summary_strategy: if options.generate_summaries {
271-
SummaryStrategy::selective(self.config.min_summary_tokens, false)
273+
// SummaryStrategy::selective(self.config.min_summary_tokens, false)
274+
SummaryStrategy::full()
272275
} else {
273276
SummaryStrategy::none()
274277
},

rust/src/index/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ impl Default for PipelineOptions {
160160
Self {
161161
mode: IndexMode::Auto,
162162
generate_ids: true,
163-
summary_strategy: SummaryStrategy::default(),
163+
summary_strategy: SummaryStrategy::full(),
164164
thinning: ThinningConfig::default(),
165165
optimization: OptimizationConfig::default(),
166166
generate_description: true,

rust/src/index/stages/enhance.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ impl IndexStage for EnhanceStage {
161161
Some(n) => n.clone(),
162162
None => continue,
163163
};
164+
println!("[DEBUG] Evaluating node for summary: {} {}", node.title, node.content);
164165

165166
// Skip if no content
166167
if node.content.is_empty() {
@@ -204,6 +205,8 @@ impl IndexStage for EnhanceStage {
204205

205206
// Generate summary (generator also has memoization built-in)
206207
println!("[DEBUG] Calling LLM to generate summary for node: {} ({} tokens)", node.title, token_count);
208+
println!("[DEBUG] Node content: {}", node.content);
209+
207210
match generator.generate(&node.title, &node.content).await {
208211
Ok(summary) => {
209212
if summary.is_empty() {

rust/src/retrieval/pipeline_retriever.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ impl PipelineRetriever {
119119
orchestrator = orchestrator.stage(plan_stage);
120120

121121
// Add search stage with Pilot for semantic navigation
122-
let mut search_stage = SearchStage::new();
122+
let mut search_stage = SearchStage::new().with_llm_client(self.llm_client.clone());
123123
if let Some(ref client) = self.llm_client {
124124
// Create LLM-based Pilot for semantic navigation guidance
125125
let mut pilot = LlmPilot::new(client.clone(), PilotConfig::default());

rust/src/retrieval/search/beam.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ impl SearchTree for BeamSearch {
236236
children.len()
237237
);
238238

239+
println!("[DEBUG] BEAM SEARCH: Pilot intervening at decision point");
239240
match p.decide(&state).await {
240241
decision => {
241242
pilot_interventions += 1;

rust/src/retrieval/search/greedy.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ impl SearchTree for GreedySearch {
144144
children.len()
145145
);
146146

147+
println!("[DEBUG] GREEDY SEARCH: Pilot intervening at decision point");
147148
match p.decide(&state).await {
148149
decision => {
149150
pilot_interventions += 1;

rust/src/retrieval/stages/evaluate.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ impl RetrievalStage for EvaluateStage {
331331

332332
println!("[DEBUG] EvaluateStage: {} candidates, iteration {}",
333333
ctx.candidates.len(), ctx.search_iterations);
334+
334335
info!(
335336
"Judging sufficiency: {} candidates, iteration {}",
336337
ctx.candidates.len(),

rust/src/retrieval/stages/search.rs

Lines changed: 212 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,21 @@
66
//! This stage executes the selected search algorithm using
77
//! the selected retrieval strategy. When a Pilot is provided,
88
//! it can provide semantic guidance at key decision points.
9+
//!
10+
//! # LLM-First Search
11+
//!
12+
//! When an LLM client is provided, the stage will first attempt to
13+
//! directly locate the top-3 most relevant nodes using the TOC,
14+
//! falling back to tree traversal algorithms (Beam/Greedy) only if
15+
//! LLM fails or returns insufficient results.
916
1017
use async_trait::async_trait;
18+
use serde::{Deserialize, Serialize};
1119
use std::sync::Arc;
1220
use tracing::{info, warn};
1321

14-
use crate::document::DocumentTree;
15-
// LlmClient is used via strategy
22+
use crate::document::{DocumentTree, TocView};
23+
use crate::llm::LlmClient;
1624
use crate::retrieval::RetrievalContext; // Legacy context
1725
use crate::retrieval::pilot::Pilot;
1826
use crate::retrieval::pipeline::{
@@ -57,6 +65,8 @@ pub struct SearchStage {
5765
hybrid_strategy: Option<Arc<dyn RetrievalStrategy>>,
5866
/// Pilot for navigation guidance (optional).
5967
pilot: Option<Arc<dyn Pilot>>,
68+
/// LLM client for direct TOC-based search (optional).
69+
llm_client: Option<LlmClient>,
6070
}
6171

6272
impl Default for SearchStage {
@@ -74,9 +84,20 @@ impl SearchStage {
7484
semantic_strategy: None,
7585
hybrid_strategy: None,
7686
pilot: None,
87+
llm_client: None,
7788
}
7889
}
7990

91+
/// Add LLM client for direct TOC-based search.
92+
///
93+
/// When provided, the stage will first attempt to locate relevant
94+
/// nodes directly using the TOC, falling back to tree traversal
95+
/// algorithms only if LLM fails or returns insufficient results.
96+
pub fn with_llm_client(mut self, client: Option<LlmClient>) -> Self {
97+
self.llm_client = client;
98+
self
99+
}
100+
80101
/// Add Pilot for semantic navigation guidance.
81102
///
82103
/// When provided, the search algorithm will consult the Pilot
@@ -210,6 +231,172 @@ impl SearchStage {
210231

211232
candidates
212233
}
234+
235+
/// Build a flat TOC list for LLM consumption.
236+
///
237+
/// Returns a formatted string with numbered entries:
238+
/// ```
239+
/// [1] Title: "Overview"
240+
/// Summary: "This section covers..."
241+
/// [2] Title: "Architecture"
242+
/// Summary: "The system architecture..."
243+
/// ```
244+
fn build_toc_for_llm(&self, tree: &DocumentTree) -> (String, Vec<crate::document::NodeId>) {
245+
let toc_view = TocView::new();
246+
let mut entries = Vec::new();
247+
let mut node_ids = Vec::new();
248+
249+
fn collect_entries(
250+
tree: &DocumentTree,
251+
node_id: crate::document::NodeId,
252+
entries: &mut Vec<(usize, String, String)>,
253+
node_ids: &mut Vec<crate::document::NodeId>,
254+
index: &mut usize,
255+
) {
256+
if let Some(node) = tree.get(node_id) {
257+
let title = node.title.clone();
258+
let summary = if node.summary.is_empty() {
259+
"(no summary)".to_string()
260+
} else {
261+
node.summary.clone()
262+
};
263+
entries.push((*index, title, summary));
264+
node_ids.push(node_id);
265+
*index += 1;
266+
267+
for child_id in tree.children(node_id) {
268+
collect_entries(tree, child_id, entries, node_ids, index);
269+
}
270+
}
271+
}
272+
273+
collect_entries(tree, tree.root(), &mut entries, &mut node_ids, &mut 0);
274+
275+
let toc_str = entries
276+
.iter()
277+
.map(|(idx, title, summary)| {
278+
format!("[{}] Title: \"{}\"\n Summary: \"{}\"", idx + 1, title, summary)
279+
})
280+
.collect::<Vec<_>>()
281+
.join("\n\n");
282+
283+
(toc_str, node_ids)
284+
}
285+
286+
/// Locate top candidates directly via LLM using TOC.
287+
///
288+
/// This method bypasses tree traversal by asking the LLM to
289+
/// directly identify the most relevant nodes from the TOC.
290+
async fn locate_via_llm(
291+
&self,
292+
query: &str,
293+
tree: &DocumentTree,
294+
) -> Option<Vec<CandidateNode>> {
295+
let llm_client = self.llm_client.as_ref()?;
296+
let (toc_str, node_ids) = self.build_toc_for_llm(tree);
297+
298+
if node_ids.is_empty() {
299+
warn!("No nodes in tree for LLM search");
300+
return None;
301+
}
302+
303+
let system_prompt = r#"You are a document navigation assistant. Your task is to locate the most relevant sections in a document hierarchy for a user's query.
304+
305+
CRITICAL INSTRUCTIONS:
306+
1. Analyze the user query carefully to understand the intent
307+
2. Examine the provided Table of Contents (TOC) with numbered entries
308+
3. Select the TOP 3 most relevant entries that would contain the answer
309+
4. You MUST respond with ONLY valid JSON. No markdown code blocks. No explanations outside JSON.
310+
311+
Your response must have this EXACT structure:
312+
{
313+
"reasoning": "Brief analysis of the query and why you selected these entries",
314+
"candidates": [
315+
{"node_id": 1, "relevance_score": 0.95, "reason": "Why this entry matches the query"},
316+
{"node_id": 2, "relevance_score": 0.80, "reason": "Why this entry is also relevant"},
317+
{"node_id": 3, "relevance_score": 0.65, "reason": "Why this entry might be relevant"}
318+
]
319+
}
320+
321+
Rules:
322+
- node_id: MUST be a number from the provided TOC (the number in [N] brackets)
323+
- relevance_score: Number between 0.0 and 1.0 (higher = more relevant)
324+
- reason: Brief explanation for each selection
325+
- candidates: Must have exactly 3 items, ordered by relevance (highest first)"#;
326+
327+
let user_prompt = format!(
328+
"USER QUERY: {}\n\nDOCUMENT TOC ({} entries):\n{}\n\nBased on the query and TOC above, select the TOP 3 most relevant entries.\n\nRespond with ONLY the JSON object:",
329+
query,
330+
node_ids.len(),
331+
toc_str
332+
);
333+
334+
info!("Attempting LLM-based search for query: '{}'", query);
335+
336+
match llm_client.complete(system_prompt, &user_prompt).await {
337+
Ok(response) => {
338+
// Parse JSON response
339+
match serde_json::from_str::<LlmLocateResponse>(&response) {
340+
Ok(llm_response) => {
341+
let mut candidates = Vec::new();
342+
343+
for candidate in llm_response.candidates {
344+
// node_id is 1-indexed from LLM, convert to 0-indexed
345+
let idx = candidate.node_id.saturating_sub(1);
346+
if idx < node_ids.len() {
347+
let node_id = node_ids[idx];
348+
if let Some(node) = tree.get(node_id) {
349+
candidates.push(CandidateNode::new(
350+
node_id,
351+
candidate.relevance_score,
352+
node.depth,
353+
tree.is_leaf(node_id),
354+
));
355+
info!(
356+
"LLM selected: [{}] '{}' (score: {:.2})",
357+
candidate.node_id, node.title, candidate.relevance_score
358+
);
359+
}
360+
}
361+
}
362+
363+
if candidates.is_empty() {
364+
warn!("LLM returned no valid candidates");
365+
return None;
366+
}
367+
368+
println!("LLM search found {} candidates", candidates.len());
369+
println!("LLM candidates content: {:?}", candidates);
370+
Some(candidates)
371+
}
372+
Err(e) => {
373+
warn!("Failed to parse LLM response as JSON: {}", e);
374+
warn!("Raw response: {}", response);
375+
None
376+
}
377+
}
378+
}
379+
Err(e) => {
380+
warn!("LLM call failed: {}", e);
381+
None
382+
}
383+
}
384+
}
385+
}
386+
387+
/// LLM response for locate query.
388+
#[derive(Debug, Clone, Serialize, Deserialize)]
389+
struct LlmLocateResponse {
390+
reasoning: String,
391+
candidates: Vec<LlmLocateCandidate>,
392+
}
393+
394+
/// A candidate from LLM locate response.
395+
#[derive(Debug, Clone, Serialize, Deserialize)]
396+
struct LlmLocateCandidate {
397+
node_id: usize,
398+
relevance_score: f32,
399+
reason: String,
213400
}
214401

215402
#[async_trait]
@@ -264,6 +451,29 @@ impl RetrievalStage for SearchStage {
264451
// Increment search iteration
265452
ctx.increment_search_iteration();
266453

454+
// === Try LLM-first search (direct TOC-based location) ===
455+
if self.llm_client.is_some() {
456+
info!("Attempting LLM-first search for query: '{}'", ctx.query);
457+
458+
if let Some(candidates) = self.locate_via_llm(&ctx.query, &ctx.tree).await {
459+
if !candidates.is_empty() {
460+
ctx.candidates = candidates;
461+
ctx.metrics.search_time_ms += start.elapsed().as_millis() as u64;
462+
ctx.metrics.nodes_visited += ctx.candidates.len();
463+
ctx.metrics.llm_calls += 1;
464+
465+
info!(
466+
"LLM-first search found {} candidates (skipped tree traversal)",
467+
ctx.candidates.len()
468+
);
469+
470+
return Ok(StageOutcome::cont());
471+
}
472+
}
473+
474+
info!("LLM-first search returned no results, falling back to tree traversal");
475+
}
476+
267477
// Build search config for search algorithms
268478
let search_config = SearchAlgConfig {
269479
top_k: config.beam_width * 2,

0 commit comments

Comments
 (0)