@@ -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