Skip to content

Commit 9b123c1

Browse files
committed
feat(indexer): add synonym expansion and cross-reference resolution
- Add enable_synonym_expansion option to IndexOptions for expanding keywords with LLM-generated synonyms during indexing to improve recall for differently-worded queries - Implement cross-reference resolution in enrich stage to extract and resolve in-document references like "see Section 2.1" or "Appendix G" to actual node IDs in the document tree - Add DocumentTree::set_references method for managing node references and children_with_refs for including referenced nodes - Include resolved reference count in indexing metrics
1 parent 0c3f3dd commit 9b123c1

6 files changed

Lines changed: 195 additions & 5 deletions

File tree

python/src/lib.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ fn parse_format(format: &str) -> PyResult<DocumentFormat> {
107107
/// generate_description: Whether to generate document description. Default: False.
108108
/// include_text: Whether to include node text in the tree. Default: True.
109109
/// generate_ids: Whether to generate node IDs. Default: True.
110+
/// enable_synonym_expansion: Whether to expand keywords with LLM-generated
111+
/// synonyms during indexing. Improves recall for differently-worded queries.
112+
/// Default: False.
110113
#[pyclass(name = "IndexOptions", skip_from_py_object)]
111114
#[derive(Clone)]
112115
pub struct PyIndexOptions {
@@ -116,13 +119,14 @@ pub struct PyIndexOptions {
116119
#[pymethods]
117120
impl PyIndexOptions {
118121
#[new]
119-
#[pyo3(signature = (mode="default", generate_summaries=true, generate_description=false, include_text=true, generate_ids=true))]
122+
#[pyo3(signature = (mode="default", generate_summaries=true, generate_description=false, include_text=true, generate_ids=true, enable_synonym_expansion=false))]
120123
fn new(
121124
mode: &str,
122125
generate_summaries: bool,
123126
generate_description: bool,
124127
include_text: bool,
125128
generate_ids: bool,
129+
enable_synonym_expansion: bool,
126130
) -> PyResult<Self> {
127131
let mut opts = IndexOptions::new();
128132
match mode {
@@ -140,12 +144,13 @@ impl PyIndexOptions {
140144
opts.generate_description = generate_description;
141145
opts.include_text = include_text;
142146
opts.generate_ids = generate_ids;
147+
opts.enable_synonym_expansion = enable_synonym_expansion;
143148
Ok(Self { inner: opts })
144149
}
145150

146151
fn __repr__(&self) -> String {
147152
format!(
148-
"IndexOptions(mode='{}', generate_summaries={}, generate_description={}, include_text={}, generate_ids={})",
153+
"IndexOptions(mode='{}', generate_summaries={}, generate_description={}, include_text={}, generate_ids={}, enable_synonym_expansion={})",
149154
match self.inner.mode {
150155
IndexMode::Default => "default",
151156
IndexMode::Force => "force",
@@ -155,6 +160,7 @@ impl PyIndexOptions {
155160
self.inner.generate_description,
156161
self.inner.include_text,
157162
self.inner.generate_ids,
163+
self.inner.enable_synonym_expansion,
158164
)
159165
}
160166
}

rust/src/client/indexer.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use uuid::Uuid;
2828

2929
use crate::error::{Error, Result};
3030
use crate::index::parse::DocumentFormat;
31-
use crate::index::{IndexInput, IndexMode, PipelineExecutor, PipelineOptions, SummaryStrategy};
31+
use crate::index::{IndexInput, IndexMode, PipelineExecutor, PipelineOptions, ReasoningIndexConfig, SummaryStrategy};
3232
use crate::llm::LlmClient;
3333
use crate::storage::{DocumentMeta, PersistedDocument};
3434

@@ -285,6 +285,10 @@ impl IndexerClient {
285285
SummaryStrategy::none()
286286
},
287287
generate_description: options.generate_description,
288+
reasoning_index: ReasoningIndexConfig {
289+
enable_synonym_expansion: options.enable_synonym_expansion,
290+
..ReasoningIndexConfig::default()
291+
},
288292
existing_tree,
289293
..Default::default()
290294
}

rust/src/client/types.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ pub struct IndexOptions {
206206

207207
/// Whether to generate document description.
208208
pub generate_description: bool,
209+
210+
/// Whether to expand keywords with LLM-generated synonyms
211+
/// during reasoning index construction. Improves recall for
212+
/// queries that use different wording than the document.
213+
pub enable_synonym_expansion: bool,
209214
}
210215

211216
impl Default for IndexOptions {
@@ -216,6 +221,7 @@ impl Default for IndexOptions {
216221
include_text: true,
217222
generate_ids: true,
218223
generate_description: false,
224+
enable_synonym_expansion: true,
219225
}
220226
}
221227
}

rust/src/document/tree.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,13 @@ impl DocumentTree {
622622
}
623623
}
624624

625+
/// Set the references for a node.
626+
pub fn set_references(&mut self, id: NodeId, references: Vec<super::reference::NodeReference>) {
627+
if let Some(node) = self.get_mut(id) {
628+
node.references = references;
629+
}
630+
}
631+
625632
/// Export the tree structure to JSON format.
626633
pub fn to_structure_json(&self, doc_name: &str) -> DocumentStructure {
627634
let structure = self.build_structure_nodes(self.root_id);
@@ -795,3 +802,88 @@ impl Default for DocumentTree {
795802
Self::new("Root", "")
796803
}
797804
}
805+
806+
#[cfg(test)]
807+
mod tests {
808+
use super::*;
809+
use crate::document::reference::{NodeReference, RefType};
810+
811+
#[test]
812+
fn test_children_with_refs_no_references() {
813+
let mut tree = DocumentTree::new("Root", "root content");
814+
let child1 = tree.add_child(tree.root(), "Section 1", "content 1");
815+
let child2 = tree.add_child(tree.root(), "Section 2", "content 2");
816+
817+
let children = tree.children_with_refs(tree.root());
818+
assert_eq!(children.len(), 2);
819+
assert!(children.contains(&child1));
820+
assert!(children.contains(&child2));
821+
}
822+
823+
#[test]
824+
fn test_children_with_refs_includes_resolved_references() {
825+
let mut tree = DocumentTree::new("Root", "root content");
826+
let section1 = tree.add_child(tree.root(), "Section 1", "content 1");
827+
let section2 = tree.add_child(tree.root(), "Section 2", "content 2");
828+
let appendix = tree.add_child(tree.root(), "Appendix A", "appendix content");
829+
830+
// Add a resolved reference from Section 1 to Appendix A
831+
let refs = vec![NodeReference::resolved(
832+
"see Appendix A".to_string(),
833+
"A".to_string(),
834+
RefType::Appendix,
835+
10,
836+
appendix,
837+
0.9,
838+
)];
839+
tree.set_references(section1, refs);
840+
841+
// section1's children_with_refs should include appendix as a reference target
842+
let children = tree.children_with_refs(section1);
843+
// section1 has no direct children, but has a resolved reference to appendix
844+
assert_eq!(children.len(), 1);
845+
assert!(children.contains(&appendix));
846+
}
847+
848+
#[test]
849+
fn test_children_with_refs_deduplicates() {
850+
let mut tree = DocumentTree::new("Root", "root content");
851+
let child = tree.add_child(tree.root(), "Section 1", "content 1");
852+
853+
// Add a reference that points to the same node as an existing child
854+
let refs = vec![NodeReference::resolved(
855+
"see Section 1".to_string(),
856+
"1".to_string(),
857+
RefType::Section,
858+
5,
859+
child,
860+
0.8,
861+
)];
862+
tree.set_references(tree.root(), refs);
863+
864+
let children = tree.children_with_refs(tree.root());
865+
// Should not duplicate
866+
assert_eq!(children.len(), 1);
867+
assert!(children.contains(&child));
868+
}
869+
870+
#[test]
871+
fn test_children_with_refs_unresolved_ignored() {
872+
let mut tree = DocumentTree::new("Root", "root content");
873+
let child = tree.add_child(tree.root(), "Section 1", "content 1");
874+
875+
// Add an unresolved reference (target_node = None)
876+
let refs = vec![NodeReference::new(
877+
"see Section 5".to_string(),
878+
"5".to_string(),
879+
RefType::Section,
880+
5,
881+
)];
882+
tree.set_references(tree.root(), refs);
883+
884+
let children = tree.children_with_refs(tree.root());
885+
// Unresolved reference should not be included
886+
assert_eq!(children.len(), 1);
887+
assert!(children.contains(&child));
888+
}
889+
}

rust/src/index/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ pub use pipeline::{IndexInput, IndexMetrics, PipelineExecutor, PipelineResult};
6464

6565
// Re-export config types
6666
pub use config::{IndexMode, PipelineOptions, ThinningConfig};
67+
pub use crate::document::ReasoningIndexConfig;
6768

6869
// Re-export summary
6970
pub use summary::SummaryStrategy;

rust/src/index/stages/enrich.rs

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use super::async_trait;
77
use std::time::Instant;
88
use tracing::info;
99

10-
use crate::document::{DocumentTree, NodeId, TocView};
10+
use crate::document::{DocumentTree, NodeId, RefType, ReferenceExtractor, TocView};
1111
use crate::error::Result;
1212

1313
use super::{AccessPattern, IndexStage, StageResult};
@@ -93,6 +93,46 @@ impl EnrichStage {
9393
}
9494
}
9595
}
96+
97+
/// Extract and resolve in-document cross-references for all nodes.
98+
///
99+
/// Parses content for patterns like "see Section 2.1", "Appendix G", etc.
100+
/// and resolves them to actual `NodeId`s in the tree using the retrieval
101+
/// index for fast lookup.
102+
fn resolve_references(tree: &mut DocumentTree) -> usize {
103+
let retrieval_index = tree.build_retrieval_index();
104+
let node_ids: Vec<NodeId> = tree.traverse().into_iter().collect();
105+
let mut total_resolved = 0;
106+
107+
for node_id in node_ids {
108+
let content = tree.get(node_id).map(|n| n.content.clone()).unwrap_or_default();
109+
if content.is_empty() {
110+
continue;
111+
}
112+
113+
// Quick check: skip nodes without any reference-like patterns
114+
let content_lower = content.to_lowercase();
115+
let has_ref_pattern = content_lower.contains("section")
116+
|| content_lower.contains("appendix")
117+
|| content_lower.contains("table")
118+
|| content_lower.contains("figure")
119+
|| content_lower.contains("page")
120+
|| content_lower.contains("equation");
121+
122+
if !has_ref_pattern {
123+
continue;
124+
}
125+
126+
let refs = ReferenceExtractor::extract_and_resolve(&content, tree, &retrieval_index);
127+
let resolved = refs.iter().filter(|r| r.is_resolved()).count();
128+
if resolved > 0 {
129+
total_resolved += resolved;
130+
}
131+
tree.set_references(node_id, refs);
132+
}
133+
134+
total_resolved
135+
}
96136
}
97137

98138
impl Default for EnrichStage {
@@ -142,7 +182,13 @@ impl IndexStage for EnrichStage {
142182
let (total_tokens, node_count) = Self::calculate_token_stats(tree);
143183
info!("Total tokens: {}, nodes: {}", total_tokens, node_count);
144184

145-
// 4. Generate document description
185+
// 4. Extract and resolve cross-references
186+
let resolved_refs = Self::resolve_references(tree);
187+
if resolved_refs > 0 {
188+
info!("Resolved {} cross-references", resolved_refs);
189+
}
190+
191+
// 5. Generate document description
146192
self.generate_description(ctx);
147193

148194
let duration = start.elapsed().as_millis() as u64;
@@ -158,7 +204,42 @@ impl IndexStage for EnrichStage {
158204
stage_result
159205
.metadata
160206
.insert("node_count".to_string(), serde_json::json!(node_count));
207+
stage_result
208+
.metadata
209+
.insert("resolved_references".to_string(), serde_json::json!(resolved_refs));
161210

162211
Ok(stage_result)
163212
}
164213
}
214+
215+
#[cfg(test)]
216+
mod tests {
217+
use super::*;
218+
219+
#[test]
220+
fn test_resolve_references_section_ref() {
221+
let mut tree = DocumentTree::new("Root", "root content");
222+
let s1 = tree.add_child(tree.root(), "Introduction", "Introduction text.");
223+
tree.set_structure(s1, "1");
224+
let s2 = tree.add_child(tree.root(), "Details", "For details, see Section 1 for more info");
225+
tree.set_structure(s2, "2");
226+
227+
let resolved = EnrichStage::resolve_references(&mut tree);
228+
assert_eq!(resolved, 1);
229+
230+
// Verify the reference was stored on s2 and resolved to s1
231+
let refs = tree.get(s2).unwrap().references.clone();
232+
assert_eq!(refs.len(), 1);
233+
assert_eq!(refs[0].ref_type, RefType::Section);
234+
assert_eq!(refs[0].target_node, Some(s1));
235+
}
236+
237+
#[test]
238+
fn test_resolve_references_no_refs() {
239+
let mut tree = DocumentTree::new("Root", "root content");
240+
tree.add_child(tree.root(), "Section 1", "No references here.");
241+
242+
let resolved = EnrichStage::resolve_references(&mut tree);
243+
assert_eq!(resolved, 0);
244+
}
245+
}

0 commit comments

Comments
 (0)