Skip to content

Commit 5052ecb

Browse files
committed
pkg/parser: split oversized leaf sections so cover pages become retrievable
Filing cover pages (and any other long, mixed-topic leaf section) produce one 2-3k-char blob under a generic title like "3M COMPANY" — mixing registration tables, addresses, IRS IDs, contact info. A single summary can't cover all those topics, so retrieval picks unrelated "long-term debt" sections instead of the one that actually holds the answer. Add chunkOversizedLeaves: any LEAF section whose Content exceeds 2400 chars is replaced by a parent (title preserved) with smaller children at the next level. Children are sized around 900 chars and split at word boundaries. The chunk title prefers a natural colon-terminated header within the first 80 chars ("Securities registered pursuant to Section 12(b) of the Act:") when available — exactly the pattern in filings — otherwise the first ~60 chars trimmed at a word boundary, falling back to "<parent title> — part N". Internal nodes are recursed into but never split (they're already structured). Threshold deliberately high (2400) so most paper sub- sections aren't affected; combined with the retrieval-friendly summary prompt (previous commit), each chunk gets a topic-rich summary downstream so the retrieval LLM can match it to specific questions. Tests in chunk_test.go: oversized leaf gets split with the parent title preserved + children at level+1; first chunk takes the colon-header title; small sections are untouched; oversized leaves nested inside internal nodes are still split.
1 parent b6f8d4e commit 5052ecb

2 files changed

Lines changed: 201 additions & 1 deletion

File tree

pkg/parser/chunk_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package parser
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestChunkOversizedLeavesSplits(t *testing.T) {
9+
// 12 words per "sentence", 5 sentences ~ 60-65 words, ~360 chars; we want
10+
// >2400 chars so build it from a longer paragraph + a colon-terminated header.
11+
header := "Securities registered pursuant to Section 12(b) of the Act: "
12+
long := strings.Repeat("alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu ", 60)
13+
content := header + long
14+
if len(content) <= leafChunkThreshold {
15+
t.Fatalf("test setup: content must exceed threshold; got %d", len(content))
16+
}
17+
in := []Section{{Level: 1, Title: "3M COMPANY", Content: content}}
18+
19+
out := chunkOversizedLeaves(in)
20+
if len(out) != 1 {
21+
t.Fatalf("expected 1 top-level section, got %d", len(out))
22+
}
23+
parent := out[0]
24+
if parent.Title != "3M COMPANY" {
25+
t.Errorf("parent title should be preserved, got %q", parent.Title)
26+
}
27+
if parent.Content != "" {
28+
t.Errorf("parent content should be cleared after splitting, got %d chars", len(parent.Content))
29+
}
30+
if len(parent.Children) < 2 {
31+
t.Fatalf("expected multiple chunks, got %d", len(parent.Children))
32+
}
33+
// First chunk's title should use the colon-terminated header.
34+
if !strings.HasPrefix(parent.Children[0].Title, "Securities registered pursuant to Section 12(b)") {
35+
t.Errorf("first chunk title should come from the colon header, got %q", parent.Children[0].Title)
36+
}
37+
// Every chunk's content should be non-empty and well below the original.
38+
for i, c := range parent.Children {
39+
if c.Content == "" {
40+
t.Errorf("chunk %d has empty content", i)
41+
}
42+
if len(c.Content) > leafChunkTarget*2 {
43+
t.Errorf("chunk %d larger than expected: %d chars", i, len(c.Content))
44+
}
45+
}
46+
}
47+
48+
func TestChunkOversizedLeavesLeavesSmallSectionsAlone(t *testing.T) {
49+
in := []Section{
50+
{Level: 1, Title: "Intro", Content: strings.Repeat("a b c d e f ", 50)}, // ~600 chars
51+
{Level: 1, Title: "Methods", Content: strings.Repeat("x y z ", 200)}, // ~1200 chars
52+
}
53+
out := chunkOversizedLeaves(in)
54+
if len(out) != 2 {
55+
t.Fatalf("expected 2 sections preserved, got %d", len(out))
56+
}
57+
for i, s := range out {
58+
if len(s.Children) != 0 {
59+
t.Errorf("section %d was unexpectedly split into %d children", i, len(s.Children))
60+
}
61+
}
62+
}
63+
64+
func TestChunkOversizedLeavesRecursesIntoInternals(t *testing.T) {
65+
bigLeaf := Section{Level: 2, Title: "Detail", Content: strings.Repeat("the quick brown fox jumps over the lazy dog ", 100)}
66+
parent := Section{Level: 1, Title: "Parent", Children: []Section{bigLeaf}}
67+
out := chunkOversizedLeaves([]Section{parent})
68+
if len(out) != 1 || len(out[0].Children) == 0 {
69+
t.Fatalf("parent should be retained with chunked children, got %+v", out)
70+
}
71+
leaf := out[0].Children[0]
72+
if leaf.Title != "Detail" {
73+
t.Errorf("inner leaf title should be preserved, got %q", leaf.Title)
74+
}
75+
if len(leaf.Children) < 2 {
76+
t.Errorf("inner leaf should have been chunked, has %d children", len(leaf.Children))
77+
}
78+
}
79+
80+
func TestDeriveChunkTitleColonHeader(t *testing.T) {
81+
got := deriveChunkTitle("Securities registered pursuant to Section 12(b) of the Act: Title of each class ...", "fallback")
82+
want := "Securities registered pursuant to Section 12(b) of the Act"
83+
if got != want {
84+
t.Errorf("colon-header title: got %q want %q", got, want)
85+
}
86+
}
87+
88+
func TestDeriveChunkTitleFallback(t *testing.T) {
89+
if got := deriveChunkTitle("", "fb"); got != "fb" {
90+
t.Errorf("empty chunk should fall back, got %q", got)
91+
}
92+
}

