Skip to content

Commit f29d769

Browse files
committed
refactor(retrieval): improve ToC navigator with better naming and fallback logic
- Rename level_0_nodes top_level_nodes for clarity - Simplify BM25 scoring phase description in docs - Split document imports for better organization - Add fallback mechanism when no branches pass threshold - Improve LLM refinement by collecting tree entries directly - Add depth control for tree traversal in LLM refinement - Remove unused TocView dependency fix(retrieval): enhance search stage with candidate restoration - Use depth-1 nodes instead of level-0 for top-level sections - Restore cue root nodes as direct candidates during traversal - Add sorting and deduplication for search candidates - Improve debugging output for search iterations
1 parent 21e8717 commit f29d769

2 files changed

Lines changed: 99 additions & 55 deletions

File tree

rust/src/retrieval/search/toc_navigator.rs

Lines changed: 69 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44
//! Hierarchical ToC-based node locator.
55
//!
66
//! Replaces the monolithic `build_toc_for_llm` with a two-phase approach:
7-
//! - Phase A: BM25 scoring on level-0 (top-level) nodes for fast filtering
7+
//! - Phase A: BM25 scoring on top-level nodes for fast filtering
88
//! - Phase B: Optional LLM refinement when top scores are below a threshold
99
1010
use std::sync::Arc;
1111

1212
use serde::Deserialize;
1313
use tracing::{debug, info, warn};
1414

