Skip to content

Commit d9a954b

Browse files
fix: preserve summaries across incremental update()
update() computed summaries and then discarded them, so every tree it wrote back had no summaries at all. Two independent causes: 1. build_tree_from_nodes() rebuilt each node dict from scratch and never copied `summary`. md_to_tree() was unaffected because it summarizes after building the tree; update() summarizes before, so its results were dropped. Fixed at the shared function, which both paths use. 2. The cached-summary lookup keyed the old tree by bare `title` but read it by full `title_path`. title_path exists only on the flat node list, never on the persisted tree, so every clean section missed the cache and fell through to ''. Rebuilt the map by walking the tree with the same ' > ' join. Also adds split_summary_fields() so update() matches index()'s convention (parents -> prefix_summary, leaves -> summary), and drops the now-unused structure_to_list import. This mattered because get_document_structure() strips `text` and hands the model titles + summaries only. After an update the payload was bare titles, so retrieval had nothing to reason over -- it would misroute silently rather than error. sample.md is rewritten with longer sections so the demo actually crosses the 200-token summarization threshold; the previous version was short enough that every node returned raw text and no summary was generated. Tests: 2 new deterministic cases (no API key needed) covering summary survival through build_tree_from_nodes and the tree-walk/title_path key agreement. 7 passing.
1 parent 02106c2 commit d9a954b

4 files changed

Lines changed: 153 additions & 25 deletions

File tree

examples/documents/sample.md

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,101 @@
22

33
PageIndex turns long documents into a navigable tree of sections, each with a
44
summary, so agents can reason over structure instead of flat chunks. This
5-
sample doc is used by the incremental update demo.
5+
sample document is used by the incremental update demo, and its sections are
6+
deliberately long enough that section summaries are generated by a model rather
7+
than passed through as raw text, which is what makes the incremental behaviour
8+
observable when only part of the document is edited.
69

710
## 1. What PageIndex Does
811

912
PageIndex parses a PDF or Markdown file into a hierarchical structure of nodes.
10-
Each node holds a title, its text, and a generated summary. The tree lets a
11-
retrieval agent walk from the document root down to the exact section that
12-
answers a question, without embedding every chunk into a vector store.
13+
Each node holds a title, its own text span, and a generated summary. The tree
14+
lets a retrieval agent walk from the document root down to the exact section
15+
that answers a question, without embedding every chunk into a vector store and
16+
without relying on nearest-neighbour similarity to decide relevance.
17+
18+
The practical consequence is that retrieval becomes an act of navigation rather
19+
than an act of matching. An agent reads the root description, decides which
20+
branch is plausible, reads that branch's summary, and descends. At every step
21+
the decision is legible: there is a title and a summary that explain why the
22+
branch was taken. When the agent lands on a leaf it has the full text of that
23+
section, not a windowed fragment that may have been cut mid-argument.
24+
25+
This matters most for documents where meaning depends on position. A clause in
26+
a contract, a subsection of a policy manual, or a numbered requirement in a
27+
regulatory filing all derive part of their meaning from where they sit in the
28+
document. Flat chunking discards that placement. A tree preserves it, and the
29+
path from root to leaf is itself a piece of evidence the agent can cite.
1330

1431
## 2. Indexing
1532

16-
Indexing builds the tree once. For Markdown, headings define the hierarchy; for
17-
PDFs, the table of contents and page layout are used. Every section is
18-
summarized, and the whole document gets a short description. The result is
19-
persisted in a workspace as JSON keyed by a document id.
33+
Indexing builds the tree once. For Markdown, headings define the hierarchy
34+
directly: each heading opens a node, and the heading level determines where
35+
that node attaches to its parent. For PDFs, the table of contents and the page
36+
layout are used instead, with a series of checks that verify the extracted
37+
table of contents actually corresponds to the physical pages of the document.
38+
39+
Every section is then summarized, and the whole document is given a short
40+
description derived from the structure. Summarization is conditional: a section
41+
whose text falls below a token threshold is stored verbatim, on the grounds
42+
that a summary of a short passage costs a model call and returns something no
43+
more useful than the passage itself. Longer sections are sent to the model.
44+
45+
The result is persisted in a workspace directory as JSON, keyed by a document
46+
identifier. Alongside the tree, the record stores a hash of the whole file and
47+
a map of per-section hashes. Those hashes are what make the next run cheap:
48+
they are the record of what the tree was built from, so a later run can compare
49+
against them instead of re-deriving the tree from scratch to find out whether
50+
anything moved.
2051

