Enterprise document search is a solved problem — until lawyers ask "which page?"
Azure AI Search indexes document chunks, not pages. Out of the box, retrieved chunks carry no page provenance. Re-indexing with page metadata embedded would have taken 8–12 hours per corpus update and required changes to every upstream document pipeline.
The citation extraction approach
Instead of re-indexing, we attached page-number extraction to the retrieval step. Each chunk in the Azure AI Search index already carried the byte-offset range of its source document. We mapped those offsets back to the PDF page boundaries at query time — no index changes, no pipeline disruption.
from dataclasses import dataclass
from azure.search.documents.models import VectorizedQuery
@dataclass
class Citation:
document_id: str
title: str
page_number: int
excerpt: str
score: float
def extract_citations(
search_results: list[dict],
page_boundaries: dict[str, list[int]],
) -> list[Citation]:
"""Map Azure AI Search chunk results to page-number citations.
Args:
search_results: Raw results from the Azure AI Search client.
page_boundaries: Pre-loaded byte-offset boundaries per document ID.
Returns:
Citations with resolved page numbers, sorted by relevance score.
"""
citations = []
for result in search_results:
doc_id = result["document_id"]
byte_offset = result.get("chunk_offset", 0)
boundaries = page_boundaries.get(doc_id, [])
page_number = _offset_to_page(byte_offset, boundaries)
citations.append(
Citation(
document_id=doc_id,
title=result.get("title", ""),
page_number=page_number,
excerpt=result["content"][:300],
score=result["@search.score"],
)
)
return sorted(citations, key=lambda c: c.score, reverse=True)
def _offset_to_page(offset: int, boundaries: list[int]) -> int:
"""Binary search to find the 1-indexed page for a byte offset."""
lo, hi = 0, len(boundaries) - 1
while lo < hi:
mid = (lo + hi) // 2
if boundaries[mid] <= offset:
lo = mid + 1
else:
hi = mid
return lo + 1Page boundaries were computed once per document at ingest time and stored in a lightweight DynamoDB table keyed by document ID — a read that added under 5 ms to each query.
Accuracy at scale
We benchmarked against a manually audited set of 500 randomly sampled citations across 15 document types (contracts, regulatory filings, technical specs). The system reached 94% exact-page accuracy, with most misses concentrated on scanned PDFs where OCR introduced byte-offset drift.
For scanned documents, a GPT-4o fallback re-ranked candidate pages using visual layout cues from the chunk's surrounding context — recovering another 4% of cases at the cost of an additional 200–300 ms.
Lessons learned
Byte offsets are stable; chunk text is not. Storing byte ranges at ingest meant the citation layer was immune to prompt engineering changes or re-chunking experiments.
Measure against the actual user task. Initial accuracy metrics tracked semantic overlap, which looked great (~97%). Compliance reviewers cared about the page number only — that metric was 58% before the offset mapping was added.
Keep fast paths fast. The DynamoDB page-boundary lookup added 4 ms on average. The GPT-4o fallback added 250 ms but only fired for ~12% of queries. Tiered approaches keep the common case cheap.