Skip to content

Commit 419349d

Browse files
committed
feat(toc): implement bounded concurrency for LLM operations
- Replace join_all with stream::buffer_unordered(5) for page assignment - Add bounded concurrency to TOC verification with buffer_unordered(5) - Implement bounded concurrency for index repair with buffer_unordered(5) - Use stream processing instead of collecting all futures at once - Prevent rate limiting by limiting concurrent LLM requests This change improves performance and reliability by preventing excessive concurrent API calls to LLM services. fix(structure-extractor): optimize hierarchical structure extraction - Process first page group as initial structure, then remaining groups in parallel with bounded concurrency - Add static version of continuation generation for parallel use - Improve error handling for failed continuation groups - Add proper entry deduplication and sorting logic - Maintain shared context from initial entries for all continuations The extraction now follows a phased approach: initial structure generation followed by parallel continuation processing, which improves both accuracy and performance.
1 parent 369bbf5 commit 419349d

5 files changed

Lines changed: 203 additions & 73 deletions

File tree

rust/src/index/parse/toc/assigner.rs

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! Page assigner - assigns physical page numbers to TOC entries.
55
66
use std::collections::HashMap;
7-
use futures::future::join_all;
7+
use futures::stream::{self, StreamExt};
88
use tracing::{debug, info};
99

1010
use crate::config::LlmConfig;
@@ -175,7 +175,10 @@ impl PageAssigner {
175175
})
176176
.collect();
177177

178-
let verified_offsets = join_all(futures).await;
178+
let verified_offsets: Vec<_> = stream::iter(futures)
179+
.buffer_unordered(5)
180+
.collect()
181+
.await;
179182

180183
// Calculate the mode (most common offset)
181184
let successful: Vec<_> = verified_offsets
@@ -265,29 +268,32 @@ Reply in JSON format:
265268
Ok(result.page)
266269
}
267270