2152
## 3. Incremental Update
2253

23-
When a document changes, PageIndex avoids rebuilding everything. It hashes the
24-
file and each section: if the file hash is unchanged the update is skipped
25-
entirely, and if only some sections changed, only those (plus their ancestors)
26-
are re-summarized. Unchanged sections reuse their cached summary.
54+
When a document changes, PageIndex avoids rebuilding everything. The update
55+
path applies two gates in sequence, and each gate that passes eliminates a
56+
larger amount of work than the one before it.
57+
58+
The first gate is the file hash. If the hash of the file's current contents
59+
matches the hash recorded at index time, nothing in the document has changed,
60+
the update returns immediately with a status of unchanged, and no model call is
61+
made at all. This is the common case for a scheduled re-ingest over a corpus
62+
where most documents are static between runs.
63+
64+
The second gate is the section diff. The file is re-parsed into sections, each
65+
section is hashed, and the new hash map is compared against the stored one.
66+
That comparison yields three sets: sections that are new, sections that were
67+
removed, and sections whose text changed in place. The changed and added
68+
sections are marked dirty, and the ancestors of each dirty section are added to
69+
the set as well, on the assumption that a parent's roll-up may be affected by
70+
what happened underneath it.
71+
72+
Everything in that set is re-summarized. Everything outside it reuses the
73+
summary already stored on the previous tree. A two-page revision to a five
74+
hundred page manual therefore costs a handful of model calls rather than a
75+
full rebuild, and the cost scales with the size of the edit rather than with
76+
the size of the document.
2777

2878
## 4. Vectorless Retrieval
2979

3080
Because the tree carries summaries at every level, an agent can retrieve by
31-
traversing the structure instead of doing nearest-neighbor search over
32-
embeddings. This keeps retrieval explainable and cheap to maintain.
81+
traversing the structure instead of doing nearest-neighbour search over
82+
embeddings. There is no index to build beyond the tree itself, no embedding
83+
model to keep consistent between ingest and query time, and no drift when the
84+
embedding model is upgraded underneath a corpus that was embedded with an
85+
older version.
86+
87+
It also keeps retrieval explainable. A vector search returns a ranked list with
88+
a similarity score, and the score is not an explanation: it does not say why
89+
one passage outranked another, and it cannot be audited after the fact. A
90+
traversal returns a path, and the path is an explanation: this section, inside
91+
this chapter, inside this document, chosen because its summary matched what was
92+
asked. For compliance and audit workflows that difference is the point.
3393

3494
## Appendix: Key Methods
3595

36-
`client.index(path)` builds the tree. `client.update(doc_id)` refreshes it
37-
incrementally. `client.get_doc_id_by_path(path)` resolves an existing document
38-
so the same file is never indexed twice.
96+
`client.index(path)` builds the tree for a document that has not been seen
97+
before and returns its document identifier. `client.update(doc_id)` refreshes
98+
an existing tree incrementally, applying the two gates described above and
99+
returning a dictionary describing which sections were updated, added, or
100+
deleted. `client.get_doc_id_by_path(path)` resolves an existing document by its
101+
source path, so that re-ingesting the same file finds the tree that already
102+
exists instead of minting a second identifier and orphaning the first.

pageindex/client.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
extract_node_text_content,
1515
get_node_summary,
1616
build_tree_from_nodes,
17+
split_summary_fields,
1718
)
1819
from .retrieve import get_document, get_document_structure, get_page_content
1920
from .utils import (
@@ -22,7 +23,6 @@
2223
hash_text,
2324
compute_section_hashes,
2425
find_ancestors,
25-
structure_to_list,
2626
write_node_id,
2727
format_structure,
2828
)
@@ -292,12 +292,17 @@ def update(self, doc_id: str) -> dict:
292292
for path in dirty:
293293
to_summarize.update(find_ancestors(path))
294294

295-
# Reuse cached summaries for clean sections.
296-
old_structure_flat = structure_to_list(doc.get('structure', []))
297-
old_summary_map = {
298-
n.get('title_path', n.get('title')): n.get('summary') or n.get('prefix_summary', '')
299-
for n in old_structure_flat
300-
}
295+
# Reuse cached summaries for clean sections. The persisted tree has no
296+
# title_path, so rebuild it to match the keys used by new_nodes.
297+
old_summary_map = {}
298+
299+
def _collect_summaries(nodes, prefix=''):
300+
for n in nodes:
301+
path = f"{prefix} > {n['title']}" if prefix else n['title']
302+
old_summary_map[path] = n.get('summary') or n.get('prefix_summary', '')
303+
_collect_summaries(n.get('nodes', []), path)
304+
305+
_collect_summaries(doc.get('structure', []))
301306

