Skip to content

Commit 0d97434

Browse files
committed
feat(retrieval): add structural path constraints and hints extraction
Add optional path_constraint field to SubQuery to store structural path constraints extracted from queries (e.g. "3.2", "Chapter 5"). Add resolved_path_hints field to PipelineContext to store node IDs extracted from structural queries. Implement Chinese number parsing utility function to convert Chinese numerals to integers (e.g. "三" → 3, "二十一" → 21). Add extract_structure_hints method to recognize and map various structural patterns including: - Chinese chapter/section patterns: "第X章", "第X节", "第一章" - English section numbers: "Section 3.2", "section 4.1.2" - Chapter references: "Chapter 5", "chapter 10" - Bare section numbers: "3.2.1", "2.1" - Table/Figure references: "表3", "Table 5", "图2", "Figure 4" Integrate structure hints into search stage by injecting them as high-priority search cues with confidence score of 1.0. Update all relevant initialization code to include the new fields in tests and implementation.
1 parent 92f54e1 commit 0d97434

4 files changed

Lines changed: 178 additions & 3 deletions

File tree

rust/src/retrieval/decompose.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ pub struct SubQuery {
6464
pub depends_on: Vec<usize>,
6565
/// Type of sub-query.
6666
pub query_type: SubQueryType,
67+
/// Optional structural path constraint extracted from the query
68+
/// (e.g. "3.2", "Chapter 5"). When set, the search should start
69+
/// from the corresponding tree node instead of searching broadly.
70+
pub path_constraint: Option<String>,
6771
}
6872

6973
/// Complexity level for a sub-query.
@@ -130,6 +134,7 @@ impl DecompositionResult {
130134
priority: 0,
131135
depends_on: vec![],
132136
query_type: SubQueryType::Fact,
137+
path_constraint: None,
133138
}],
134139
was_decomposed: false,
135140
reason: reason.to_string(),
@@ -338,6 +343,7 @@ impl QueryDecomposer {
338343
priority: i as u8,
339344
depends_on: vec![],
340345
query_type: self.detect_query_type(part),
346+
path_constraint: None,
341347
});
342348
}
343349
}
@@ -359,6 +365,7 @@ impl QueryDecomposer {
359365
vec![]
360366
},
361367
query_type: self.detect_query_type(part),
368+
path_constraint: None,
362369
});
363370
}
364371
break;
@@ -666,13 +673,15 @@ mod tests {
666673
depends_on: vec![],
667674
query_type: SubQueryType::Fact,
668675
complexity: SubQueryComplexity::Simple,
676+
path_constraint: None,
669677
},
670678
SubQuery {
671679
text: "Second".to_string(),
672680
priority: 1,
673681
depends_on: vec![0],
674682
query_type: SubQueryType::Fact,
675683
complexity: SubQueryComplexity::Simple,
684+
path_constraint: None,
676685
},
677686
];
678687
result.was_decomposed = true;
@@ -711,6 +720,7 @@ mod tests {
711720
depends_on: vec![],
712721
query_type: SubQueryType::Fact,
713722
complexity: SubQueryComplexity::Simple,
723+
path_constraint: None,
714724
},
715725
content: "Answer 1".to_string(),
716726
score: 0.9,
@@ -723,6 +733,7 @@ mod tests {
723733
depends_on: vec![0],
724734
query_type: SubQueryType::Fact,
725735
complexity: SubQueryComplexity::Simple,
736+
path_constraint: None,
726737
},
727738
content: "Answer 2".to_string(),
728739
score: 0.8,

rust/src/retrieval/pipeline/context.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use std::sync::Arc;
1111
use std::time::Instant;
1212

