Does the document-structure layer actually retrieve better?
What this measures
litesearch.tree was added on the argument that chunks should know where they live. This notebook tests that argument against the version of litesearch that came before it, on 486 pages of EU legislation plus one paper, documents with real hierarchy (TITLE › CHAPTER › SECTION › Article) and the kind of corpus the layer was built for.
Four systems, one corpus, one encoder, one query set:
system
what it is
flat-page
litesearch before this work: chunk_markdown per page, db.search
flat-fine
the tree’s own chunk texts, in a plain store with no nodes and no headings
db.sections, the same retrieval rolled up to whole sections
flat-fine is the one that matters. Without it, the tree layer takes credit for chunking finer, which is a change any store could make.
Encoder caveat.huggingface.co is blocked by policy in the environment this was run in, so no ONNX or model2vec model could be downloaded. The dense leg is TF-IDF + truncated SVD (LSA) fitted on the corpus, a weak embedder, so absolute numbers understate every vector-dependent result. Every system shares it, so comparisons between them hold; anything that depends on semantic quality (heading context, most of all) is being judged in its worst case.
import glob, math, random, re, timefrom collections import Counterfrom pathlib import Pathimport numpy as npfrom litesearch import databasefrom litesearch.data import pdf_parse, chunk_markdownPDFS =sorted(glob.glob('../examples/pdfs/*.pdf')) + ['pdfs/attention_is_all_you_need.pdf']pages_by_doc = {p: list(enumerate(pdf_parse(p))) for p in PDFS}print(f'{len(pages_by_doc)} documents, {sum(len(v) for v in pages_by_doc.values())} pages')
Dictionary used where Stream expected, treating as empty stream
Dictionary used where Stream expected, treating as empty stream
Dictionary used where Stream expected, treating as empty stream
Dictionary used where Stream expected, treating as empty stream
Dictionary used where Stream expected, treating as empty stream
Dictionary used where Stream expected, treating as empty stream
Dictionary used where Stream expected, treating as empty stream
Dictionary used where Stream expected, treating as empty stream
11 documents, 524 pages
class LSA:"TF-IDF + truncated SVD, fitted on the corpus. Stands in for an ONNX model — see the caveat above."def__init__(self, dim=256):from sklearn.feature_extraction.text import TfidfVectorizerfrom sklearn.decomposition import TruncatedSVDfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import Normalizerself.p = make_pipeline(TfidfVectorizer(sublinear_tf=True, min_df=2, max_df=0.5, stop_words='english', ngram_range=(1,2)), TruncatedSVD(n_components=dim, random_state=0), Normalizer(copy=False))def fit(self, t): self.p.fit(list(t));returnselfdef__call__(self, t, **kw): returnself.p.transform(list(t)).astype(np.float16)all_text = [t for pg in pages_by_doc.values() for _, t in pg if t and t.strip()]enc = LSA(256).fit(all_text)E =lambda q: enc([q])[0].tobytes()
Build the four stores
# tree: one add_doc per documenttdb = database(); tdb.get_tree('store')for p, pages in pages_by_doc.items(): tdb.add_doc(pages, title=Path(p).stem, source=p, kind='pdf', emb_fn=enc)trows = tdb.t.store(select='rowid as rowid, content, node_id, page')nodes = {n['id']: n for n in tdb.t.nodes()}# tree without the heading path folded into the embeddingndb = database(); ndb.get_tree('store')for p, pages in pages_by_doc.items(): ndb.add_doc(pages, title=Path(p).stem, source=p, kind='pdf', emb_fn=enc, with_heading=False)# flat-page: litesearch before this workfdb = database(); fst = fdb.get_store('flat', hash=True, ann=True, doc=str, page=int)fst.sync([dict(content=c, doc=Path(p).name, page=pg)for p, pages in pages_by_doc.items() for pg, txt in pagesfor c in chunk_markdown(txt) iflen(c.strip()) >=40], emb_fn=enc)# flat-fine: the tree's chunks with every structural signal removedgdb = database(); gst = gdb.get_store('fine', hash=True, ann=True)gst.sync([dict(content=r['content']) for r in trows], emb_fn=enc)print(f'flat-page {len(fst()):>5} chunks')print(f'flat-fine {len(gst()):>5} chunks')print(f'tree {len(trows):>5} chunks, {len(nodes)} nodes')
The trees these PDFs get are worth a look on their own. pdf-oxide marks 16–31 lines per page as markdown headings, all at depth 1, which is not structure, detect_mode spots the density with no depth variation and switches to the words in the text instead, deriving the level ladder per document.
from litesearch.tree import detect_mode, struct_levels, build_treefor p inlist(pages_by_doc)[:5]: pages = pages_by_doc[p] t = build_tree(pages, title=Path(p).stem) lv = Counter(n.level for n in t)print(f'{Path(p).name[:31]:<31}{detect_mode(pages):8}{len(t):>5} nodes 'f'levels={dict(sorted(lv.items()))} ladder={struct_levels(pages)}')
One query per Article: a verbatim sentence lifted from it. Two variants, - verbatim, which FTS5 answers trivially and which therefore only checks for regressions; - degraded, with the rarest terms stripped out. Not a paraphrase, but it removes the exact-match shortcut and leaves roughly what someone types when they do not know the wording.
Ground truth is the sentence itself, so a hit counts when the text a system returns contains it, independent of how that system chunked anything.
_ART, _SENT = re.compile(r'^\s*#*\s*Article\s+\d+', re.I), re.compile(r'[^.!?\n]{60,240}[.!?]')arts = {i: n for i, n in nodes.items() if _ART.match(n['title'] or'')}def owner(nid): seen =set()while nid and nid notin seen: seen.add(nid)if nid in arts: return nid nid = (nodes.get(nid) or {}).get('parent_id')by_node = {}for r in trows: by_node.setdefault(r['node_id'], []).append(r['content'])rnd = random.Random(0); aids =list(arts); rnd.shuffle(aids)qs = []for a in aids:for m in _SENT.finditer(' '.join(by_node.get(a, []))): s =' '.join(m.group(0).split()).strip('. ')if9<=len(s.split()) <=26andnot s.startswith(('http','- [','|')): qs.append((s, a));breakiflen(qs) >=150: breakdf = Counter(w.lower().strip('.,;:()') for t in all_text for w in t.split())def degrade(q, keep=0.55): ws = q.split()iflen(ws) <6: return q rare =sorted(range(len(ws)), key=lambda i: df.get(ws[i].lower().strip('.,;:()'), 0)) drop =set(rare[:max(1, int(len(ws)*(1-keep)))])return' '.join(w for i, w inenumerate(ws) if i notin drop)dqs = [(degrade(q), a, q) for q, a in qs]print(f'{len(qs)} queries, {len(arts)} Article nodes indexed')print(f' verbatim : {qs[0][0][:86]}')print(f' degraded : {dqs[0][0][:86]}')
150 queries, 1903 Article nodes indexed
verbatim : ‘Supply of services’ shall mean any transaction which does not constitute a supply of
degraded : of shall any which not a supply of goods
def nrm(s): return' '.join((s or'').lower().split())def score_text(runs, k=10): r1=r5=rk=0; mrr=0.0for q, ts in runs: r =next((i for i,t inenumerate(ts[:k]) if nrm(q) in nrm(t)), None)if r isNone: continue rk +=1; mrr +=1/(r+1); r1 += r==0; r5 += r<5 n =max(1,len(runs));returndict(r1=r1/n, r5=r5/n, r10=rk/n, mrr=mrr/n)def score_art(runs, k=5): r1=r3=rk=0; mrr=0.0for gt, got in runs: r =next((i for i,a inenumerate(got[:k]) if a==gt), None)if r isNone: continue rk +=1; mrr +=1/(r+1); r1 += r==0; r3 += r<3 n =max(1,len(runs));returndict(r1=r1/n, r3=r3/n, r5=rk/n, mrr=mrr/n)def show(nm, s): print(f' {nm:<24} '+' '.join(f'{k}={v:.3f}'for k,v in s.items()))plain =lambda db, tbl, q, k=10: [h['content'] for h in (db.search(q, E(q), columns=['content'], limit=k, table_name=tbl, dtype=np.float16) or [])]tree_hits =lambda db, q, k=10, **kw: [h['content'] for h in db.doc_search(q, E(q), limit=k, dtype=np.float16, **kw)]SYS = {'flat-page (before)': lambda q: plain(fdb,'flat',q),'flat-fine (chunking)': lambda q: plain(gdb,'fine',q),'tree doc_search': lambda q: tree_hits(tdb,q),'tree -heading ctx': lambda q: tree_hits(ndb,q),'tree -spans': lambda q: tree_hits(tdb,q,spans=False)}
A. Verbatim queries, a regression check, nothing more
Everything scores near 1.0. An exact sentence is what FTS5 is for, so this task cannot separate retrieval strategies; it is here to show the tree layer costs nothing on the easy case.
for nm, fn in SYS.items(): show(nm, score_text([(q, fn(q)) for q,_ in qs]))
Read this carefully.flat-page → flat-fine is the whole story: MRR 0.330 → 0.522, a 58% relative gain, from nothing but smaller chunks. flat-fine → tree is 0.522 → 0.503, the tree layer is fractionally behind once granularity is controlled, and removing heading context changes nothing (0.509).
So on this corpus the retrieval win credited to “document structure” is a chunking win. The tree is what produces the better chunks, node segments are narrower than pages, but heading context and adaptive fusion contribute nothing measurable, and should not be sold as if they did.
C. “Which Article answers this?”
The question a reader actually asks of a legal corpus. Every system’s output is mapped to Articles through the same index, so the mapping is not a variable: an exact chunk match first, then any Article whose text the returned passage overlaps.
c2a, a2text = {}, {}for r in trows:if (a := owner(r['node_id'])): c2a[nrm(r['content'])] = a a2text.setdefault(a, []).append(r['content'])a2text = {a: nrm(' '.join(v)) for a, v in a2text.items()}a_keys = [(a, t[:200]) for a, t in a2text.items() iflen(t) >60]def to_arts(texts, k=5): out = []for t in texts: n = nrm(t) got = [c2a[n]] if n in c2a else [a for a, key in a_keys if key in n or n[:200] in a2text[a]]for a in got:if a notin out: out.append(a)iflen(out) >= k: breakreturn out[:k]def sec_arts(q, k=5, **kw): out = []for s in tdb.sections(q, E(q), limit=k*2, per=2, dtype=np.float16, **kw): a = s['node_id'] if s['node_id'] in arts else (c2a.get(nrm(s['snippets'][0])) if s['snippets'] elseNone)if a and a notin out: out.append(a)iflen(out) >= k: breakreturn outfor label, qset in (('verbatim', [(q,a) for q,a in qs]), ('degraded', [(d,a) for d,a,_ in dqs])):print(f'[{label}]')for nm, fn in (('flat-page (before)', lambda q: to_arts(plain(fdb,'flat',q,10))), ('flat-fine (chunking)', lambda q: to_arts(plain(gdb,'fine',q,10))), ('tree doc_search', lambda q: to_arts(tree_hits(tdb,q,10))), ('sections score=max', lambda q: sec_arts(q)), ('sections score=sum', lambda q: sec_arts(q, score='sum'))): show(nm, score_art([(a, fn(q)) for q, a in qset]))
score='sum' is why sections() now defaults to max. Summing every hit’s RRF mass inside a node is a length prior wearing a plausible story (“five weak hits in a chapter beat one strong hit in an appendix”): long chapters outrank the precise article that actually answers the question. Switching to “a section is as relevant as its best evidence” moves section-level retrieval from clearly worse than chunk search to level with it, while returning a section, which is the more useful unit.
Note what flat-page cannot do here at all. It finds the right text 94% of the time on verbatim queries and still identifies the right Article only 27% of the time at rank 1, because a page-sized chunk spans several articles and cannot say which one. That is the structural argument for the tree layer, and it is the one the numbers actually support.
What this changed
finding
action taken
pdf-oxide marks every bold line as an h1; the VAT directive built 5,242 one-line nodes
detect_mode now rejects high-density/no-depth markdown and derives a level ladder from CHAPTER/Article/TITLE words, 1,983 nodes over 4 real levels
sections() summing RRF mass is a length prior
default changed to score='max'; +0.11 MRR verbatim, +0.16 degraded
adaptive_weights never fires on prose; an FTS-coverage replacement was worse
doc_search(adaptive=...) defaults off, documented rather than removed
chunk granularity dominates every structural feature
the cost-model splitter moves to the top of the roadmap
heading context measures as noise under a weak encoder
kept, cheap, and documented as unproven: it needs a real embedder to be judged