302307
async def _identity(val):
303308
return val
@@ -323,6 +328,7 @@ async def _regenerate():
323328

324329
# Rebuild the tree with fresh node ids.
325330
new_structure = build_tree_from_nodes(new_nodes)
331+
split_summary_fields(new_structure)
326332
write_node_id(new_structure)
327333
new_structure = format_structure(
328334
new_structure,

pageindex/page_index_md.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ async def generate_summaries_for_structure_md(structure, summary_token_threshold
2929
return structure
3030

3131

32+
def split_summary_fields(tree_nodes):
33+
"""Apply the same leaf/parent convention as generate_summaries_for_structure_md.
34+
35+
For callers that attach `summary` to a flat node list before the tree is
36+
built, so parent nodes end up with `prefix_summary` as index() produces.
37+
"""
38+
for node in tree_nodes:
39+
if node.get('nodes'):
40+
node['prefix_summary'] = node.pop('summary', '')
41+
split_summary_fields(node['nodes'])
42+
return tree_nodes
43+
44+
3245
def extract_nodes_from_markdown(markdown_content):
3346
header_pattern = r'^(#{1,6})\s+(.+)$'
3447
code_block_pattern = r'^```'
@@ -217,6 +230,10 @@ def build_tree_from_nodes(node_list):
217230
'line_num': node['line_num'],
218231
'nodes': []
219232
}
233+
# Callers that summarize before building the tree (e.g. incremental
234+
# update) would otherwise have their summaries dropped here.
235+
if 'summary' in node:
236+
tree_node['summary'] = node['summary']
220237
node_counter += 1
221238

222239
while stack and stack[-1][1] >= current_level:

tests/test_incremental_update.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010

1111
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
1212

13-
from pageindex.page_index_md import extract_nodes_from_markdown, extract_node_text_content
13+
from pageindex.page_index_md import (
14+
extract_nodes_from_markdown,
15+
extract_node_text_content,
16+
build_tree_from_nodes,
17+
split_summary_fields,
18+
)
1419
from pageindex.utils import compute_section_hashes, find_ancestors
1520

1621

@@ -65,6 +70,42 @@ def test_dirty_set_includes_ancestors():
6570
assert to_summarize == {"Root", "Root > A", "Root > A > A1"}, to_summarize
6671

6772

73+
def test_build_tree_preserves_summaries():
74+
"""update() attaches summaries before building the tree; they must survive."""
75+
md = "# Root\nintro\n## A\nalpha\n## B\nbeta\n"
76+
node_list, lines = extract_nodes_from_markdown(md)
77+
nodes = extract_node_text_content(node_list, lines)
78+
for n in nodes:
79+
n["summary"] = f"S:{n['title_path']}"
80+
81+
tree = split_summary_fields(build_tree_from_nodes(nodes))
82+
root = tree[0]
83+
# Parents carry prefix_summary, leaves carry summary — as index() produces.
84+
assert root["prefix_summary"] == "S:Root", root
85+
assert "summary" not in root, root
86+
assert [c["summary"] for c in root["nodes"]] == ["S:Root > A", "S:Root > B"]
87+
88+
89+
def test_tree_walk_paths_match_flat_title_paths():
90+
"""The cached-summary lookup keys the old tree by walking it; those paths
91+
must equal the title_paths the new flat node list is keyed by."""
92+
md = "# Root\nintro\n## A\nalpha\n### A1\nsub\n## B\nbeta\n"
93+
node_list, lines = extract_nodes_from_markdown(md)
94+
nodes = extract_node_text_content(node_list, lines)
95+
tree = build_tree_from_nodes(nodes)
96+
97+
walked = []
98+
99+
def collect(ns, prefix=""):
100+
for n in ns:
101+
path = f"{prefix} > {n['title']}" if prefix else n["title"]
102+
walked.append(path)
103+
collect(n.get("nodes", []), path)
104+
105+
collect(tree)
106+
assert walked == [n["title_path"] for n in nodes], walked
107+
108+
68109
if __name__ == "__main__":
69110
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
70111
for fn in fns:

0 commit comments

Comments
 (0)