Skip to content

Commit 8c39a61

Browse files
committed
feat(rag): enable multi-document and page-level reasoning
Signed-off-by: Cesar Berrospi Ramis <ceb@zurich.ibm.com>
1 parent 2d88ad2 commit 8c39a61

1 file changed

Lines changed: 260 additions & 6 deletions

File tree

docling_agent/agent/rag.py

Lines changed: 260 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ class DoclingRAGAgent(BaseDoclingAgent):
5959

6060
max_iterations: int = 5
6161
verbose: bool = False
62+
enable_document_selection: bool = False # NEW: Enable document filtering before RAG
63+
use_page_level: bool = False # NEW: Use pages instead of sections
64+
use_batch_selection: bool = False # NEW: Batch-based selection instead of iterative
65+
batch_size: int = 30 # NEW: Pages/sections per batch
66+
top_k: int = 10 # NEW: Maximum pages/sections to select
6267

6368
def __init__(
6469
self,
@@ -67,6 +72,11 @@ def __init__(
6772
backend=None,
6873
max_iterations: int = 5,
6974
verbose: bool = False,
75+
enable_document_selection: bool = False,
76+
use_page_level: bool = False,
77+
use_batch_selection: bool = False,
78+
batch_size: int = 30,
79+
top_k: int = 10,
7080
):
7181
super().__init__(
7282
agent_type=DoclingAgentType.DOCLING_DOCUMENT_RAG,
@@ -75,6 +85,11 @@ def __init__(
7585
)
7686
self.max_iterations = max_iterations
7787
self.verbose = verbose
88+
self.enable_document_selection = enable_document_selection
89+
self.use_page_level = use_page_level
90+
self.use_batch_selection = use_batch_selection
91+
self.batch_size = batch_size
92+
self.top_k = top_k
7893
self._console = Console(highlight=False) if verbose else None
7994

8095
def _rprint(self, renderable: Any) -> None:
@@ -95,6 +110,31 @@ def run(
95110
if not docs:
96111
raise ValueError("DoclingRAGAgent requires at least one DoclingDocument.")
97112

113+
# Phase 1: Optional document selection
114+
if self.enable_document_selection and len(docs) > 1:
115+
self._rprint(Rule(f"[bold cyan]Selecting relevant documents from {len(docs)} candidates[/bold cyan]"))
116+
117+
# Build document summaries
118+
documents_dict = {doc.name: doc for doc in docs}
119+
doc_summaries = {doc.name: self._extract_document_summary(doc) for doc in docs}
120+
121+
# Select relevant documents
122+
selected_doc_names = self._select_relevant_documents(
123+
query=task,
124+
documents=documents_dict,
125+
doc_summaries=doc_summaries,
126+
)
127+
128+
# Filter docs to only selected ones
129+
docs = [documents_dict[name] for name in selected_doc_names if name in documents_dict]
130+
self._rprint(
131+
Panel(
132+
f"Selected {len(docs)} document(s): {[d.name for d in docs]}",
133+
title="[cyan]Document Selection[/cyan]",
134+
border_style="cyan",
135+
)
136+
)
137+
98138
per_doc_answers: list[str] = []
99139
all_iterations: list[RAGIteration] = []
100140

@@ -261,18 +301,111 @@ def _rag_loop(self, *, query: str, doc: DoclingDocument) -> RAGResult:
261301
# ------------------------------------------------------------------
262302

263303
def _build_outline(self, doc: DoclingDocument) -> str:
304+
"""Build outline or page list based on mode."""
305+
if self.use_page_level:
306+
# Page-level mode: build a list of pages with summaries
307+
return self._build_page_list(doc)
308+
309+
# Section-level mode: use standard outline
264310
serializer = OutlineDocSerializer(
265311
doc=doc,
266312
params=OutlineParams(mode=OutlineMode.OUTLINE),
267313
)
268314
return serializer.serialize().text
269315

316+
def _build_page_list(self, doc: DoclingDocument) -> str:
317+
"""Build a formatted list of pages with their summaries."""
318+
lines = []
319+
num_pages = len(doc.pages) if hasattr(doc, "pages") and doc.pages else 0
320+
321+
for i in range(num_pages):
322+
page_ref = f"#/pages/{i}"
323+
page_num = i + 1 # 1-indexed for display
324+
325+
# Get page summary if available
326+
summary = "No summary available"
327+
page_node = get_item_by_ref(doc, page_ref)
328+
if page_node and hasattr(page_node, "children") and page_node.children:
329+
# Look for summary in first child's meta
330+
first_child = page_node.children[0]
331+
if hasattr(first_child, "meta") and first_child.meta:
332+
meta_dict = first_child.meta.model_dump() if hasattr(first_child.meta, "model_dump") else {}
333+
if "summary" in meta_dict and isinstance(meta_dict["summary"], dict):
334+
summary = meta_dict["summary"].get("text", summary)
335+
336+
# Use page_ref directly in the display (e.g., "#/pages/0")
337+
lines.append(f"Page {page_num} ({page_ref}): {summary}")
338+
339+
return "\n".join(lines)
340+
270341
def _extract_section_refs(self, doc: DoclingDocument) -> set[str]:
271-
refs: set[str] = set()
272-
for item, _ in doc.iterate_items():
273-
if isinstance(item, TitleItem | SectionHeaderItem):
274-
refs.add(item.self_ref)
275-
return refs
342+
"""Extract section or page references based on mode.
343+
344+
Returns:
345+
Set of section refs (e.g., "#/body/0") or page refs (e.g., "#/pages/0")
346+
"""
347+
if self.use_page_level:
348+
# Page-level mode: return page references using JSON pointer format
349+
refs: set[str] = set()
350+
num_pages = len(doc.pages) if hasattr(doc, "pages") and doc.pages else 0
351+
for i in range(num_pages):
352+
refs.add(f"#/pages/{i}") # Use JSON pointer format
353+
return refs
354+
else:
355+
# Section-level mode: return section header references
356+
refs = set()
357+
for item, _ in doc.iterate_items():
358+
if isinstance(item, TitleItem | SectionHeaderItem):
359+
refs.add(item.self_ref)
360+
return refs
361+
362+
def _extract_page_summaries(self, doc: DoclingDocument) -> dict[int, str]:
363+
"""Extract page-level summaries from enriched document.
364+
365+
For page-level enrichment, the summary is stored in the meta field of the
366+
first document item on each page.
367+
368+
Args:
369+
doc: The enriched DoclingDocument
370+
371+
Returns:
372+
Dictionary mapping page number (1-indexed) to summary text
373+
"""
374+
page_summaries: dict[int, str] = {}
375+
376+
try:
377+
for item, _ in doc.iterate_items():
378+
# Check if item has prov (provenance) with page number
379+
if hasattr(item, "prov") and item.prov:
380+
for prov in item.prov:
381+
if hasattr(prov, "page_no") and prov.page_no is not None:
382+
page_num = prov.page_no + 1 # Convert to 1-indexed
383+
384+
# Only process if we haven't seen this page yet
385+
if page_num not in page_summaries:
386+
# Check if item has meta with summary
387+
if hasattr(item, "meta") and item.meta:
388+
meta = item.meta
389+
# Handle both dict and Pydantic object
390+
if isinstance(meta, dict):
391+
summary_data = meta.get("summary", {})
392+
else:
393+
summary_data = getattr(meta, "summary", {})
394+
395+
if isinstance(summary_data, dict):
396+
summary_text = summary_data.get("text", "")
397+
elif hasattr(summary_data, "text"):
398+
summary_text = summary_data.text
399+
else:
400+
summary_text = ""
401+
402+
if summary_text:
403+
page_summaries[page_num] = summary_text
404+
break # Only need first prov entry
405+
except Exception as e:
406+
log_warning(f"Error extracting page summaries: {e}")
407+
408+
return page_summaries
276409

277410
# ------------------------------------------------------------------
278411
# Section selection
@@ -336,7 +469,11 @@ def _validate(content: str) -> bool:
336469
# ------------------------------------------------------------------
337470

338471
def _get_section_content(self, doc: DoclingDocument, section_ref: str) -> str:
339-
"""Return all text belonging to the given section node."""
472+
"""Return all text belonging to the given section node or page."""
473+
# Handle page-level mode
474+
if self.use_page_level and section_ref.startswith("#/pages/"):
475+
return self._get_page_content(doc, section_ref)
476+
340477
node = get_item_by_ref(doc, section_ref)
341478
if node is None:
342479
log_warning(f"Could not resolve section ref {section_ref!r}")
@@ -353,6 +490,16 @@ def _get_section_content(self, doc: DoclingDocument, section_ref: str) -> str:
353490

354491
return subtree
355492

493+
def _get_page_content(self, doc: DoclingDocument, page_ref: str) -> str:
494+
"""Extract all text content from a specific page."""
495+
page_node = get_item_by_ref(doc, page_ref)
496+
if page_node is None:
497+
log_warning(f"Could not resolve page ref {page_ref!r}")
498+
return ""
499+
500+
# Collect all text from the page using subtree traversal
501+
return collect_subtree_text(page_node, doc)
502+
356503
def _collect_flat_section_text(self, doc: DoclingDocument, section_ref: str) -> str:
357504
"""Scan iterate_items for the section and collect siblings until next section."""
358505
texts: list[str] = []
@@ -440,3 +587,110 @@ def _merge_answers(self, *, query: str, answers: list[str]) -> str:
440587
retry_budget=3,
441588
)
442589
return answer.strip()
590+
591+
# ------------------------------------------------------------------
592+
# Document selection (Phase 1: Multi-document support)
593+
# ------------------------------------------------------------------
594+
595+
def _extract_document_summary(self, doc: DoclingDocument) -> str:
596+
"""Extract document-level summary from enriched document.
597+
598+
The summary is stored in the meta field of the root body item.
599+
"""
600+
try:
601+
# Find the root body item (self_ref == "#/body")
602+
for item, _ in doc.iterate_items():
603+
if hasattr(item, "self_ref") and item.self_ref == "#/body":
604+
# Check if meta exists and has summary
605+
if hasattr(item, "meta") and item.meta:
606+
meta = item.meta
607+
# Handle both dict and Pydantic object
608+
if isinstance(meta, dict):
609+
summary_data = meta.get("summary", {})
610+
else:
611+
summary_data = getattr(meta, "summary", {})
612+
613+
if isinstance(summary_data, dict):
614+
return summary_data.get("text", "")
615+
elif hasattr(summary_data, "text"):
616+
return summary_data.text
617+
618+
log_warning(f"No document summary found for {doc.name}")
619+
return f"Document: {doc.name}"
620+
except Exception as e:
621+
log_warning(f"Error extracting document summary: {e}")
622+
return f"Document: {doc.name}"
623+
624+
def _select_relevant_documents(
625+
self,
626+
*,
627+
query: str,
628+
documents: dict[str, DoclingDocument],
629+
doc_summaries: dict[str, str],
630+
) -> list[str]:
631+
"""Select which documents are relevant for answering the query.
632+
633+
This removes evaluation bias by not assuming we know which document
634+
contains the answer. The model must decide based on document summaries.
635+
636+
Args:
637+
query: The query to answer
638+
documents: Dictionary mapping doc_id to DoclingDocument
639+
doc_summaries: Dictionary mapping doc_id to document summary
640+
641+
Returns:
642+
List of relevant doc_ids
643+
"""
644+
log_info(f"Selecting relevant documents from {len(documents)} candidates")
645+
646+
# Build prompt with all document summaries
647+
doc_list = "\n".join([f"Document '{doc_id}':\n{summary}" for doc_id, summary in doc_summaries.items()])
648+
649+
prompt = f"""You are analyzing a collection of documents to find which ones are relevant for answering a query.
650+
651+
QUERY:
652+
{query}
653+
654+
AVAILABLE DOCUMENTS:
655+
{doc_list}
656+
657+
TASK:
658+
Identify the MOST relevant document(s) for answering the query. Be selective and precise.
659+
660+
IMPORTANT GUIDELINES:
661+
- Prefer selecting 1-2 documents that are highly relevant
662+
- Only select additional documents if they provide essential complementary information
663+
- Do NOT select documents just because they might be tangentially related
664+
- Quality over quantity: it's better to select fewer, highly relevant documents than many loosely related ones
665+
- If the query is specific to one company/topic, typically only 1 document is needed
666+
- If the query compares multiple entities, select only the documents for those specific entities
667+
668+
Format your response as:
669+
Document 'doc_id': [reason]
670+
671+
Only include documents that are actually relevant to the query.
672+
"""
673+
674+
try:
675+
# Get reasoning model
676+
m = self._create_reasoning_session(system_prompt=self._RAG_SYSTEM_PROMPT)
677+
response = m.instruct(prompt, retry_budget=3)
678+
679+
# Parse response to extract doc_ids
680+
selected_docs = []
681+
for doc_id in documents.keys():
682+
# Look for the doc_id in the response
683+
if f"'{doc_id}'" in response or f'"{doc_id}"' in response or doc_id in response:
684+
selected_docs.append(doc_id)
685+
686+
if not selected_docs:
687+
log_warning("No documents selected by model, using all documents as fallback")
688+
selected_docs = list(documents.keys())
689+
690+
log_info(f"Selected {len(selected_docs)} relevant document(s): {selected_docs}")
691+
return selected_docs
692+
693+
except Exception as e:
694+
log_warning(f"Error selecting documents: {e}")
695+
# Fallback to all documents
696+
return list(documents.keys())

0 commit comments

Comments
 (0)