268-
/// Assign pages using LLM for each entry (concurrently).
271+
/// Assign pages using LLM for each entry (with bounded concurrency).
269272
async fn assign_with_llm(&self, entries: &mut [TocEntry], pages: &[PdfPage]) -> Result<()> {
270273
info!("Assigning pages using LLM positioning");
271274

272275
let client = self.client.clone();
273276
let pages_owned = pages.to_vec();
277+
let total = entries.len();
274278

275-
// Launch all entry searches concurrently
276-
let futures: Vec<_> = entries
277-
.iter()
278-
.map(|entry| {
279-
let title = entry.title.clone();
280-
let client = client.clone();
281-
let pages = pages_owned.clone();
279+
// Launch entry searches with bounded concurrency to avoid rate limiting
280+
let futures: Vec<_> = entries.iter().map(|entry| {
281+
let title = entry.title.clone();
282+
let client = client.clone();
283+
let pages = pages_owned.clone();
282284

283-
async move {
284-
let groups = Self::group_pages_owned(&pages, 5);
285-
Self::locate_title_in_groups_static(&client, &title, &groups).await
286-
}
287-
})
288-
.collect();
285+
async move {
286+
let groups = Self::group_pages_owned(&pages, 5);
287+
Self::locate_title_in_groups_static(&client, &title, &groups).await
288+
}
289+
}).collect();
290+
291+
let results: Vec<_> = stream::iter(futures)
292+
.buffer_unordered(5)
293+
.collect()
294+
.await;
289295

290-
let results = join_all(futures).await;
296+
info!("Assigned pages for {}/{} entries", results.len(), total);
291297

292298
// Write results back
293299
for (entry, result) in entries.iter_mut().zip(results.into_iter()) {

rust/src/index/parse/toc/processor.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//! degradation: if one mode fails verification, it falls back to a lower-quality
88
//! but more reliable mode.
99
10-
use futures::future::join_all;
10+
use futures::stream::{self, StreamExt};
1111
use tracing::{debug, info, warn};
1212

1313
use crate::error::Result;
@@ -505,7 +505,10 @@ impl TocProcessor {
505505
})
506506
.collect();
507507

508-
let extraction_results = join_all(oversized_futures).await;
508+
let extraction_results: Vec<_> = stream::iter(oversized_futures)
509+
.buffer_unordered(3)
510+
.collect()
511+
.await;
509512

510513
// Build a lookup from index → refined sub-entries
511514
let mut refined_map = std::collections::HashMap::new();

rust/src/index/parse/toc/repairer.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
//! Index repairer - fixes incorrect TOC entry page assignments.
55
6-
use futures::future::join_all;
6+
use futures::stream::{self, StreamExt};
77
use tracing::{debug, info};
88

99
use crate::config::LlmConfig;
@@ -63,7 +63,7 @@ impl IndexRepairer {
6363
Self::new(RepairerConfig::default())
6464
}
6565

66-
/// Repair incorrect entries concurrently.
66+
/// Repair incorrect entries with bounded concurrency.
6767
pub async fn repair(
6868
&self,
6969
entries: &mut [TocEntry],
@@ -107,7 +107,10 @@ impl IndexRepairer {
107107
})
108108
.collect();
109109

110-
let results = join_all(tasks).await;
110+
let results: Vec<_> = stream::iter(tasks)
111+
.buffer_unordered(5)
112+
.collect()
113+
.await;
111114

112115
// Apply repairs
113116
let mut repaired_count = 0;

rust/src/index/parse/toc/structure_extractor.rs

Lines changed: 141 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//! module uses LLM to analyse page content and extract the document's
88
//! hierarchical structure directly.
99
10+
use futures::stream::{self, StreamExt};
1011
use tracing::{debug, info, warn};
1112

1213
use crate::config::LlmConfig;
@@ -40,6 +41,7 @@ impl Default for StructureExtractorConfig {
4041
}
4142

4243
/// A group of consecutive pages with their combined text.
44+
#[derive(Clone)]
4345
struct PageGroup {
4446
/// Combined text with page markers: `<page_N>\n...\n</page_N>`.
4547
text: String,
@@ -77,42 +79,102 @@ impl StructureExtractor {
7779
}
7880

7981
/// Extract hierarchical structure from all pages.
82+
///
83+
/// The first page group is processed alone (initial structure), then all
84+
/// remaining groups are processed in parallel, each using the initial
85+
/// entries as context. Results are merged and deduplicated.
8086
pub async fn extract(&self, pages: &[PdfPage]) -> Result<Vec<TocEntry>> {
8187
if pages.is_empty() {
8288
return Ok(Vec::new());
8389
}
8490

8591
let groups = self.group_pages(pages);
92+
let page_count = pages.len();
8693
info!(
8794
"Extracting structure from {} pages in {} groups",
88-
pages.len(),
95+
page_count,
8996
groups.len()
9097
);
9198

92-
let mut all_entries = Vec::new();
93-
let page_count = pages.len();
99+
// Phase 1: Generate initial structure from first group
100+
let initial_entries = self.generate_initial(&groups[0]).await?;
101+
debug!(
102+
"Initial group (pages {}-{}): extracted {} entries",
103+
groups[0].start_page,
104+
groups[0].end_page,
105+
initial_entries.len()
106+
);
94107

95-
for (i, group) in groups.iter().enumerate() {
96-
let group_entries = if i == 0 {
97-
self.generate_initial(group).await?
98-
} else {
99-
self.generate_continuation(group, &all_entries).await?
100-
};
108+
if groups.len() == 1 {
109+
return Ok(Self::finalize_entries(initial_entries, page_count));
110+
}
101111

102-
debug!(
103-
"Group {}/{} (pages {}-{}): extracted {} entries",
104-
i + 1,
105-
groups.len(),
106-
group.start_page,
107-
group.end_page,
108-
group_entries.len()
109-
);
112+
// Phase 2: Process remaining groups in parallel (bounded concurrency)
113+
// Each continuation group uses the initial entries as shared context.
114+
let client = self.client.clone();
115+
let initial_entries_ref = &initial_entries;
110116

111-
all_entries.extend(group_entries);
117+
let continuation_futures: Vec<_> = groups[1..]
118+
.iter()
119+
.map(|group| {
120+
let group = group.clone();
121+
let client = client.clone();
122+
let initial = initial_entries_ref.to_vec();
123+
124+
async move {
125+
let result = Self::generate_continuation_with_client(
126+
&client, &group, &initial,
127+
)
128+
.await;
129+
(group.start_page, group.end_page, result)
130+
}
131+
})
132+
.collect();
133+
134+
let continuation_results: Vec<_> = stream::iter(continuation_futures)
135+
.buffer_unordered(5)
136+
.collect()
137+
.await;
138+
139+
// Phase 3: Merge initial + continuation entries
140+
let mut all_entries = initial_entries;
141+
for (start, end, result) in continuation_results {
142+
match result {
143+
Ok(entries) => {
144+
debug!(
145+
"Continuation group (pages {}-{}): extracted {} entries",
146+
start,
147+
end,
148+
entries.len()
149+
);
150+
all_entries.extend(entries);
151+
}
152+
Err(e) => {
153+
warn!(
154+
"Continuation group (pages {}-{}) failed: {}",
155+
start, end, e
156+
);
157+
}
158+
}
112159
}
113160

114-
// Truncate physical_page values that exceed document length
115-
for entry in &mut all_entries {
161+
// Phase 4: Sort by page number, deduplicate, truncate
162+
all_entries.sort_by(|a, b| {
163+
a.physical_page
164+
.unwrap_or(0)
165+
.cmp(&b.physical_page.unwrap_or(0))
166+
});
167+
all_entries.dedup_by(|a, b| {
168+
a.title.trim() == b.title.trim()
169+
&& a.physical_page == b.physical_page
170+
});
171+
172+
Ok(Self::finalize_entries(all_entries, page_count))
173+
}
174+
175+
/// Truncate out-of-range page numbers and log stats.
176+
fn finalize_entries(mut entries: Vec<TocEntry>, page_count: usize) -> Vec<TocEntry> {
177+
for entry in &mut entries {
116178
if let Some(p) = entry.physical_page {
117179
if p > page_count {
118180
warn!(
@@ -123,9 +185,8 @@ impl StructureExtractor {
123185
}
124186
}
125187
}
126-
127-
info!("Structure extraction complete: {} entries", all_entries.len());
128-
Ok(all_entries)
188+
info!("Structure extraction complete: {} entries", entries.len());
189+
entries
129190
}
130191

131192
/// Group pages by estimated token count.
@@ -267,6 +328,63 @@ If no new structural elements are found, return: []"#,
267328
})
268329
.collect())
269330
}
331+
332+
/// Static version of continuation generation for parallel use.
333+
///
334+
/// Uses an owned `LlmClient` reference instead of `&self`.
335+
async fn generate_continuation_with_client(
336+
client: &LlmClient,
337+
group: &PageGroup,
338+
previous: &[TocEntry],
339+
) -> Result<Vec<TocEntry>> {
340+
let system = STRUCTURE_EXTRACTION_SYSTEM_PROMPT;
341+
342+
let prev_summary = previous
343+
.iter()
344+
.rev()
345+
.take(10)
346+
.rev()
347+
.map(|e| {
348+
format!(
349+
" {{\"title\": \"{}\", \"level\": {}, \"physical_page\": {}}}",
350+
e.title,
351+
e.level,
352+
e.physical_page.unwrap_or(0)
353+
)
354+
})
355+
.collect::<Vec<_>>()
356+
.join(",\n");
357+
358+
let user = format!(
359+
r#"Previously extracted structure:
360+
[
361+
{}
362+
]
363+
364+
Continue extracting structure from these pages:
365+
{}
366+
367+
Return ONLY the NEW entries (do not repeat previous ones):
368+
[
369+
{{"title": "...", "level": N, "physical_page": M}},
370+
...
371+
]
372+
373+
If no new structural elements are found, return: []"#,
374+
prev_summary, group.text
375+
);
376+
377+
let sections: Vec<ExtractedSection> = client.complete_json(system, &user).await?;
378+
379+
Ok(sections
380+
.into_iter()
381+
.map(|s| {
382+
TocEntry::new(s.title, s.level)
383+
.with_physical_page(s.physical_page)
384+
.with_confidence(0.7)
385+
})
386+
.collect())
387+
}
270388
}
271389

272390
/// Format pages into tagged text for LLM consumption.

0 commit comments

Comments
 (0)