1313
use crate::document::{DocumentTree, NodeId, RetrievalIndex};
14-
use crate::retrieval::pilot::Pilot;
1514
use crate::retrieval::pipeline::budget::RetrievalBudgetController;
15+
use crate::retrieval::pilot::Pilot;
1616
use crate::retrieval::types::{
1717
NavigationDecision, QueryComplexity, ReasoningChain, ReasoningStep, RetrieveOptions,
1818
RetrieveResponse, SearchPath, StageName, StrategyPreference, SufficiencyLevel,
@@ -212,6 +212,9 @@ pub struct PipelineContext {
212212
pub keywords: Vec<String>,
213213
/// Target sections from ToC matching.
214214
pub target_sections: Vec<String>,
215+
/// Resolved structural path hints — node IDs extracted from the query
216+
/// (e.g. "第3章" → NodeId of Chapter 3). Search should start from these nodes.
217+
pub resolved_path_hints: Vec<(String, NodeId)>,
215218
/// Decomposed sub-queries (if query was decomposed).
216219
pub decomposition: Option<crate::retrieval::decompose::DecompositionResult>,
217220

@@ -275,6 +278,7 @@ impl PipelineContext {
275278
complexity: None,
276279
keywords: Vec::new(),
277280
target_sections: Vec::new(),
281+
resolved_path_hints: Vec::new(),
278282
decomposition: None,
279283
selected_strategy: None,
280284
selected_algorithm: None,

rust/src/retrieval/stages/analyze.rs

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
use async_trait::async_trait;
1313
use tracing::info;
1414

15-
use crate::document::{DocumentTree, TocView};
15+
use crate::document::{DocumentTree, NodeId, TocView};
1616
use crate::retrieval::complexity::ComplexityDetector;
1717
use crate::retrieval::decompose::{DecompositionConfig, QueryDecomposer};
1818
use crate::retrieval::pipeline::{FailurePolicy, PipelineContext, RetrievalStage, StageOutcome};
@@ -29,6 +29,56 @@ use crate::llm::LlmClient;
2929
///
3030
/// # Example
3131
///
32+
/// Convert Chinese number string to integer (e.g. "三" → 3, "二十一" → 21).
33+
fn chinese_num_to_int(s: &str) -> Option<usize> {
34+
let chars: Vec<char> = s.chars().collect();
35+
if chars.is_empty() {
36+
return None;
37+
}
38+
// If purely digits, parse directly
39+
if chars.iter().all(|c| c.is_ascii_digit()) {
40+
return s.parse().ok();
41+
}
42+
let map = |c: char| -> usize {
43+
match c {
44+
'一' => 1, '二' => 2, '三' => 3, '四' => 4, '五' => 5,
45+
'六' => 6, '七' => 7, '八' => 8, '九' => 9, '十' => 10,
46+
'百' => 100,
47+
_ => 0,
48+
}
49+
};
50+
// Simple two-pass: handle 十/百 as positional
51+
let mut total: usize = 0;
52+
let mut current: usize = 0;
53+
for &c in &chars {
54+
let v = map(c);
55+
if v == 0 {
56+
continue;
57+
}
58+
if v >= 10 {
59+
// Positional multiplier
60+
let base = if current == 0 { 1 } else { current };
61+
total += base * v;
62+
current = 0;
63+
} else {
64+
current = v;
65+
}
66+
}
67+
total += current;
68+
if total > 0 { Some(total) } else { None }
69+
}
70+
71+
/// Analyze Stage - analyzes queries for retrieval planning.
72+
///
73+
/// This stage:
74+
/// 1. Detects query complexity (Simple/Medium/Complex)
75+
/// 2. Extracts keywords for matching
76+
/// 3. Matches target sections from ToC
77+
/// 4. Extracts structural path hints (Section 3.2, 第3章, etc.)
78+
/// 5. Decomposes complex queries into sub-queries (if enabled)
79+
///
80+
/// # Example
81+
///
3282
/// ```rust,ignore
3383
/// let stage = AnalyzeStage::new()
3484
/// .with_toc_matching(true)
@@ -145,6 +195,87 @@ impl AnalyzeStage {
145195
.collect()
146196
}
147197

198+
/// Extract structural path hints from the query.
199+
///
200+
/// Recognizes patterns like:
201+
/// - "第3章", "第2节", "第一章" (Chinese chapter/section)
202+
/// - "Section 3.2", "section 4.1.2" (English section numbers)
203+
/// - "Chapter 5", "chapter 10" (English chapter)
204+
/// - "3.2.1", "2.1" (bare section numbers)
205+
/// - "表3", "Table 5", "图2", "Figure 4" (table/figure references)
206+
///
207+
/// Maps them to tree NodeIds via `find_by_structure()`.
208+
fn extract_structure_hints(&self, query: &str, tree: &DocumentTree) -> Vec<(String, NodeId)> {
209+
let mut hints = Vec::new();
210+
211+
// Chinese patterns: 第X章, 第X节, 第X部分
212+
for cap in regex::Regex::new(r"第([一二三四五六七八九十百\d]+)[章节部分]")
213+
.unwrap()
214+
.captures_iter(query)
215+
{
216+
let num = chinese_num_to_int(&cap[1]).unwrap_or(0);
217+
if num > 0 {
218+
if let Some(node_id) = tree.find_by_structure(&num.to_string()) {
219+
hints.push((cap[0].to_string(), node_id));
220+
}
221+
}
222+
}
223+
224+
// "Section X.Y.Z" or "section X.Y"
225+
for cap in regex::Regex::new(r"(?i)section\s+(\d+(?:\.\d+)*)")
226+
.unwrap()
227+
.captures_iter(query)
228+
{
229+
if let Some(node_id) = tree.find_by_structure(&cap[1]) {
230+
hints.push((cap[0].to_string(), node_id));
231+
}
232+
}
233+
234+
// "Chapter X"
235+
for cap in regex::Regex::new(r"(?i)chapter\s+(\d+)")
236+
.unwrap()
237+
.captures_iter(query)
238+
{
239+
if let Some(node_id) = tree.find_by_structure(&cap[1]) {
240+
hints.push((cap[0].to_string(), node_id));
241+
}
242+
}
243+
244+
// Bare section numbers: "3.2.1", "2.1"
245+
for cap in regex::Regex::new(r"(?<!\w)(\d+\.\d+(?:\.\d+)*)")
246+
.unwrap()
247+
.captures_iter(query)
248+
{
249+
if let Some(node_id) = tree.find_by_structure(&cap[1]) {
250+
hints.push((cap[0].to_string(), node_id));
251+
}
252+
}
253+
254+
// Table/Figure references
255+
for cap in regex::Regex::new(r"(?:表|(?i)table)\s*(\d+)")
256+
.unwrap()
257+
.captures_iter(query)
258+
{
259+
if let Some(node_id) = tree.find_by_structure(&format!("table {}", &cap[1])) {
260+
hints.push((cap[0].to_string(), node_id));
261+
}
262+
}
263+
for cap in regex::Regex::new(r"(?:图|(?i)figure)\s*(\d+)")
264+
.unwrap()
265+
.captures_iter(query)
266+
{
267+
if let Some(node_id) = tree.find_by_structure(&format!("figure {}", &cap[1])) {
268+
hints.push((cap[0].to_string(), node_id));
269+
}
270+
}
271+
272+
// Deduplicate by NodeId
273+
let mut seen = std::collections::HashSet::new();
274+
hints.retain(|(_, nid)| seen.insert(*nid));
275+
276+
hints
277+
}
278+
148279
/// Match target sections from ToC.
149280
fn match_toc_sections(&self, query: &str, tree: &DocumentTree) -> Vec<String> {
150281
if !self.enable_toc_matching {
@@ -232,6 +363,16 @@ impl RetrievalStage for AnalyzeStage {
232363
info!("Target sections: {:?}", ctx.target_sections);
233364
}
234365

366+
// 3.5 Extract structural path hints
367+
ctx.resolved_path_hints = self.extract_structure_hints(&ctx.query, &ctx.tree);
368+
if !ctx.resolved_path_hints.is_empty() {
369+
info!(
370+
"Resolved {} structure hints: {:?}",
371+
ctx.resolved_path_hints.len(),
372+
ctx.resolved_path_hints.iter().map(|(s, _)| s).collect::<Vec<_>>()
373+
);
374+
}
375+
235376
// 4. Decompose query if enabled and complex enough
236377
if self.enable_decomposition {
237378
if let Some(ref decomposer) = self.query_decomposer {
@@ -279,6 +420,12 @@ impl RetrievalStage for AnalyzeStage {
279420
if !ctx.target_sections.is_empty() {
280421
reasoning_parts.push(format!("Target sections: {:?}", ctx.target_sections));
281422
}
423+
if !ctx.resolved_path_hints.is_empty() {
424+
reasoning_parts.push(format!(
425+
"Structure hints: {:?}",
426+
ctx.resolved_path_hints.iter().map(|(s, _)| s).collect::<Vec<_>>()
427+
));
428+
}
282429
if let Some(ref decomp) = ctx.decomposition {
283430
if decomp.was_decomposed {
284431
reasoning_parts.push(format!(

rust/src/retrieval/stages/search.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,26 @@ impl RetrievalStage for SearchStage {
383383
.map(|nodes| nodes.to_vec())
384384
.unwrap_or_else(|| ctx.tree.children(ctx.tree.root()));
385385

386-
let cues = self
386+
let mut cues = self
387387
.toc_navigator
388388
.locate(&ctx.query, &ctx.tree, &top_level_nodes)
389389
.await;
390390

391391
debug!("ToCNavigator returned {} cues", cues.len());
392392

393+
// Inject structure hints from Analyze stage as high-priority cues
394+
if !ctx.resolved_path_hints.is_empty() {
395+
for (hint_text, node_id) in &ctx.resolved_path_hints {
396+
if ctx.tree.get(*node_id).is_some() {
397+
info!("Injecting structure hint '{}' as search cue", hint_text);
398+
cues.push(SearchCue {
399+
root: *node_id,
400+
confidence: 1.0, // Direct match from query structure
401+
});
402+
}
403+
}
404+
}
405+
393406
// === Resolve queries (decomposed or original) ===
394407
let queries = Self::resolve_queries(ctx);
395408

0 commit comments

Comments
 (0)