Source code for fastrag.retrieval.sections
"""Database-independent exact source context for structural retrieval hits."""
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Literal
from fastrag.models import Document
from fastrag.normalization import NormalizedDocument, SourceSpan
[docs]
@dataclass(frozen=True, slots=True)
class SectionContext:
"""Original retained section syntax, with document-scoped provenance."""
document_id: str
section_id: str
content: str
source_spans: tuple[SourceSpan, ...]
heading_path: tuple[str, ...]
[docs]
class SectionIndex:
"""Keep source parents outside vector chunks and resolve merged sections."""
def __init__(self, documents: Iterable[NormalizedDocument] = ()) -> None:
"""Build an index of validated, document-scoped parents."""
self._documents: dict[str, NormalizedDocument] = {}
for document in documents:
self.add(document)
[docs]
def add(self, document: NormalizedDocument) -> None:
"""Register a validated parent; conflicting document IDs are rejected."""
document.validate()
old = self._documents.get(document.document.id)
if old is not None and old != document:
raise ValueError("Conflicting normalized document ID")
self._documents[document.document.id] = document
[docs]
def resolve(
self,
chunk: Document,
*,
ancestors: bool = False,
missing: Literal["raise", "skip"] = "raise",
) -> tuple[SectionContext, ...]:
"""Return all source sections in source order, optionally with ancestors."""
if missing not in {"raise", "skip"}:
raise ValueError("missing must be raise or skip")
document_id = chunk.metadata.get("document_id", chunk.metadata.get("parent_id"))
document = self._documents.get(document_id)
if document is None:
if missing == "skip":
return ()
raise KeyError(f"Missing normalized document {document_id}")
nodes = {n.id: n for n in document.walk()}
selected = set()
for ident in chunk.metadata.get(
"source_section_ids", [chunk.metadata.get("parent_section_id")]
):
node = nodes.get(ident)
if node is None or node.kind not in {"document", "section"}:
if missing == "skip":
continue
raise KeyError(f"Missing section {ident} in {document_id}")
selected.add(node.id)
while ancestors and node.parent_id:
node = nodes[node.parent_id]
if node.kind == "section":
selected.add(node.id)
result = []
for node in document.walk():
if node.id not in selected:
continue
path = []
current = node
while current:
if current.kind == "section":
path.append(current.title or "")
current = nodes.get(current.parent_id)
result.append(
SectionContext(
document.document.id,
node.id,
node.content,
node.spans,
tuple(reversed(path)),
)
)
return tuple(result)