From fb6ff12d4adf860b7dc5032bd42889c82a90df18 Mon Sep 17 00:00:00 2001 From: zTgx <747674262@qq.com> Date: Tue, 14 Apr 2026 09:25:08 +0800 Subject: [PATCH 1/3] feat(search): add backtracking support to beam search algorithm - Implement fallback stack mechanism to store viable paths truncated from the main beam when they meet minimum score threshold - Add backtracking logic that pops highest-scoring entries from fallback stack when main beam exhausts without sufficient results - Introduce SearchState parameter for Pilot to handle backtrack guidance with new guide_backtrack method - Add max_backtracks and fallback_score_ratio configuration options to control backtracking behavior - Include comprehensive unit tests for fallback stack operations and backtracking functionality - Update NavigationDecision enum to track backtrack events in traces - Modify beam search to split candidates between main beam and fallback stack during each iteration --- rust/src/retrieval/search/beam.rs | 405 +++++++++++++++++++++++++++- rust/src/retrieval/search/trait.rs | 16 ++ rust/src/retrieval/stages/search.rs | 2 + rust/src/retrieval/types.rs | 6 +- 4 files changed, 416 insertions(+), 13 deletions(-) diff --git a/rust/src/retrieval/search/beam.rs b/rust/src/retrieval/search/beam.rs index a7319988..860b78e0 100644 --- a/rust/src/retrieval/search/beam.rs +++ b/rust/src/retrieval/search/beam.rs @@ -1,11 +1,18 @@ // Copyright (c) 2026 vectorless developers // SPDX-License-Identifier: Apache-2.0 -//! Beam search algorithm with Pilot as primary scorer. +//! Beam search algorithm with Pilot as primary scorer and backtracking support. //! //! Explores multiple paths in parallel, keeping only the top-k candidates //! at each level. Pilot provides semantic guidance; NodeScorer is the //! fallback when Pilot is unavailable. +//! +//! # Backtracking +//! +//! When the main beam exhausts all paths without finding enough results, +//! the search pops entries from a fallback stack and tries alternative +//! branches. This prevents the search from getting stuck in dead ends +//! caused by Pilot misjudgments at early layers. use async_trait::async_trait; use std::collections::HashSet; @@ -16,9 +23,22 @@ use super::super::types::{NavigationDecision, NavigationStep, SearchPath}; use super::pilot_scorer::{PilotDecisionCache, score_candidates}; use super::{SearchConfig, SearchResult, SearchTree}; use crate::document::{DocumentTree, NodeId}; -use crate::retrieval::pilot::Pilot; +use crate::retrieval::pilot::{Pilot, SearchState}; + +/// Maximum entries in the fallback stack relative to beam width. +const FALLBACK_STACK_MULTIPLIER: usize = 3; + +/// An entry in the fallback stack representing a viable alternative path +/// that was truncated from the main beam. +#[derive(Debug, Clone)] +struct FallbackEntry { + /// The alternative search path. + path: SearchPath, + /// Score when this path was shelved. + score: f32, +} -/// Beam search — explores multiple paths simultaneously. +/// Beam search — explores multiple paths simultaneously with backtracking. /// /// Keeps top `beam_width` candidates at each level, providing /// a balance between exploration and computational cost. @@ -28,6 +48,14 @@ use crate::retrieval::pilot::Pilot; /// Pilot is the primary scorer (weight=0.7). NodeScorer supplements /// for candidates Pilot didn't rank. Decisions are cached by /// (query, parent_node_id) to avoid redundant LLM calls. +/// +/// # Backtracking +/// +/// Paths truncated from the beam that still have reasonable scores +/// are kept in a fallback stack. When the main beam empties without +/// finding enough results, the search pops from the fallback stack, +/// calls `Pilot::guide_backtrack()` for re-guidance, and continues +/// from the alternative path. pub struct BeamSearch { beam_width: usize, } @@ -45,6 +73,152 @@ impl BeamSearch { } } + /// Push a path into the fallback stack if it meets the score threshold. + fn push_fallback( + fallback_stack: &mut Vec, + entry: FallbackEntry, + min_score: f32, + fallback_score_ratio: f32, + max_size: usize, + ) { + let threshold = min_score * fallback_score_ratio; + if entry.score < threshold { + return; + } + + // Evict lowest-score entry if at capacity + if fallback_stack.len() >= max_size { + if let Some(min_idx) = fallback_stack + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| a.score.partial_cmp(&b.score).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(i, _)| i) + { + if entry.score > fallback_stack[min_idx].score { + fallback_stack.swap_remove(min_idx); + } else { + return; // New entry isn't better than worst in stack + } + } + } + + fallback_stack.push(entry); + } + + /// Pop the highest-score entry from the fallback stack. + fn pop_fallback(fallback_stack: &mut Vec) -> Option { + if fallback_stack.is_empty() { + return None; + } + // Find and remove the highest-score entry + let max_idx = fallback_stack + .iter() + .enumerate() + .max_by(|(_, a), (_, b)| a.score.partial_cmp(&b.score).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(i, _)| i)?; + Some(fallback_stack.swap_remove(max_idx)) + } + + /// Attempt backtracking by popping from the fallback stack and + /// consulting Pilot for re-guidance. + async fn try_backtrack( + &self, + tree: &DocumentTree, + context: &RetrievalContext, + pilot: Option<&dyn Pilot>, + cache: &PilotDecisionCache, + visited: &HashSet, + fallback_stack: &mut Vec, + result: &mut SearchResult, + pilot_interventions: &mut usize, + ) -> Option { + let entry = Self::pop_fallback(fallback_stack)?; + let dead_end_title = entry + .path + .leaf + .and_then(|id| tree.get(id)) + .map(|n| n.title.clone()) + .unwrap_or_else(|| "unknown".to_string()); + + debug!( + "Backtracking: trying alternative path (score={:.2}, dead_end='{}')", + entry.score, dead_end_title + ); + + // Record backtrack in trace + result.trace.push(NavigationStep { + node_id: format!("{:?}", entry.path.leaf), + title: dead_end_title.clone(), + score: entry.score, + decision: NavigationDecision::BacktrackFrom(dead_end_title), + depth: entry.path.nodes.len(), + }); + + // Consult Pilot for re-guidance at the backtracking point + if let Some(p) = pilot { + // Get siblings of the dead-end node (alternatives at the same level) + let parent_node = if entry.path.nodes.len() >= 2 { + entry.path.nodes[entry.path.nodes.len() - 2] + } else { + tree.root() + }; + let siblings = tree.children(parent_node); + let unvisited_siblings: Vec = siblings + .into_iter() + .filter(|id| !visited.contains(id)) + .collect(); + + if !unvisited_siblings.is_empty() { + let path_ref = &entry.path.nodes[..]; + let state = SearchState { + tree, + query: &context.query, + path: path_ref, + candidates: &unvisited_siblings, + visited, + depth: entry.path.nodes.len(), + iteration: result.iterations, + best_score: result.paths.iter().map(|p| p.score).fold(0.0f32, f32::max), + is_backtracking: true, + }; + + if let Some(decision) = p.guide_backtrack(&state).await { + *pilot_interventions += 1; + + // Use Pilot's ranked candidates to pick the best alternative + if let Some(top) = decision.top_candidate() { + let new_path = entry.path.extend(top.node_id, top.score); + let child_node = tree.get(top.node_id); + result.trace.push(NavigationStep { + node_id: format!("{:?}", top.node_id), + title: child_node.map(|n| n.title.clone()).unwrap_or_default(), + score: top.score, + decision: NavigationDecision::GoToChild( + unvisited_siblings + .iter() + .position(|&c| c == top.node_id) + .unwrap_or(0), + ), + depth: child_node.map(|n| n.depth).unwrap_or(0), + }); + result.nodes_visited += 1; + debug!( + "Pilot re-guided to '{}' (score={:.2})", + child_node.map(|n| n.title.clone()).unwrap_or_default(), + top.score + ); + return Some(new_path); + } + } + } + } + + // No Pilot guidance or Pilot returned None — use the path as-is + // (continue expanding from where it was shelved) + debug!("No Pilot guidance during backtrack, using shelved path as-is"); + Some(entry.path) + } + /// Core beam search logic parameterized by start node. async fn search_impl( &self, @@ -56,17 +230,22 @@ impl BeamSearch { ) -> SearchResult { let mut result = SearchResult::default(); let beam_width = config.beam_width.min(self.beam_width); + let max_fallback_size = beam_width * FALLBACK_STACK_MULTIPLIER; let mut visited: HashSet = HashSet::new(); let cache = PilotDecisionCache::new(); visited.insert(start_node); debug!( - "BeamSearch: query='{}', start_node={:?}, beam_width={}, min_score={:.2}", - context.query, start_node, beam_width, config.min_score + "BeamSearch: query='{}', start_node={:?}, beam_width={}, min_score={:.2}, max_backtracks={}", + context.query, start_node, beam_width, config.min_score, config.max_backtracks ); let mut pilot_interventions = 0; + let mut backtrack_count = 0; + + // Fallback stack holds viable paths truncated from the beam + let mut fallback_stack: Vec = Vec::new(); // Initialize with start_node's children let start_children = tree.children(start_node); @@ -88,19 +267,79 @@ impl BeamSearch { pilot_interventions += 1; } - let mut current_beam: Vec = initial_candidates + // Split initial candidates into beam and fallback + let mut sorted_initial: Vec<_> = initial_candidates .into_iter() .map(|(node_id, score)| SearchPath::from_node(node_id, score)) .collect(); + sorted_initial.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut current_beam: Vec = sorted_initial + .iter() + .take(beam_width) + .cloned() + .collect(); - debug!("Initial {} candidates after scoring", current_beam.len()); + // Remaining candidates go to fallback stack + for path in sorted_initial.iter().skip(beam_width) { + Self::push_fallback( + &mut fallback_stack, + FallbackEntry { + path: path.clone(), + score: path.score, + }, + config.min_score, + config.fallback_score_ratio, + max_fallback_size, + ); + } - // Keep top beam_width - current_beam.truncate(beam_width); + debug!( + "Initial beam={}, fallback_stack={}", + current_beam.len(), + fallback_stack.len() + ); for iteration in 0..config.max_iterations { result.iterations = iteration + 1; + // === BACKTRACKING CHECK === + // If beam is empty but we have fallback entries and haven't + // found enough results, try backtracking. + if current_beam.is_empty() && result.paths.len() < config.top_k { + if backtrack_count < config.max_backtracks { + if let Some(new_path) = self + .try_backtrack( + tree, + context, + pilot, + &cache, + &visited, + &mut fallback_stack, + &mut result, + &mut pilot_interventions, + ) + .await + { + backtrack_count += 1; + current_beam = vec![new_path]; + debug!( + "Backtrack #{}: injected path into beam (remaining fallback={})", + backtrack_count, + fallback_stack.len() + ); + // Continue the search from this path + continue; + } + } + // No more fallback options or max backtracks reached + break; + } + if current_beam.is_empty() { break; } @@ -159,15 +398,32 @@ impl BeamSearch { } } - // Sort next beam and keep top candidates + // Sort next beam and split into beam + fallback next_beam.sort_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); - next_beam.truncate(beam_width); - current_beam = next_beam; + // Keep top beam_width in the beam, shelve the rest + let mut beam_candidates = next_beam; + let overflow: Vec = beam_candidates.split_off(beam_width.min(beam_candidates.len())); + + for path in overflow { + let score = path.score; + Self::push_fallback( + &mut fallback_stack, + FallbackEntry { + path, + score, + }, + config.min_score, + config.fallback_score_ratio, + max_fallback_size, + ); + } + + current_beam = beam_candidates; if result.paths.len() >= config.top_k { break; @@ -211,6 +467,14 @@ impl BeamSearch { result.pilot_interventions = pilot_interventions; + debug!( + "BeamSearch complete: paths={}, iterations={}, backtracks={}, pilot_interventions={}", + result.paths.len(), + result.iterations, + backtrack_count, + pilot_interventions + ); + result } } @@ -254,6 +518,13 @@ impl SearchTree for BeamSearch { #[cfg(test)] mod tests { use super::*; + use crate::document::TreeNode; + use indextree::Arena; + + /// Helper to create a NodeId from an Arena for tests. + fn make_node_id(arena: &mut Arena) -> NodeId { + NodeId(arena.new_node(TreeNode::default())) + } #[test] fn test_beam_search_creation() { @@ -269,4 +540,114 @@ mod tests { let search = BeamSearch::with_width(0); assert_eq!(search.beam_width, 1); } + + #[test] + fn test_fallback_push_and_pop() { + let mut arena = Arena::new(); + let id0 = make_node_id(&mut arena); + let id1 = make_node_id(&mut arena); + let id2 = make_node_id(&mut arena); + let mut stack = Vec::new(); + + BeamSearch::push_fallback( + &mut stack, + FallbackEntry { path: SearchPath::from_node(id0, 0.3), score: 0.3 }, + 0.1, 0.5, 100, + ); + BeamSearch::push_fallback( + &mut stack, + FallbackEntry { path: SearchPath::from_node(id1, 0.7), score: 0.7 }, + 0.1, 0.5, 100, + ); + BeamSearch::push_fallback( + &mut stack, + FallbackEntry { path: SearchPath::from_node(id2, 0.5), score: 0.5 }, + 0.1, 0.5, 100, + ); + + assert_eq!(stack.len(), 3); + + // Pop should return highest score (0.7) + let popped = BeamSearch::pop_fallback(&mut stack); + assert!(popped.is_some()); + assert!((popped.unwrap().score - 0.7).abs() < 0.001); + + // Next pop should return 0.5 + let popped = BeamSearch::pop_fallback(&mut stack); + assert!(popped.is_some()); + assert!((popped.unwrap().score - 0.5).abs() < 0.001); + } + + #[test] + fn test_fallback_score_threshold() { + let mut arena = Arena::new(); + let id0 = make_node_id(&mut arena); + let id1 = make_node_id(&mut arena); + let mut stack = Vec::new(); + + // Score 0.01 with threshold 0.1 * 0.5 = 0.05 → should be rejected + BeamSearch::push_fallback( + &mut stack, + FallbackEntry { path: SearchPath::from_node(id0, 0.01), score: 0.01 }, + 0.1, 0.5, 100, + ); + assert_eq!(stack.len(), 0, "Score below threshold should be rejected"); + + // Score 0.06 with threshold 0.05 → should be accepted + BeamSearch::push_fallback( + &mut stack, + FallbackEntry { path: SearchPath::from_node(id1, 0.06), score: 0.06 }, + 0.1, 0.5, 100, + ); + assert_eq!(stack.len(), 1, "Score above threshold should be accepted"); + } + + #[test] + fn test_fallback_capacity_eviction() { + let mut arena = Arena::new(); + let id0 = make_node_id(&mut arena); + let id1 = make_node_id(&mut arena); + let id2 = make_node_id(&mut arena); + let mut stack = Vec::new(); + + // Fill to capacity (max_size=2) + BeamSearch::push_fallback( + &mut stack, + FallbackEntry { path: SearchPath::from_node(id0, 0.3), score: 0.3 }, + 0.1, 0.5, 2, + ); + BeamSearch::push_fallback( + &mut stack, + FallbackEntry { path: SearchPath::from_node(id1, 0.5), score: 0.5 }, + 0.1, 0.5, 2, + ); + assert_eq!(stack.len(), 2); + + // Push a higher-score entry → should evict the lowest (0.3) + BeamSearch::push_fallback( + &mut stack, + FallbackEntry { path: SearchPath::from_node(id2, 0.8), score: 0.8 }, + 0.1, 0.5, 2, + ); + assert_eq!(stack.len(), 2); + + // Verify the 0.3 entry was evicted + let scores: Vec = stack.iter().map(|e| e.score).collect(); + assert!(scores.contains(&0.5)); + assert!(scores.contains(&0.8)); + assert!(!scores.contains(&0.3)); + } + + #[test] + fn test_fallback_empty_pop() { + let mut stack: Vec = Vec::new(); + assert!(BeamSearch::pop_fallback(&mut stack).is_none()); + } + + #[test] + fn test_search_config_backtrack_defaults() { + let config = SearchConfig::default(); + assert_eq!(config.max_backtracks, 3); + assert!((config.fallback_score_ratio - 0.5).abs() < 0.001); + } } diff --git a/rust/src/retrieval/search/trait.rs b/rust/src/retrieval/search/trait.rs index 74ccaca8..b77b645c 100644 --- a/rust/src/retrieval/search/trait.rs +++ b/rust/src/retrieval/search/trait.rs @@ -50,6 +50,20 @@ pub struct SearchConfig { pub min_score: f32, /// Whether to include leaf nodes only. pub leaf_only: bool, + /// Maximum number of backtracking attempts per search. + /// + /// When the main beam exhausts all paths without finding enough + /// results, the search can pop entries from the fallback stack + /// and try alternative branches. This limits how many times + /// that happens. Default: equal to `beam_width`. + pub max_backtracks: usize, + /// Minimum score ratio for a path to be eligible for the fallback stack. + /// + /// Expressed as a fraction of `min_score`. Paths truncated from the + /// beam with a score above `min_score * fallback_score_ratio` are + /// kept in the fallback stack for potential backtracking. + /// Default: 0.5. + pub fallback_score_ratio: f32, } impl Default for SearchConfig { @@ -60,6 +74,8 @@ impl Default for SearchConfig { max_iterations: 10, min_score: 0.1, leaf_only: false, + max_backtracks: 3, + fallback_score_ratio: 0.5, } } } diff --git a/rust/src/retrieval/stages/search.rs b/rust/src/retrieval/stages/search.rs index 8f431dec..6befd3f7 100644 --- a/rust/src/retrieval/stages/search.rs +++ b/rust/src/retrieval/stages/search.rs @@ -320,6 +320,8 @@ impl SearchStage { max_iterations: config.max_iterations, min_score: config.min_score, leaf_only: false, + max_backtracks: config.beam_width, + fallback_score_ratio: 0.5, }; let pilot_ref: Option<&dyn Pilot> = self.pilot.as_deref(); diff --git a/rust/src/retrieval/types.rs b/rust/src/retrieval/types.rs index a559912c..86de10e7 100644 --- a/rust/src/retrieval/types.rs +++ b/rust/src/retrieval/types.rs @@ -447,7 +447,7 @@ pub struct NavigationStep { } /// Navigation decision at each step. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum NavigationDecision { /// Go to the specified child. GoToChild(usize), @@ -460,6 +460,10 @@ pub enum NavigationDecision { /// Skip this branch. Skip, + + /// Backtrack from a dead-end node to a previously shelved alternative. + /// Contains the title of the dead-end node being abandoned. + BacktrackFrom(String), } /// Pipeline stage name for reasoning chain provenance. From 68fb727c7764116ffa027c161386496635f6c831 Mon Sep 17 00:00:00 2001 From: zTgx <747674262@qq.com> Date: Tue, 14 Apr 2026 09:47:18 +0800 Subject: [PATCH 2/3] refactor(retrieval): reorganize scoring and pilot modules - move extract_keywords import from search to scoring module - create new scoring module with BM25 utilities - rename pilot_scorer.rs to decision_scorer.rs and relocate to pilot module - rename scorer.rs to pilot/scorer.rs - update all imports to reference correct module paths - move scoring related utilities to dedicated scoring module - separate search algorithms from scoring logic in search module --- rust/src/index/stages/reasoning.rs | 2 +- rust/src/retrieval/content/scorer.rs | 2 +- rust/src/retrieval/mod.rs | 1 + .../pilot_scorer.rs => pilot/decision_scorer.rs} | 0 rust/src/retrieval/pilot/mod.rs | 5 ++++- rust/src/retrieval/{search => pilot}/scorer.rs | 4 ++-- rust/src/retrieval/{search => scoring}/bm25.rs | 0 rust/src/retrieval/scoring/mod.rs | 12 ++++++++++++ rust/src/retrieval/search/beam.rs | 2 +- rust/src/retrieval/search/greedy.rs | 2 +- rust/src/retrieval/search/mcts.rs | 3 +-- rust/src/retrieval/search/mod.rs | 8 ++++---- rust/src/retrieval/search/toc_navigator.rs | 2 +- rust/src/retrieval/stages/search.rs | 2 +- rust/src/retrieval/strategy/hybrid.rs | 2 +- 15 files changed, 31 insertions(+), 16 deletions(-) rename rust/src/retrieval/{search/pilot_scorer.rs => pilot/decision_scorer.rs} (100%) rename rust/src/retrieval/{search => pilot}/scorer.rs (99%) rename rust/src/retrieval/{search => scoring}/bm25.rs (100%) create mode 100644 rust/src/retrieval/scoring/mod.rs diff --git a/rust/src/index/stages/reasoning.rs b/rust/src/index/stages/reasoning.rs index 7133dc2a..0b02fcc5 100644 --- a/rust/src/index/stages/reasoning.rs +++ b/rust/src/index/stages/reasoning.rs @@ -15,7 +15,7 @@ use crate::document::{ TopicEntry, }; use crate::error::Result; -use crate::retrieval::search::extract_keywords; +use crate::retrieval::scoring::extract_keywords; use super::async_trait; use super::{AccessPattern, IndexStage, StageResult}; diff --git a/rust/src/retrieval/content/scorer.rs b/rust/src/retrieval/content/scorer.rs index 777006da..8597c0a1 100644 --- a/rust/src/retrieval/content/scorer.rs +++ b/rust/src/retrieval/content/scorer.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use crate::document::NodeId; -use crate::retrieval::search::{Bm25Params, STOPWORDS, extract_keywords}; +use crate::retrieval::scoring::{Bm25Params, STOPWORDS, extract_keywords}; use crate::utils::estimate_tokens; use super::config::ScoringStrategyConfig; diff --git a/rust/src/retrieval/mod.rs b/rust/src/retrieval/mod.rs index 982906c2..3a8865fe 100644 --- a/rust/src/retrieval/mod.rs +++ b/rust/src/retrieval/mod.rs @@ -60,6 +60,7 @@ pub mod complexity; pub mod content; pub mod pilot; pub mod pipeline; +pub mod scoring; pub mod search; pub mod stages; pub mod strategy; diff --git a/rust/src/retrieval/search/pilot_scorer.rs b/rust/src/retrieval/pilot/decision_scorer.rs similarity index 100% rename from rust/src/retrieval/search/pilot_scorer.rs rename to rust/src/retrieval/pilot/decision_scorer.rs diff --git a/rust/src/retrieval/pilot/mod.rs b/rust/src/retrieval/pilot/mod.rs index daae3737..5e981666 100644 --- a/rust/src/retrieval/pilot/mod.rs +++ b/rust/src/retrieval/pilot/mod.rs @@ -35,6 +35,7 @@ mod builder; mod complexity; mod config; mod decision; +mod decision_scorer; mod fallback; mod feedback; mod llm_pilot; @@ -43,10 +44,12 @@ mod noop; mod parser; mod prompts; mod r#trait; +mod scorer; pub use complexity::detect_with_llm; pub use config::PilotConfig; pub use decision::{InterventionPoint, PilotDecision}; - +pub use decision_scorer::{PilotDecisionCache, score_candidates}; pub use llm_pilot::LlmPilot; pub use r#trait::{Pilot, SearchState}; +pub use scorer::{NodeScorer, ScoringContext}; diff --git a/rust/src/retrieval/search/scorer.rs b/rust/src/retrieval/pilot/scorer.rs similarity index 99% rename from rust/src/retrieval/search/scorer.rs rename to rust/src/retrieval/pilot/scorer.rs index 65af4713..b612a23b 100644 --- a/rust/src/retrieval/search/scorer.rs +++ b/rust/src/retrieval/pilot/scorer.rs @@ -10,10 +10,10 @@ use std::collections::HashMap; use crate::document::{DocumentTree, NodeId}; -use super::bm25::Bm25Params; +use crate::retrieval::scoring::bm25::Bm25Params; // Re-export extract_keywords for other modules to use -pub use super::bm25::extract_keywords; +pub use crate::retrieval::scoring::bm25::extract_keywords; /// Scoring strategy to use. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/rust/src/retrieval/search/bm25.rs b/rust/src/retrieval/scoring/bm25.rs similarity index 100% rename from rust/src/retrieval/search/bm25.rs rename to rust/src/retrieval/scoring/bm25.rs diff --git a/rust/src/retrieval/scoring/mod.rs b/rust/src/retrieval/scoring/mod.rs new file mode 100644 index 00000000..0682ed7d --- /dev/null +++ b/rust/src/retrieval/scoring/mod.rs @@ -0,0 +1,12 @@ +// Copyright (c) 2026 vectorless developers +// SPDX-License-Identifier: Apache-2.0 + +//! Scoring utilities for text relevance assessment. +//! +//! This module provides text scoring algorithms (BM25, keyword matching) +//! that are used across the retrieval pipeline. These are general-purpose +//! tools, not tied to any specific search algorithm. + +pub mod bm25; + +pub use bm25::{Bm25Engine, Bm25Params, FieldDocument, STOPWORDS, extract_keywords}; diff --git a/rust/src/retrieval/search/beam.rs b/rust/src/retrieval/search/beam.rs index 860b78e0..5eabc6f5 100644 --- a/rust/src/retrieval/search/beam.rs +++ b/rust/src/retrieval/search/beam.rs @@ -20,7 +20,7 @@ use tracing::debug; use super::super::RetrievalContext; use super::super::types::{NavigationDecision, NavigationStep, SearchPath}; -use super::pilot_scorer::{PilotDecisionCache, score_candidates}; +use crate::retrieval::pilot::{PilotDecisionCache, score_candidates}; use super::{SearchConfig, SearchResult, SearchTree}; use crate::document::{DocumentTree, NodeId}; use crate::retrieval::pilot::{Pilot, SearchState}; diff --git a/rust/src/retrieval/search/greedy.rs b/rust/src/retrieval/search/greedy.rs index 34ed0de5..ed2e76a3 100644 --- a/rust/src/retrieval/search/greedy.rs +++ b/rust/src/retrieval/search/greedy.rs @@ -13,7 +13,7 @@ use tracing::debug; use super::super::RetrievalContext; use super::super::types::{NavigationDecision, NavigationStep, SearchPath}; -use super::pilot_scorer::{PilotDecisionCache, score_candidates}; +use crate::retrieval::pilot::{PilotDecisionCache, score_candidates}; use super::{SearchConfig, SearchResult, SearchTree}; use crate::document::{DocumentTree, NodeId}; use crate::retrieval::pilot::Pilot; diff --git a/rust/src/retrieval/search/mcts.rs b/rust/src/retrieval/search/mcts.rs index 9663d686..ff01fdaa 100644 --- a/rust/src/retrieval/search/mcts.rs +++ b/rust/src/retrieval/search/mcts.rs @@ -20,8 +20,7 @@ use tracing::debug; use super::super::RetrievalContext; use super::super::types::{NavigationDecision, NavigationStep, SearchPath}; -use super::pilot_scorer::{PilotDecisionCache, score_candidates}; -use super::scorer::{NodeScorer, ScoringContext}; +use crate::retrieval::pilot::{PilotDecisionCache, score_candidates, NodeScorer, ScoringContext}; use super::{SearchConfig, SearchResult, SearchTree}; use crate::document::{DocumentTree, NodeId}; use crate::retrieval::pilot::Pilot; diff --git a/rust/src/retrieval/search/mod.rs b/rust/src/retrieval/search/mod.rs index cceec5e4..f2111625 100644 --- a/rust/src/retrieval/search/mod.rs +++ b/rust/src/retrieval/search/mod.rs @@ -2,18 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 //! Search algorithms for tree traversal. +//! +//! This module contains only tree traversal strategies (Beam, MCTS, Greedy). +//! All scoring intelligence lives in the `pilot` module. +//! BM25 and keyword utilities live in the `scoring` module. mod beam; -mod bm25; mod greedy; mod mcts; -mod pilot_scorer; -mod scorer; mod toc_navigator; mod r#trait; pub use beam::BeamSearch; -pub use bm25::{Bm25Engine, Bm25Params, FieldDocument, STOPWORDS, extract_keywords}; pub use greedy::PurePilotSearch; pub use mcts::MctsSearch; pub use toc_navigator::{SearchCue, ToCNavigator}; diff --git a/rust/src/retrieval/search/toc_navigator.rs b/rust/src/retrieval/search/toc_navigator.rs index 778b5da2..95e0cf2a 100644 --- a/rust/src/retrieval/search/toc_navigator.rs +++ b/rust/src/retrieval/search/toc_navigator.rs @@ -16,7 +16,7 @@ use crate::document::DocumentTree; use crate::document::NodeId; use crate::llm::LlmClient; use crate::memo::MemoStore; -use crate::retrieval::search::scorer::NodeScorer; +use crate::retrieval::pilot::NodeScorer; /// A navigation cue produced by the ToCNavigator. #[derive(Debug, Clone)] diff --git a/rust/src/retrieval/stages/search.rs b/rust/src/retrieval/stages/search.rs index 6befd3f7..a15410c3 100644 --- a/rust/src/retrieval/stages/search.rs +++ b/rust/src/retrieval/stages/search.rs @@ -21,7 +21,7 @@ use crate::retrieval::pilot::Pilot; use crate::retrieval::pipeline::{ CandidateNode, FailurePolicy, PipelineContext, RetrievalStage, SearchAlgorithm, StageOutcome, }; -use crate::retrieval::search::extract_keywords; +use crate::retrieval::scoring::extract_keywords; use crate::retrieval::search::{ BeamSearch, MctsSearch, PurePilotSearch, SearchConfig as SearchAlgConfig, SearchCue, SearchTree, ToCNavigator, diff --git a/rust/src/retrieval/strategy/hybrid.rs b/rust/src/retrieval/strategy/hybrid.rs index c60572e5..74484efa 100644 --- a/rust/src/retrieval/strategy/hybrid.rs +++ b/rust/src/retrieval/strategy/hybrid.rs @@ -12,7 +12,7 @@ use async_trait::async_trait; use super::r#trait::{NodeEvaluation, RetrievalStrategy, StrategyCapabilities}; use crate::document::{DocumentTree, NodeId}; use crate::retrieval::RetrievalContext; -use crate::retrieval::search::{Bm25Engine, FieldDocument}; +use crate::retrieval::scoring::{Bm25Engine, FieldDocument}; use crate::retrieval::types::{NavigationDecision, QueryComplexity}; /// Configuration for hybrid retrieval. From 4a392bb60c1e4ec0a5c16cc634fbee44375b88a7 Mon Sep 17 00:00:00 2001 From: zTgx <747674262@qq.com> Date: Tue, 14 Apr 2026 10:16:06 +0800 Subject: [PATCH 3/3] feat(pilot): add per-step reasoning support to search algorithms Add optional per-step reasoning tracking throughout the search pipeline to provide better context for navigation decisions. This includes: - Extend build_path_section() to show enhanced navigation history with step-by-step reasoning when available, falling back to original breadcrumb format otherwise - Introduce ScoredCandidate struct with optional reasoning field and new score_candidates_detailed() function that preserves reasoning from the Pilot for each candidate - Add step_reasons field to SearchState to track reasoning history through the search process - Update beam search algorithm to capture and propagate reasoning for each navigation step, including backtracking scenarios - Modify greedy and MCTS search implementations to support reasoning parameters while maintaining backward compatibility - Enhance SearchPath struct with step_reasons vector to maintain per-step reasoning history throughout multi-path algorithms --- rust/src/retrieval/pilot/builder.rs | 70 +++++++++++++----- rust/src/retrieval/pilot/decision_scorer.rs | 80 +++++++++++++++++---- rust/src/retrieval/pilot/mod.rs | 2 +- rust/src/retrieval/pilot/trait.rs | 7 ++ rust/src/retrieval/search/beam.rs | 35 ++++++--- rust/src/retrieval/search/greedy.rs | 1 + rust/src/retrieval/search/mcts.rs | 2 + rust/src/retrieval/types.rs | 34 ++++++++- 8 files changed, 187 insertions(+), 44 deletions(-) diff --git a/rust/src/retrieval/pilot/builder.rs b/rust/src/retrieval/pilot/builder.rs index 0096cde4..0be2d338 100644 --- a/rust/src/retrieval/pilot/builder.rs +++ b/rust/src/retrieval/pilot/builder.rs @@ -395,7 +395,7 @@ impl ContextBuilder { ctx.estimated_tokens += self.estimate_tokens(&ctx.query_section); // Build path section - ctx.path_section = self.build_path_section(state.tree, state.path); + ctx.path_section = self.build_path_section(state.tree, state.path, state.step_reasons); ctx.estimated_tokens += self.estimate_tokens(&ctx.path_section); // Build candidates section @@ -439,7 +439,7 @@ impl ContextBuilder { // Show failed path ctx.path_section = format!( "Failed path:\n{}", - self.build_path_section(state.tree, failed_path) + self.build_path_section(state.tree, failed_path, None) ); ctx.estimated_tokens += self.estimate_tokens(&ctx.path_section); @@ -463,35 +463,67 @@ impl ContextBuilder { format!("User Query:\n{}\n", truncated) } - /// Build current path section. - fn build_path_section(&self, tree: &DocumentTree, path: &[NodeId]) -> String { + /// Build current path section with optional per-step reasoning. + fn build_path_section( + &self, + tree: &DocumentTree, + path: &[NodeId], + step_reasons: Option<&[Option]>, + ) -> String { if path.is_empty() { return "Current Position: Root\n".to_string(); } - let mut result = String::from("Current Path:\n"); - result.push_str("Root"); + let has_reasons = step_reasons + .map(|r| r.iter().any(|x| x.is_some())) + .unwrap_or(false); - // Limit depth shown - let max_depth = self.effective_max_path_depth(); - let start = if path.len() > max_depth { - path.len() - max_depth - } else { - 0 - }; + if !has_reasons { + // Original breadcrumb format when no reasoning available + let mut result = String::from("Current Path:\n"); + result.push_str("Root"); + + let max_depth = self.effective_max_path_depth(); + let start = if path.len() > max_depth { + path.len() - max_depth + } else { + 0 + }; - if start > 0 { - result.push_str(" → ..."); + if start > 0 { + result.push_str(" → ..."); + } + + for node_id in path.iter().skip(start) { + if let Some(node) = tree.get(*node_id) { + result.push_str(" → "); + result.push_str(&node.title); + } + } + + result.push('\n'); + return result; } - for node_id in path.iter().skip(start) { + // Enhanced format with per-step reasoning + let mut result = String::from("Navigation History:\n"); + let reasons = step_reasons.unwrap(); + + for (i, node_id) in path.iter().enumerate() { if let Some(node) = tree.get(*node_id) { - result.push_str(" → "); - result.push_str(&node.title); + let reason = reasons + .get(i) + .and_then(|r| r.as_deref()) + .unwrap_or("(automatic selection)"); + result.push_str(&format!( + " Step {}: {} — because: {}\n", + i + 1, + node.title, + reason + )); } } - result.push('\n'); result } diff --git a/rust/src/retrieval/pilot/decision_scorer.rs b/rust/src/retrieval/pilot/decision_scorer.rs index 22db9805..4b9fe930 100644 --- a/rust/src/retrieval/pilot/decision_scorer.rs +++ b/rust/src/retrieval/pilot/decision_scorer.rs @@ -86,18 +86,54 @@ pub async fn score_candidates( visited: &HashSet, pilot_weight: f32, cache: Option<&PilotDecisionCache>, + step_reasons: Option<&[Option]>, ) -> Vec<(NodeId, f32)> { + let scored = score_candidates_detailed( + tree, candidates, query, pilot, path, visited, pilot_weight, cache, step_reasons, + ) + .await; + scored.into_iter().map(|s| (s.node_id, s.score)).collect() +} + +/// A scored candidate with optional reasoning from the Pilot. +#[derive(Debug, Clone)] +pub struct ScoredCandidate { + /// The node ID. + pub node_id: NodeId, + /// Relevance score (0.0 - 1.0). + pub score: f32, + /// Reason the Pilot chose this node, if available. + pub reason: Option, +} + +/// Score child candidates and return detailed results with reasons. +/// +/// Like [`score_candidates`] but preserves per-candidate reasoning +/// from the Pilot. Use this when the search algorithm needs to +/// record why each path step was taken (e.g., for beam search +/// reasoning history). +pub async fn score_candidates_detailed( + tree: &DocumentTree, + candidates: &[NodeId], + query: &str, + pilot: Option<&dyn Pilot>, + path: &[NodeId], + visited: &HashSet, + pilot_weight: f32, + cache: Option<&PilotDecisionCache>, + step_reasons: Option<&[Option]>, +) -> Vec { if candidates.is_empty() { return Vec::new(); } - // If no Pilot, pure NodeScorer + // If no Pilot, pure NodeScorer (no reasons available) let Some(p) = pilot else { - return score_with_scorer(tree, candidates, query); + return score_with_scorer_detailed(tree, candidates, query); }; if !p.is_active() { - return score_with_scorer(tree, candidates, query); + return score_with_scorer_detailed(tree, candidates, query); } // Determine parent node (last in path) for cache key @@ -109,20 +145,22 @@ pub async fn score_candidates( tracing::trace!("Pilot cache hit for parent={:?}", parent); cached } else { - let state = SearchState::new(tree, query, path, candidates, visited); + let mut state = SearchState::new(tree, query, path, candidates, visited); + state.step_reasons = step_reasons; let d = p.decide(&state).await; c.put(query, parent, &d).await; d } } else { - let state = SearchState::new(tree, query, path, candidates, visited); + let mut state = SearchState::new(tree, query, path, candidates, visited); + state.step_reasons = step_reasons; p.decide(&state).await }; - // Build Pilot score map - let mut pilot_scores: HashMap = HashMap::new(); + // Build Pilot score + reason map + let mut pilot_data: HashMap)> = HashMap::new(); for ranked in &decision.ranked_candidates { - pilot_scores.insert(ranked.node_id, ranked.score); + pilot_data.insert(ranked.node_id, (ranked.score, ranked.reason.clone())); } // Compute NodeScorer fallback scores @@ -132,24 +170,26 @@ pub async fn score_candidates( let scorer = NodeScorer::new(ScoringContext::new(query)); - let mut scored: Vec<(NodeId, f32)> = candidates + let mut scored: Vec = candidates .iter() .map(|&node_id| { let algo_score = scorer.score(tree, node_id); - let p_score = pilot_scores.get(&node_id).copied().unwrap_or(0.0); + let (p_score, reason) = pilot_data.get(&node_id) + .map(|(s, r)| (*s, r.clone())) + .unwrap_or((0.0, None)); - let final_score = if effective_pilot > 0.0 && pilot_scores.contains_key(&node_id) { + let final_score = if effective_pilot > 0.0 && pilot_data.contains_key(&node_id) { (effective_pilot * p_score + scorer_weight * algo_score) / (effective_pilot + scorer_weight) } else { algo_score }; - (node_id, final_score) + ScoredCandidate { node_id, score: final_score, reason } }) .collect(); - scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); scored } @@ -163,6 +203,20 @@ fn score_with_scorer( scorer.score_and_sort(tree, candidates) } +/// Pure NodeScorer fallback returning detailed results (no reasons). +fn score_with_scorer_detailed( + tree: &DocumentTree, + candidates: &[NodeId], + query: &str, +) -> Vec { + let scorer = NodeScorer::new(ScoringContext::new(query)); + scorer + .score_and_sort(tree, candidates) + .into_iter() + .map(|(node_id, score)| ScoredCandidate { node_id, score, reason: None }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/src/retrieval/pilot/mod.rs b/rust/src/retrieval/pilot/mod.rs index 5e981666..0ba28975 100644 --- a/rust/src/retrieval/pilot/mod.rs +++ b/rust/src/retrieval/pilot/mod.rs @@ -49,7 +49,7 @@ mod scorer; pub use complexity::detect_with_llm; pub use config::PilotConfig; pub use decision::{InterventionPoint, PilotDecision}; -pub use decision_scorer::{PilotDecisionCache, score_candidates}; +pub use decision_scorer::{PilotDecisionCache, ScoredCandidate, score_candidates, score_candidates_detailed}; pub use llm_pilot::LlmPilot; pub use r#trait::{Pilot, SearchState}; pub use scorer::{NodeScorer, ScoringContext}; diff --git a/rust/src/retrieval/pilot/trait.rs b/rust/src/retrieval/pilot/trait.rs index 3f4b868a..bc8d136a 100644 --- a/rust/src/retrieval/pilot/trait.rs +++ b/rust/src/retrieval/pilot/trait.rs @@ -42,6 +42,11 @@ pub struct SearchState<'a> { pub best_score: f32, /// Whether the search is currently backtracking. pub is_backtracking: bool, + /// Per-step reasoning for why each node in `path` was chosen. + /// + /// Same length as `path` when present. `None` means no reasoning + /// history is available (e.g. first iteration, algorithm-only mode). + pub step_reasons: Option<&'a [Option]>, } impl<'a> SearchState<'a> { @@ -63,6 +68,7 @@ impl<'a> SearchState<'a> { iteration: 0, best_score: 0.0, is_backtracking: false, + step_reasons: None, } } @@ -78,6 +84,7 @@ impl<'a> SearchState<'a> { iteration: 0, best_score: 0.0, is_backtracking: false, + step_reasons: None, } } diff --git a/rust/src/retrieval/search/beam.rs b/rust/src/retrieval/search/beam.rs index 5eabc6f5..265a484c 100644 --- a/rust/src/retrieval/search/beam.rs +++ b/rust/src/retrieval/search/beam.rs @@ -20,7 +20,7 @@ use tracing::debug; use super::super::RetrievalContext; use super::super::types::{NavigationDecision, NavigationStep, SearchPath}; -use crate::retrieval::pilot::{PilotDecisionCache, score_candidates}; +use crate::retrieval::pilot::{PilotDecisionCache, score_candidates, score_candidates_detailed}; use super::{SearchConfig, SearchResult, SearchTree}; use crate::document::{DocumentTree, NodeId}; use crate::retrieval::pilot::{Pilot, SearchState}; @@ -180,6 +180,7 @@ impl BeamSearch { iteration: result.iterations, best_score: result.paths.iter().map(|p| p.score).fold(0.0f32, f32::max), is_backtracking: true, + step_reasons: Some(&entry.path.step_reasons), }; if let Some(decision) = p.guide_backtrack(&state).await { @@ -251,7 +252,7 @@ impl BeamSearch { let start_children = tree.children(start_node); debug!("Start node has {} children", start_children.len()); - let initial_candidates = score_candidates( + let initial_candidates = score_candidates_detailed( tree, &start_children, &context.query, @@ -260,6 +261,7 @@ impl BeamSearch { &visited, 0.7, // Beam: Pilot weight = 0.7 Some(&cache), + None, // No reasoning history at start ) .await; @@ -270,7 +272,14 @@ impl BeamSearch { // Split initial candidates into beam and fallback let mut sorted_initial: Vec<_> = initial_candidates .into_iter() - .map(|(node_id, score)| SearchPath::from_node(node_id, score)) + .map(|s| { + let mut path = SearchPath::from_node(s.node_id, s.score); + // Record reason for initial selection + if let Some(reason) = s.reason { + path.step_reasons = vec![Some(reason)]; + } + path + }) .collect(); sorted_initial.sort_by(|a, b| { b.score @@ -362,7 +371,7 @@ impl BeamSearch { // Expand this path let children = tree.children(leaf_id); - let scored_children = score_candidates( + let scored_children = score_candidates_detailed( tree, &children, &context.query, @@ -371,6 +380,7 @@ impl BeamSearch { &visited, 0.7, // Beam: Pilot weight = 0.7 Some(&cache), + Some(&path.step_reasons), ) .await; @@ -378,16 +388,20 @@ impl BeamSearch { pilot_interventions += 1; } - for (child_id, child_score) in scored_children.into_iter().take(beam_width) { - let new_path = path.extend(child_id, child_score); + for sc in scored_children.into_iter().take(beam_width) { + let new_path = if let Some(ref reason) = sc.reason { + path.extend_with_reason(sc.node_id, sc.score, reason) + } else { + path.extend(sc.node_id, sc.score) + }; - let child_node = tree.get(child_id); + let child_node = tree.get(sc.node_id); result.trace.push(NavigationStep { - node_id: format!("{:?}", child_id), + node_id: format!("{:?}", sc.node_id), title: child_node.map(|n| n.title.clone()).unwrap_or_default(), - score: child_score, + score: sc.score, decision: NavigationDecision::GoToChild( - children.iter().position(|&c| c == child_id).unwrap_or(0), + children.iter().position(|&c| c == sc.node_id).unwrap_or(0), ), depth: child_node.map(|n| n.depth).unwrap_or(0), }); @@ -450,6 +464,7 @@ impl BeamSearch { &visited, 0.7, None, + None, // No reasoning history for fallback ) .await; for (node_id, score) in fallback.into_iter().take(config.top_k) { diff --git a/rust/src/retrieval/search/greedy.rs b/rust/src/retrieval/search/greedy.rs index ed2e76a3..2bd72ca6 100644 --- a/rust/src/retrieval/search/greedy.rs +++ b/rust/src/retrieval/search/greedy.rs @@ -77,6 +77,7 @@ impl PurePilotSearch { &visited, 1.0, // PurePilot: Pilot weight = 1.0 Some(&cache), + None, // No reasoning history tracked ) .await; diff --git a/rust/src/retrieval/search/mcts.rs b/rust/src/retrieval/search/mcts.rs index ff01fdaa..1ab48480 100644 --- a/rust/src/retrieval/search/mcts.rs +++ b/rust/src/retrieval/search/mcts.rs @@ -107,6 +107,7 @@ impl MctsSearch { visited, 0.5, // MCTS prior: balanced Pilot/Scorer Some(cache), + None, // No reasoning history tracked ) .await; @@ -174,6 +175,7 @@ impl MctsSearch { visited, 0.5, // MCTS simulation: balanced Some(cache), + None, // No reasoning history tracked ) .await; diff --git a/rust/src/retrieval/types.rs b/rust/src/retrieval/types.rs index 86de10e7..b9a3d17e 100644 --- a/rust/src/retrieval/types.rs +++ b/rust/src/retrieval/types.rs @@ -596,6 +596,11 @@ impl ReasoningChain { } /// Search path for multi-path algorithms. +/// +/// Tracks the sequence of nodes visited, along with the reasoning +/// for each navigation step. This reasoning is fed back into the +/// LLM context so the Pilot can understand how it arrived at the +/// current position and avoid repeating mistakes. #[derive(Debug, Clone)] pub struct SearchPath { /// Nodes in the path. @@ -606,6 +611,13 @@ pub struct SearchPath { /// Leaf node (if path ends at leaf). pub leaf: Option, + + /// Per-step reasoning for why each node was chosen. + /// + /// Same length as `nodes`. Each entry is the reason the + /// corresponding node was selected. `None` means no reason + /// was captured (e.g., algorithm-only fallback). + pub step_reasons: Vec>, } impl SearchPath { @@ -616,6 +628,7 @@ impl SearchPath { nodes: Vec::new(), score: 0.0, leaf: None, + step_reasons: Vec::new(), } } @@ -626,18 +639,37 @@ impl SearchPath { nodes: vec![node_id], score, leaf: Some(node_id), + step_reasons: vec![None], } } - /// Extend the path with a new node. + /// Extend the path with a new node and optional reason. #[must_use] pub fn extend(&self, node_id: NodeId, score: f32) -> Self { let mut nodes = self.nodes.clone(); + let mut step_reasons = self.step_reasons.clone(); + nodes.push(node_id); + step_reasons.push(None); + Self { + nodes, + score: self.score + score, + leaf: Some(node_id), + step_reasons, + } + } + + /// Extend the path with a new node and a reason for choosing it. + #[must_use] + pub fn extend_with_reason(&self, node_id: NodeId, score: f32, reason: impl Into) -> Self { + let mut nodes = self.nodes.clone(); + let mut step_reasons = self.step_reasons.clone(); nodes.push(node_id); + step_reasons.push(Some(reason.into())); Self { nodes, score: self.score + score, leaf: Some(node_id), + step_reasons, } } }