Skip to content

Commit 9f54ab9

Browse files
authored
Merge pull request #27 from vectorlessflow/feat-retrieval-system
feat(search): add search_from functionality and ToC-based navigation
2 parents 654447c + 45fd646 commit 9f54ab9

7 files changed

Lines changed: 606 additions & 483 deletions

File tree

docs/design/logo-horizontal.svg

Lines changed: 8 additions & 27 deletions
Loading

rust/src/retrieval/search/beam.rs

Lines changed: 58 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -105,89 +105,79 @@ impl BeamSearch {
105105

106106
merged
107107
}
108-
}
109-
110-
impl Default for BeamSearch {
111-
fn default() -> Self {
112-
Self::new()
113-
}
114-
}
115108

116-
#[async_trait]
117-
impl SearchTree for BeamSearch {
118-
async fn search(
109+
/// Core beam search logic parameterized by start node.
110+
///
111+
/// This is the shared implementation used by both `search` (starts from root)
112+
/// and `search_from` (starts from an arbitrary node).
113+
async fn search_impl(
119114
&self,
120115
tree: &DocumentTree,
121116
context: &RetrievalContext,
122117
config: &SearchConfig,
123118
pilot: Option<&dyn Pilot>,
119+
start_node: NodeId,
124120
) -> SearchResult {
125121
let mut result = SearchResult::default();
126122
let beam_width = config.beam_width.min(self.beam_width);
127123
let mut visited: HashSet<NodeId> = HashSet::new();
128124

129-
println!("[DEBUG] BeamSearch: query='{}', beam_width={}, min_score={:.2}",
130-
context.query, beam_width, config.min_score);
125+
// Mark start_node as visited so we don't go back up
126+
visited.insert(start_node);
127+
128+
debug!(
129+
"BeamSearch: query='{}', start_node={:?}, beam_width={}, min_score={:.2}",
130+
context.query, start_node, beam_width, config.min_score
131+
);
131132

132133
// Track Pilot interventions
133134
let mut pilot_interventions = 0;
134135

135-
// Initialize with root's children
136-
let root_children = tree.children(tree.root());
137-
println!("[DEBUG] Root has {} children", root_children.len());
136+
// Initialize with start_node's children
137+
let start_children = tree.children(start_node);
138+
debug!("Start node has {} children", start_children.len());
138139

139140
// Check if Pilot wants to guide the start
140141
let initial_candidates = if let Some(p) = pilot {
141-
println!("[DEBUG] BeamSearch: Pilot is available, name={}, guide_at_start={}",
142-
p.name(), p.config().guide_at_start);
142+
debug!(
143+
"BeamSearch: Pilot is available, name={}, guide_at_start={}",
144+
p.name(),
145+
p.config().guide_at_start
146+
);
143147
if p.config().guide_at_start {
144-
println!("[DEBUG] BeamSearch: Calling pilot.guide_start()...");
145148
if let Some(guidance) = p.guide_start(tree, &context.query).await {
146149
debug!(
147150
"Pilot provided start guidance with confidence {}",
148151
guidance.confidence
149152
);
150153
pilot_interventions += 1;
151-
println!("[DEBUG] BeamSearch: Pilot returned guidance! confidence={:.2}, candidates={}",
152-
guidance.confidence, guidance.ranked_candidates.len());
153154

154-
// Use Pilot's ranked order if available
155155
if guidance.has_candidates() {
156156
self.merge_with_pilot_decision(
157157
tree,
158-
&root_children,
158+
&start_children,
159159
&guidance,
160160
&context.query,
161161
)
162162
} else {
163-
println!("[DEBUG] BeamSearch: Guidance has no candidates, using algorithm scoring");
164-
self.score_candidates_with_query(tree, &root_children, &context.query)
163+
self.score_candidates_with_query(tree, &start_children, &context.query)
165164
}
166165
} else {
167-
println!("[DEBUG] BeamSearch: pilot.guide_start() returned None");
168-
self.score_candidates_with_query(tree, &root_children, &context.query)
166+
self.score_candidates_with_query(tree, &start_children, &context.query)
169167
}
170168
} else {
171-
println!("[DEBUG] BeamSearch: guide_at_start=false, skipping Pilot");
172-
self.score_candidates_with_query(tree, &root_children, &context.query)
169+
self.score_candidates_with_query(tree, &start_children, &context.query)
173170
}
174171
} else {
175-
println!("[DEBUG] BeamSearch: No Pilot available");
176-
self.score_candidates_with_query(tree, &root_children, &context.query)
172+
self.score_candidates_with_query(tree, &start_children, &context.query)
177173
};
178174

179175
let mut current_beam: Vec<SearchPath> = initial_candidates
180176
.into_iter()
181177
.map(|(node_id, score)| SearchPath::from_node(node_id, score))
182178
.collect();
183179

184-
// Debug: show initial scores
185-
println!("[DEBUG] Initial {} candidates after scoring", current_beam.len());
186-
for (i, path) in current_beam.iter().enumerate().take(5) {
187-
if let Some(node) = tree.get(path.leaf.unwrap_or(tree.root())) {
188-
println!("[DEBUG] Initial {}: score={:.3}, title='{}'", i, path.score, node.title);
189-
}
190-
}
180+
debug!("Initial {} candidates after scoring", current_beam.len());
191181

192182
// Keep top beam_width
193183
current_beam.truncate(beam_width);
@@ -207,7 +197,6 @@ impl SearchTree for BeamSearch {
207197

208198
// Check if this is a leaf node
209199
if tree.is_leaf(leaf_id) {
210-
// Add to final results
211200
if path.score >= config.min_score {
212201
result.paths.push(path.clone());
213202
}
@@ -220,7 +209,6 @@ impl SearchTree for BeamSearch {
220209

221210
// ========== Pilot Intervention Point ==========
222211
let scored_children = if let Some(p) = pilot {
223-
// Build search state for Pilot
224212
let state = SearchState::new(
225213
tree,
226214
&context.query,
@@ -229,14 +217,12 @@ impl SearchTree for BeamSearch {
229217
&visited,
230218
);
231219

232-
// Check if Pilot wants to intervene
233220
if p.should_intervene(&state) {
234221
trace!(
235222
"Pilot intervening at fork with {} candidates",
236223
children.len()
237224
);
238225

239-
println!("[DEBUG] BEAM SEARCH: Pilot intervening at decision point");
240226
match p.decide(&state).await {
241227
decision => {
242228
pilot_interventions += 1;
@@ -246,7 +232,6 @@ impl SearchTree for BeamSearch {
246232
std::mem::discriminant(&decision.direction)
247233
);
248234

249-
// Merge algorithm scores with Pilot decision
250235
self.merge_with_pilot_decision(
251236
tree,
252237
&children,
@@ -256,19 +241,16 @@ impl SearchTree for BeamSearch {
256241
}
257242
}
258243
} else {
259-
// No intervention, use algorithm scoring
260244
self.score_candidates_with_query(tree, &children, &context.query)
261245
}
262246
} else {
263-
// No Pilot, use algorithm scoring
264247
self.score_candidates_with_query(tree, &children, &context.query)
265248
};
266249
// ==============================================
267250

268251
for (child_id, child_score) in scored_children.into_iter().take(beam_width) {
269252
let new_path = path.extend(child_id, child_score);
270253

271-
// Record trace
272254
let child_node = tree.get(child_id);
273255
result.trace.push(NavigationStep {
274256
node_id: format!("{:?}", child_id),
@@ -296,7 +278,6 @@ impl SearchTree for BeamSearch {
296278

297279
current_beam = next_beam;
298280

299-
// Check if we have enough results
300281
if result.paths.len() >= config.top_k {
301282
break;
302283
}
@@ -312,9 +293,8 @@ impl SearchTree for BeamSearch {
312293
// Fallback: if no results found, add best candidates regardless of score
313294
if result.paths.is_empty() && config.min_score > 0.0 {
314295
debug!("No results above min_score, adding best candidates as fallback");
315-
// Re-score initial candidates and take top-k
316296
let all_candidates =
317-
self.score_candidates_with_query(tree, &tree.children(tree.root()), &context.query);
297+
self.score_candidates_with_query(tree, &tree.children(start_node), &context.query);
318298
for (node_id, score) in all_candidates.into_iter().take(config.top_k) {
319299
result.paths.push(SearchPath::from_node(node_id, score));
320300
}
@@ -328,11 +308,40 @@ impl SearchTree for BeamSearch {
328308
});
329309
result.paths.truncate(config.top_k);
330310

331-
// Record Pilot interventions
332311
result.pilot_interventions = pilot_interventions;
333312

334313
result
335314
}
315+
}
316+
317+
impl Default for BeamSearch {
318+
fn default() -> Self {
319+
Self::new()
320+
}
321+
}
322+
323+
#[async_trait]
324+
impl SearchTree for BeamSearch {
325+
async fn search(
326+
&self,
327+
tree: &DocumentTree,
328+
context: &RetrievalContext,
329+
config: &SearchConfig,
330+
pilot: Option<&dyn Pilot>,
331+
) -> SearchResult {
332+
self.search_impl(tree, context, config, pilot, tree.root()).await
333+
}
334+
335+
async fn search_from(
336+
&self,
337+
tree: &DocumentTree,
338+
context: &RetrievalContext,
339+
config: &SearchConfig,
340+
pilot: Option<&dyn Pilot>,
341+
start_node: NodeId,
342+
) -> SearchResult {
343+
self.search_impl(tree, context, config, pilot, start_node).await
344+
}
336345

337346
fn name(&self) -> &'static str {
338347
"beam"

rust/src/retrieval/search/greedy.rs

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ impl GreedySearch {
7070
let algo_score = scorer.score(tree, node_id);
7171
let pilot_score = pilot_scores.get(&node_id).copied().unwrap_or(0.0);
7272

73-
// Weighted combination
7473
let final_score = if beta > 0.0 {
7574
(alpha * algo_score + beta * pilot_score) / (alpha + beta)
7675
} else {
@@ -81,33 +80,30 @@ impl GreedySearch {
8180
})
8281
.collect();
8382

84-
// Sort by merged score
8583
merged.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8684

8785
merged
8886
}
89-
}
90-
91-
impl Default for GreedySearch {
92-
fn default() -> Self {
93-
Self::new()
94-
}
95-
}
9687

97-
#[async_trait]
98-
impl SearchTree for GreedySearch {
99-
async fn search(
88+
/// Core greedy search logic parameterized by start node.
89+
async fn search_impl(
10090
&self,
10191
tree: &DocumentTree,
10292
context: &RetrievalContext,
10393
config: &SearchConfig,
10494
pilot: Option<&dyn Pilot>,
95+
start_node: NodeId,
10596
) -> SearchResult {
10697
let mut result = SearchResult::default();
10798
let mut current_path = SearchPath::new();
108-
let mut current_node = tree.root();
99+
let mut current_node = start_node;
109100
let mut visited: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
110101

102+
debug!(
103+
"GreedySearch: query='{}', start_node={:?}, max_iterations={}, min_score={:.2}",
104+
context.query, start_node, config.max_iterations, config.min_score
105+
);
106+
111107
// Track Pilot interventions
112108
let mut pilot_interventions = 0;
113109

@@ -128,7 +124,6 @@ impl SearchTree for GreedySearch {
128124

129125
// ========== Pilot Integration Point ==========
130126
let scored_children = if let Some(p) = pilot {
131-
// Build search state for Pilot
132127
let state = SearchState::new(
133128
tree,
134129
&context.query,
@@ -137,14 +132,12 @@ impl SearchTree for GreedySearch {
137132
&visited,
138133
);
139134

140-
// Check if Pilot wants to intervene
141135
if p.should_intervene(&state) {
142136
trace!(
143137
"Pilot intervening at greedy decision point with {} candidates",
144138
children.len()
145139
);
146140

147-
println!("[DEBUG] GREEDY SEARCH: Pilot intervening at decision point");
148141
match p.decide(&state).await {
149142
decision => {
150143
pilot_interventions += 1;
@@ -154,7 +147,6 @@ impl SearchTree for GreedySearch {
154147
std::mem::discriminant(&decision.direction)
155148
);
156149

157-
// Merge algorithm scores with Pilot decision
158150
self.merge_with_pilot_decision(
159151
tree,
160152
&children,
@@ -164,11 +156,9 @@ impl SearchTree for GreedySearch {
164156
}
165157
}
166158
} else {
167-
// No intervention, use algorithm scoring
168159
self.score_candidates_with_query(tree, &children, &context.query)
169160
}
170161
} else {
171-
// No Pilot, use algorithm scoring
172162
self.score_candidates_with_query(tree, &children, &context.query)
173163
};
174164
// ==============================================
@@ -205,7 +195,6 @@ impl SearchTree for GreedySearch {
205195
current_node = child_id;
206196
result.nodes_visited += 1;
207197

208-
// Check if we have enough results
209198
if result.paths.len() >= config.top_k {
210199
break;
211200
}
@@ -219,11 +208,40 @@ impl SearchTree for GreedySearch {
219208
}
220209
}
221210

222-
// Record Pilot interventions
223211
result.pilot_interventions = pilot_interventions;
224212

225213
result
226214
}
215+
}
216+
217+
impl Default for GreedySearch {
218+
fn default() -> Self {
219+
Self::new()
220+
}
221+
}
222+
223+
#[async_trait]
224+
impl SearchTree for GreedySearch {
225+
async fn search(
226+
&self,
227+
tree: &DocumentTree,
228+
context: &RetrievalContext,
229+
config: &SearchConfig,
230+
pilot: Option<&dyn Pilot>,
231+
) -> SearchResult {
232+
self.search_impl(tree, context, config, pilot, tree.root()).await
233+
}
234+
235+
async fn search_from(
236+
&self,
237+
tree: &DocumentTree,
238+
context: &RetrievalContext,
239+
config: &SearchConfig,
240+
pilot: Option<&dyn Pilot>,
241+
start_node: NodeId,
242+
) -> SearchResult {
243+
self.search_impl(tree, context, config, pilot, start_node).await
244+
}
227245

228246
fn name(&self) -> &'static str {
229247
"greedy"

rust/src/retrieval/search/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod bm25;
88
mod greedy;
99
mod mcts;
1010
mod scorer;
11+
mod toc_navigator;
1112
mod r#trait;
1213

1314
pub use beam::BeamSearch;
@@ -18,4 +19,5 @@ pub use bm25::{
1819
pub use greedy::GreedySearch;
1920
pub use mcts::MctsSearch;
2021
pub use scorer::{NodeScorer, ScoringContext};
22+
pub use toc_navigator::{SearchCue, ToCNavigator};
2123
pub use r#trait::{SearchConfig, SearchResult, SearchTree};

0 commit comments

Comments
 (0)