pkg/parser/pdf.go

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,118 @@ func (*PDF) Parse(_ context.Context, r io.Reader) (*ParsedDoc, error) {
230230

231231
return &ParsedDoc{
232232
Title: title,
233-
Sections: rootSec.Children,
233+
Sections: chunkOversizedLeaves(rootSec.Children),
234234
}, nil
235235
}
236236

237+
// Filing cover pages (and any other long, mixed-topic leaf) often produce one
238+
// 2-3k-char section under a generic title like "3M COMPANY", which mixes
239+
// registration tables, addresses, IRS IDs and contact info. A single summary
240+
// can't cover all those topics, so retrieval misses. Split such leaves into
241+
// smaller sub-sections at word boundaries; each sub-section then gets its own
242+
// title (from a natural colon-terminated header, e.g. "Securities registered
243+
// pursuant to Section 12(b) of the Act", or the first few words) and its own
244+
// summary downstream.
245+
const (
246+
leafChunkThreshold = 2400 // chars; high enough to leave paper sub-sections alone
247+
leafChunkTarget = 900 // chars per chunk, give or take
248+
)
249+
250+
// chunkOversizedLeaves splits any LEAF section whose content exceeds
251+
// leafChunkThreshold into smaller sub-sections. Internal nodes (sections with
252+
// children) are recursed into but never split — they're already structured.
253+
func chunkOversizedLeaves(sections []Section) []Section {
254+
out := make([]Section, 0, len(sections))
255+
for _, s := range sections {
256+
if len(s.Children) > 0 {
257+
s.Children = chunkOversizedLeaves(s.Children)
258+
out = append(out, s)
259+
continue
260+
}
261+
if len(s.Content) <= leafChunkThreshold {
262+
out = append(out, s)
263+
continue
264+
}
265+
pieces := splitContentByWords(s.Content, leafChunkTarget)
266+
if len(pieces) <= 1 {
267+
out = append(out, s)
268+
continue
269+
}
270+
parent := Section{Level: s.Level, Title: s.Title}
271+
for i, piece := range pieces {
272+
fallback := fmt.Sprintf("%s — part %d", s.Title, i+1)
273+
parent.Children = append(parent.Children, Section{
274+
Level: s.Level + 1,
275+
Title: deriveChunkTitle(piece, fallback),
276+
Content: piece,
277+
})
278+
}
279+
out = append(out, parent)
280+
}
281+
return out
282+
}
283+
284+
// splitContentByWords breaks a long string into pieces near target size at
285+
// word boundaries. The last piece may be smaller; pieces are never midword.
286+
func splitContentByWords(s string, target int) []string {
287+
s = strings.TrimSpace(s)
288+
if target < 200 {
289+
target = 200
290+
}
291+
slack := target / 4
292+
if len(s) <= target+slack {
293+
return []string{s}
294+
}
295+
var chunks []string
296+
for len(s) > 0 {
297+
if len(s) <= target+slack {
298+
chunks = append(chunks, strings.TrimSpace(s))
299+
break
300+
}
301+
upper := target + slack
302+
if upper > len(s) {
303+
upper = len(s)
304+
}
305+
cut := strings.LastIndex(s[:upper], " ")
306+
if cut < target/2 {
307+
cut = upper // no good break: hard-cut at upper bound
308+
}
309+
chunks = append(chunks, strings.TrimSpace(s[:cut]))
310+
s = strings.TrimSpace(s[cut:])
311+
}
312+
return chunks
313+
}
314+
315+
// deriveChunkTitle picks a readable label for a content chunk. Prefers a
316+
// phrase ending in ":" within the first ~80 chars (filings use these as
317+
// natural sub-headers, e.g. "Securities registered pursuant to Section 12(b)
318+
// of the Act:"); otherwise takes the first ~60 chars trimmed at a word
319+
// boundary. Falls back to the supplied default when degenerate.
320+
func deriveChunkTitle(chunk, fallback string) string {
321+
s := strings.TrimSpace(chunk)
322+
if s == "" {
323+
return fallback
324+
}
325+
if i := strings.Index(s, ":"); i > 0 && i < 80 {
326+
candidate := strings.TrimSpace(s[:i])
327+
if len(strings.Fields(candidate)) >= 2 {
328+
return candidate
329+
}
330+
}
331+
if len(s) <= 60 {
332+
return strings.TrimRight(s, " ,;.:")
333+
}
334+
cut := strings.LastIndex(s[:60], " ")
335+
if cut < 30 {
336+
cut = 60
337+
}
338+
t := strings.TrimRight(strings.TrimSpace(s[:cut]), " ,;.:")
339+
if t == "" {
340+
return fallback
341+
}
342+
return t
343+
}
344+
237345
type pdfRow struct {
238346
page int
239347
fontSize float64

0 commit comments

Comments
 (0)