Searching documents¶
Once a PDF is distilled to a .dpdf, you search it without re-parsing
the source. There are two independent search paths over the same document: lexical
find (exact substring or regex, with honest coverage accounting) and semantic
search (cosine similarity over BAAI/bge-m3 chunk vectors). Both run against the
loaded document model and both emit ids that thread straight into a read.
import distillpdf
doc = distillpdf.load("case.dpdf")
Lexical search: Doc.find¶
Doc.find is the exact-phrase primitive. It is case-insensitive substring matching
by default, or a regex when regex=True. The match runs over each block's
searchable text — its prose, plus a table's flattened cell contents and a
figure/table caption — so a find reaches table data and captions, not only body
paragraphs.
result = doc.find("indemnification")
for hit in result.hits:
print(hit.block_id, hit.page, hit.block.get("section"))
Signature:
doc.find(
query,
*,
regex=False, # treat query as a regex (re.IGNORECASE)
section=None, # scope to a section id (includes its subtree)
kind=None, # scope to a block kind: "para", "table", "figure", …
pages=None, # scope to a list of physical page numbers
limit=None, # truncate the returned hits list (coverage counts stay full)
)
section, kind, and pages narrow the search scope. section takes a section id
(see Doc.toc) and includes that section's whole subtree. pages is a
list of physical page numbers — to turn a spec like "3-7" or a page label into
physical numbers first, use doc.resolve_pages("3-7").
# Regex, scoped to one section, capped at 5 hits.
result = doc.find(r"\$\d[\d,]*", regex=True, section="sec-methods", limit=5)
An invalid regex raises DpdfError; an unknown section id raises DpdfError
carrying the closest section ids it knows about.
The FindResult — coverage is never silent¶
find returns a FindResult, not a bare list. The contract is that "not found" is
always a measured statement: the result pairs the matches with how much was
searched, so an empty hits list is never an ambiguous silent miss.
| Attribute | Meaning |
|---|---|
hits |
list of Hit objects (see below), in document order |
searched_blocks |
number of blocks the query ran over (after scoping) |
searched_pages |
number of distinct pages those blocks spanned |
no_text_pages |
sorted page numbers that were in scope but carried no searchable text |
no_text_pages is the honesty signal: a page that is in scope but has no text layer
(for example a scanned image page that has not been OCR'd) shows up here rather than
just contributing zero hits. If you searched a document and got nothing, a non-empty
no_text_pages tells you the gap is missing text, not an absent phrase — the fix is
OCR, not a different query.
Each Hit carries:
| Attribute | Meaning |
|---|---|
block_id |
the id of the matched block (threads into doc.block(...) / read) |
block |
the full block dict (id, kind, text, page, section, …) |
page |
the physical page the block is on (or None) |
start / end |
the matched character span within the block's searchable text |
text |
the searchable text the match was found in |
result = doc.find("force majeure")
if not result.hits:
print(f"no match across {result.searched_blocks} blocks / "
f"{result.searched_pages} pages")
if result.no_text_pages:
print(f"pages with no text layer: {result.no_text_pages}")
else:
for h in result.hits:
print(f"{h.block_id} p{h.page} …{h.text[h.start:h.end]}…")
limit truncates only the returned hits list; the coverage counts
(searched_blocks, searched_pages, no_text_pages) always reflect the full
search. Pass limit=None (the default) for every hit.
Semantic search¶
Semantic search matches on meaning rather than exact characters. It is a two-step
flow: embed the document once (Doc.embed), then query it as often as you like
(Doc.search). Embeddings are stored inside the .dpdf itself, so a document only
needs embedding once.
Embeddings need an optional runtime
embed and search require the BAAI/bge-m3 model on ONNX Runtime, which is an
optional dependency. The exact install line is printed when it is missing — see
Dependencies below.
Chunks: derived, never duplicated¶
Embeddings are computed over chunks, not raw blocks. A chunk is a contiguous run of blocks within a single section, accumulated toward a target of roughly 400 tokens (measured with a cheap chars/4 proxy, not the real tokenizer). A section boundary always starts a new chunk, and a single block larger than the target becomes its own chunk — a block is never split. The intent is that a hit lands on a coherent passage, a paragraph or two of context, rather than a bare sentence.
Chunks are derived from blocks, not stored as text. A chunk record holds only
addresses and spans — id, block_ids, section, page_start, page_end — and its
text is recomposed from the referenced blocks at embed and search time. Because the
derivation is deterministic (same blocks produce the same chunks), a stored chunk set
can be diffed against a fresh derivation to detect drift, which is the staleness signal
described below.
Doc.embed¶
info = doc.embed("e1") # space_id defaults to "e1"
# {'space': 'e1', 'model': 'BAAI/bge-m3', 'chunks': 142,
# 'dimension': 1024, 'backend': 'vendored', 'dropped_stale': []}
embed derives the chunks, embeds each chunk's text with BAAI/bge-m3, writes the
1024-dimension vectors into the .dpdf as an embedding space, and saves the file.
Signature:
doc.embed(
space_id="e1", # the embedding space id to write
*,
cache_dir=None, # where the bge-m3 weights are cached
batch_size=32, # chunks per embedding batch
progress=True, # print per-batch progress to stderr when it is a tty
)
Behaviour worth knowing:
- It writes to disk. The document must have been loaded from a path
(
distillpdf.load("case.dpdf")); aDocbuilt in memory has nowhere to write and raisesDpdfError. - Atomic. Vectors are computed in full before anything is written, so a mid-batch failure leaves the file untouched — you get a complete space or no space, never a half-record.
- Re-embedding
space_idoverwrites it. Embedding the same id again replaces the prior space. - Stale spaces are dropped. Because
embedre-derives chunks, any other space whose storedchunk_idsno longer match the current chunking is dropped, and the dropped ids are listed in the return value'sdropped_stale(and printed to stderr).
Vectors are L2-normalized before they are stored (so cosine similarity is a plain dot
product), and the space metadata records normalized: true. The on-disk layout of the
space — the metadata record and the raw f32 vector member — is described in the
.dpdf format reference.
Doc.search¶
hits = doc.search("who is liable for delivery delays?", k=5)
for h in hits.hits:
print(h["chunk_id"], h["score"], h["section"], h["block_ids"])
search embeds the query with the same model, ranks the document's chunks by cosine
similarity, and returns the top k as a SearchResult. Signature:
doc.search(
query,
*,
k=8, # how many chunks to return
space=None, # embedding space id; default is the first space
)
If the document has no embedding space, search raises DpdfError pointing you at
embed (and, if the runtime is also missing, appending the install help). An unknown
space id raises DpdfError listing the available ids.
The SearchResult carries:
| Attribute | Meaning |
|---|---|
query |
the query string |
hits |
list of hit dicts (see below), highest score first |
space |
the embedding space id that was searched |
model |
the model the space was embedded with (BAAI/bge-m3) |
searched |
how many chunks were ranked |
stale |
True when the chunking has drifted from the current blocks |
Each hit is a dict:
| Key | Meaning |
|---|---|
chunk_id |
the chunk id |
score |
cosine similarity, rounded to 6 places |
section |
the chunk's section id |
page_start / page_end |
the chunk's page span |
page_label_start / page_label_end |
the /PageLabels labels for those pages, when present |
block_ids |
the ids of the blocks the chunk covers |
snippet |
the chunk's recomposed text |
The block_ids (and section) thread back into block reading: feed them to
doc.block(bid), doc.block_markdown(bid), or doc.section(sid) to pull the exact
source text behind a hit. See The .dpdf document model for those readers.
Staleness on re-distill¶
A .dpdf is a snapshot. If you re-distill the source PDF (for example after running
OCR), its blocks — and therefore its derived chunks — can change, while an embedding
space written against the old blocks stays put. Doc.search detects this: stale is
True when a fresh chunk derivation no longer matches the chunk ids the space recorded.
The scores then reflect an out-of-date chunking; refresh with doc.embed(...).
Doc.info() surfaces the same state without running a query. Its embeddings key
reports each space (id, model, dimension, chunk count) and a top-level stale
flag:
doc.info()["embeddings"]
# {'spaces': [{'id': 'e1', 'model': 'BAAI/bge-m3', 'dimension': 1024, 'chunks': 142}],
# 'chunks': 142, 'stale': False}
Dependencies¶
The embedding runtime is optional and imported lazily, so distillpdf is always
importable. The moment you embed or search without it installed, you get an
EmbedDependencyError (a subclass of ImportError) carrying the exact install line
rather than an opaque ModuleNotFoundError. The line it prints:
pip install onnxruntime tokenizers huggingface_hub
pip install numpy # optional but recommended (faster cosine)
The bge-m3 weights (~2.3 GB) download from HuggingFace on first use and cache under
~/.cache/fastembed. Override the cache with the FASTEMBED_CACHE_PATH environment
variable, or point HF_HOME (or cache_dir=) at an existing copy to run offline.
Shared vectors with kglite
The vectors are byte-identical to what kglite's
bge-m3 stack produces. If kglite is importable, distillpdf uses its embedder
directly; otherwise it uses a vendored twin that produces identical vectors. The
backend field in embed's return value ("kglite" or "vendored") tells you
which ran.
Command line equivalents¶
Every method here has a CLI verb that operates on the same model:
distillpdf case.dpdf find "force majeure" --section sec-terms
distillpdf case.dpdf embed
distillpdf case.dpdf search "who is liable for delivery delays?" --k 5
find --semantic is an alias for search. The verbs print a human form by default
and structured JSON with --json. See Command line for the full flag set.
See also¶
- The .dpdf document model — loading a document, sections, blocks, and the readers that hit ids thread into
- OCR for scanned PDFs — when
no_text_pagespoints at a missing text layer - Python API reference — full
Doc,FindResult, andSearchResultsurfaces - .dpdf format reference — how chunks and embedding spaces are stored