15-
use crate::document::{DocumentTree, NodeId, TocView};
15+
use crate::document::DocumentTree;
16+
use crate::document::NodeId;
1617
use crate::llm::LlmClient;
1718
use crate::memo::MemoStore;
1819
use crate::retrieval::search::scorer::NodeScorer;
@@ -82,9 +83,9 @@ impl ToCNavigator {
8283
&self,
8384
query: &str,
8485
tree: &DocumentTree,
85-
level_0_nodes: &[NodeId],
86+
top_level_nodes: &[NodeId],
8687
) -> Vec<SearchCue> {
87-
if level_0_nodes.is_empty() {
88+
if top_level_nodes.is_empty() {
8889
return vec![SearchCue {
8990
root: tree.root(),
9091
confidence: 0.5,
@@ -93,7 +94,7 @@ impl ToCNavigator {
9394

9495
// Phase A: BM25 scoring
9596
let scorer = NodeScorer::for_query(query);
96-
let scored: Vec<(NodeId, f32)> = level_0_nodes
97+
let scored: Vec<(NodeId, f32)> = top_level_nodes
9798
.iter()
9899
.map(|&id| (id, scorer.score(tree, id)))
99100
.filter(|(_, s)| *s > 0.05)
@@ -102,8 +103,8 @@ impl ToCNavigator {
102103
let top_branches = take_top_n(scored, self.max_branches);
103104

104105
debug!(
105-
"ToCNavigator Phase A: {} level-0 nodes scored, top {} kept",
106-
level_0_nodes.len(),
106+
"ToCNavigator Phase A: {} top-level nodes scored, {} kept after filter",
107+
top_level_nodes.len(),
107108
top_branches.len()
108109
);
109110

@@ -116,11 +117,20 @@ impl ToCNavigator {
116117
best_score, self.llm_threshold
117118
);
118119
return self
119-
.llm_refine(query, tree, &top_branches, client)
120+
.llm_refine(query, tree, top_level_nodes, client)
120121
.await;
121122
}
122123
}
123124

125+
// Fallback: if no branches passed the filter, search from root
126+
if top_branches.is_empty() {
127+
debug!("ToCNavigator: no branches above threshold, falling back to root");
128+
return vec![SearchCue {
129+
root: tree.root(),
130+
confidence: 0.5,
131+
}];
132+
}
133+
124134
// Return BM25 results as cues
125135
top_branches
126136
.into_iter()
@@ -131,32 +141,33 @@ impl ToCNavigator {
131141
.collect()
132142
}
133143

134-
/// Phase B: Ask the LLM to refine branch selection.
144+
/// Phase B: Ask the LLM to pick the most relevant subtrees.
145+
///
146+
/// Presents the full top-level TOC to the LLM and lets it select the
147+
/// most relevant entries. Uses direct tree traversal so that we can
148+
/// correctly map LLM-selected indices back to real NodeIds.
135149
async fn llm_refine(
136150
&self,
137151
query: &str,
138152
tree: &DocumentTree,
139-
top_branches: &[(NodeId, f32)],
153+
top_level_nodes: &[NodeId],
140154
client: &LlmClient,
141155
) -> Vec<SearchCue> {
142-
let toc_view = TocView::new();
143-
let mut toc_entries = Vec::new();
144-
let mut node_ids = Vec::new();
156+
// Collect (title, summary) and the corresponding NodeId directly
157+
// from the tree, maintaining index correspondence for LLM response mapping.
158+
let mut toc_entries: Vec<(String, Option<String>)> = Vec::new();
159+
let mut node_ids: Vec<NodeId> = Vec::new();
145160

146-
for &(node_id, _) in top_branches {
147-
let sub_toc = toc_view.generate_from(tree, node_id);
148-
collect_toc_flat(&sub_toc, &mut toc_entries, &mut node_ids);
161+
for &node_id in top_level_nodes {
162+
collect_tree_entries(tree, node_id, &mut toc_entries, &mut node_ids, 0, 2);
149163
}
150164

151165
if node_ids.is_empty() {
152-
warn!("LLM refinement: no nodes collected from top branches");
153-
return top_branches
154-
.iter()
155-
.map(|&(node_id, score)| SearchCue {
156-
root: node_id,
157-
confidence: score,
158-
})
159-
.collect();
166+
warn!("LLM refinement: no nodes collected from top-level branches");
167+
return vec![SearchCue {
168+
root: tree.root(),
169+
confidence: 0.5,
170+
}];
160171
}
161172

162173
let toc_str = toc_entries
@@ -221,13 +232,10 @@ Rules:
221232

222233
if cues.is_empty() {
223234
warn!("LLM refinement returned no valid candidates, falling back to BM25");
224-
return top_branches
225-
.iter()
226-
.map(|&(node_id, score)| SearchCue {
227-
root: node_id,
228-
confidence: score,
229-
})
230-
.collect();
235+
return vec![SearchCue {
236+
root: tree.root(),
237+
confidence: 0.5,
238+
}];
231239
}
232240

233241
info!(
@@ -238,14 +246,11 @@ Rules:
238246
cues
239247
}
240248
Err(e) => {
241-
warn!("LLM refinement failed: {}, falling back to BM25", e);
242-
top_branches
243-
.iter()
244-
.map(|&(node_id, score)| SearchCue {
245-
root: node_id,
246-
confidence: score,
247-
})
248-
.collect()
249+
warn!("LLM refinement failed: {}, falling back to root", e);
250+
vec![SearchCue {
251+
root: tree.root(),
252+
confidence: 0.5,
253+
}]
249254
}
250255
}
251256
}
@@ -259,20 +264,34 @@ fn take_top_n(scored: Vec<(NodeId, f32)>, n: usize) -> Vec<(NodeId, f32)> {
259264
sorted
260265
}
261266

262-
/// Recursively collect ToC entries into flat lists for LLM consumption.
263-
fn collect_toc_flat(
264-
toc: &crate::document::TocNode,
267+
/// Collect tree entries (title, summary) alongside their NodeIds.
268+
///
269+
/// Walks the subtree rooted at `node_id` up to `max_depth` levels deep.
270+
/// The `toc_entries[i]` ↔ `node_ids[i]` correspondence is guaranteed,
271+
/// so LLM response indices can be mapped back to real NodeIds.
272+
fn collect_tree_entries(
273+
tree: &DocumentTree,
274+
node_id: NodeId,
265275
entries: &mut Vec<(String, Option<String>)>,
266276
node_ids: &mut Vec<NodeId>,
277+
depth: usize,
278+
max_depth: usize,
267279
) {
268-
// Parse node_id string back to NodeId — but TocNode stores it as Option<String>.
269-
// Since we only need the original NodeId from the tree, we store a placeholder.
270-
// The actual mapping is handled by the caller (top_branches -> node_ids).
271-
// For LLM refinement, we index into node_ids, so we just push in order.
272-
entries.push((toc.title.clone(), toc.summary.clone()));
273-
// Note: node_ids are populated separately by the caller using tree traversal
274-
for child in &toc.children {
275-
collect_toc_flat(child, entries, node_ids);
280+
if depth > max_depth {
281+
return;
282+
}
283+
if let Some(node) = tree.get(node_id) {
284+
let summary = if node.summary.is_empty() {
285+
None
286+
} else {
287+
Some(node.summary.clone())
288+
};
289+
entries.push((node.title.clone(), summary));
290+
node_ids.push(node_id);
291+
292+
for child_id in tree.children(node_id) {
293+
collect_tree_entries(tree, child_id, entries, node_ids, depth + 1, max_depth);
294+
}
276295
}
277296
}
278297

@@ -296,7 +315,6 @@ struct LocateCandidate {
296315
mod tests {
297316
#[test]
298317
fn test_take_top_n_logic() {
299-
// take_top_n is a trivial sort+truncate — verify the ordering contract.
300318
let mut scored: Vec<(u32, f32)> = vec![(0, 0.1), (1, 0.9), (2, 0.5), (3, 0.3)];
301319
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
302320
scored.truncate(2);

rust/src/retrieval/stages/search.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,16 +347,18 @@ impl RetrievalStage for SearchStage {
347347
ctx.increment_search_iteration();
348348

349349
// === Phase Locate: find relevant subtrees via ToC ===
350-
let level_0_nodes: Vec<_> = ctx
350+
// Use depth-1 nodes (root's direct children = top-level sections).
351+
// level(0) is only the root itself, which is not useful for locating.
352+
let top_level_nodes: Vec<_> = ctx
351353
.retrieval_index
352354
.as_ref()
353-
.and_then(|idx| idx.level(0))
355+
.and_then(|idx| idx.level(1))
354356
.map(|nodes| nodes.to_vec())
355357
.unwrap_or_else(|| ctx.tree.children(ctx.tree.root()));
356358

357359
let cues = self
358360
.toc_navigator
359-
.locate(&ctx.query, &ctx.tree, &level_0_nodes)
361+
.locate(&ctx.query, &ctx.tree, &top_level_nodes)
360362
.await;
361363

362364
debug!("ToCNavigator returned {} cues", cues.len());
@@ -365,7 +367,31 @@ impl RetrievalStage for SearchStage {
365367
let queries = Self::resolve_queries(ctx);
366368

367369
// === Phase Traverse + Collect ===
368-
let (paths, candidates) = self.run_search(ctx, &queries, &cues).await;
370+
let (paths, mut candidates) = self.run_search(ctx, &queries, &cues).await;
371+
372+
// Add cue root nodes as direct candidates.
373+
// The ToCNavigator already identified these as relevant; they may not
374+
// be leaf nodes so tree traversal would skip them. This restores the
375+
// old locate_via_llm behavior where LLM-selected nodes became
376+
// candidates directly.
377+
for cue in &cues {
378+
if let Some(node) = ctx.tree.get(cue.root) {
379+
candidates.push(CandidateNode::new(
380+
cue.root,
381+
cue.confidence,
382+
node.depth,
383+
ctx.tree.is_leaf(cue.root),
384+
));
385+
}
386+
}
387+
388+
// Sort by score and deduplicate
389+
candidates.sort_by(|a, b| {
390+
b.score
391+
.partial_cmp(&a.score)
392+
.unwrap_or(std::cmp::Ordering::Equal)
393+
});
394+
candidates.dedup_by(|a, b| a.node_id == b.node_id);
369395

370396
ctx.search_paths = paths;
371397
ctx.candidates = candidates;

0 commit comments

Comments
 (0)