Skip to content

Commit 28ffc33

Browse files
authored
Merge pull request #24 from hallelx2/feat/toc-tree-builder
feat(ingest): LLM-built TOC tree (PageIndex-style, PR-A)
2 parents 52b7381 + 4f1f49d commit 28ffc33

12 files changed

Lines changed: 1821 additions & 9 deletions

cmd/server/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ func run() error {
169169
SummaryAxesMaxTopics: cfg.Engine.Ingest.SummaryAxes.MaxTopics,
170170
SummaryAxesMaxEntities: cfg.Engine.Ingest.SummaryAxes.MaxEntities,
171171
SummaryAxesMaxNumbers: cfg.Engine.Ingest.SummaryAxes.MaxNumbers,
172+
TOCEnabled: cfg.Engine.Ingest.TOC.Enabled,
173+
TOCModel: cfg.Engine.Ingest.TOC.Model,
174+
TOCConcurrency: cfg.Engine.Ingest.TOC.Concurrency,
175+
TOCCheckPages: cfg.Engine.Ingest.TOC.TOCCheckPages,
172176
GlobalLLMConcurrency: cfg.Engine.Ingest.GlobalLLMConcurrency,
173177
})
174178
if cfg.Engine.Ingest.Tables.Enabled {

config.example.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,32 @@ ingest:
297297
max_entities: 8
298298
max_numbers: 6
299299

300+
# LLM-built table-of-contents tree (PageIndex-style). Runs after
301+
# summarize+HyDE on PDF inputs and persists a hierarchical TOC on
302+
# documents.toc_tree (JSONB). The tree is small (a few KB even
303+
# for 300-page filings) and is intended as a higher-level map
304+
# retrieval strategies can reason over before drilling into the
305+
# parser-derived sections tree.
306+
#
307+
# ENABLED BY DEFAULT for PDFs. Non-PDF documents skip the stage
308+
# unconditionally. Builder failures are non-fatal — the document
309+
# remains fully retrievable via the existing sections tree.
310+
toc:
311+
enabled: true
312+
# Override the LLM model used by the builder; empty inherits
313+
# the summary model. Point this at a reasoning-capable model —
314+
# the no-TOC generator has to find hierarchy in raw body text,
315+
# which a small/fast model often botches.
316+
model: ""
317+
# Cap on parallel LLM calls during the verification phase
318+
# (one call per leaf node).
319+
concurrency: 4
320+
# The detector scans the first N pages for a table of
321+
# contents. PageIndex defaults this to 20 — financial filings
322+
# put their TOC inside the first dozen pages and a document
323+
# without one by page 20 almost never has one further in.
324+
toc_check_pages: 20
325+
300326
log:
301327
level: "info" # debug | info | warn | error
302328
format: "json" # json | console

pkg/config/config.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ type IngestConfig struct {
4848
// populate it).
4949
SummaryAxes SummaryAxesBlock `yaml:"summary_axes"`
5050

51+
// TOC configures the PageIndex-style LLM-built table-of-contents
52+
// tree stage. Enabled by default for PDF inputs; the resulting
53+
// tree is persisted on documents.toc_tree (JSONB). Failures are
54+
// non-fatal — they leave the column NULL and the document fully
55+
// retrievable via the existing sections tree.
56+
TOC TOCBlock `yaml:"toc"`
57+
5158
// GlobalLLMConcurrency caps the total number of LLM calls in flight
5259
// across the summarize and HyDE stages combined, which now run
5360
// concurrently. Each stage still respects its own per-stage cap
@@ -60,6 +67,39 @@ type IngestConfig struct {
6067
GlobalLLMConcurrency int `yaml:"global_llm_concurrency"`
6168
}
6269

70+
// TOCBlock configures the LLM-driven table-of-contents tree
71+
// builder. The builder reads page-by-page text from a freshly-
72+
// ingested PDF and emits a hierarchical TOC (PageIndex-style),
73+
// persisted on documents.toc_tree (JSONB).
74+
//
75+
// Enabled by default for PDF inputs; non-PDF documents skip the
76+
// stage unconditionally. Builder failures never break ingest —
77+
// the document remains fully retrievable via the existing
78+
// sections tree.
79+
type TOCBlock struct {
80+
// Enabled toggles the stage. Default: true. Flip to false to
81+
// skip the extra LLM round-trip when ingest budget matters
82+
// more than having a TOC tree for retrieval to reason over.
83+
Enabled bool `yaml:"enabled"`
84+
85+
// Model overrides the LLM model used by the builder. Empty
86+
// inherits the engine's configured default. Point this at a
87+
// reasoning-capable model — the no-TOC generator has to find
88+
// hierarchy in raw body text, which a small/fast model often
89+
// botches.
90+
Model string `yaml:"model"`
91+
92+
// Concurrency caps parallel LLM calls during the verification
93+
// phase (one call per leaf node). Default: 4.
94+
Concurrency int `yaml:"concurrency"`
95+
96+
// TOCCheckPages bounds the leading prefix the detector scans
97+
// for a table of contents. Default: 20 — financial filings
98+
// put their TOC inside the first dozen pages and a document
99+
// without one by page 20 almost never has one further in.
100+
TOCCheckPages int `yaml:"toc_check_pages"`
101+
}
102+
63103
// SummaryAxesBlock configures the Phase 2.5 structured summarizer.
64104
//
65105
// When enabled, the summarize stage runs in JSON mode and produces
@@ -584,6 +624,11 @@ func Default() Config {
584624
MaxEntities: 8,
585625
MaxNumbers: 6,
586626
},
627+
TOC: TOCBlock{
628+
Enabled: true,
629+
Concurrency: 4,
630+
TOCCheckPages: 20,
631+
},
587632
},
588633
Log: LogConfig{Level: "info", Format: "json"},
589634
}
@@ -767,6 +812,30 @@ func applyEnvOverrides(c *Config) {
767812
c.Ingest.SummaryAxes.MaxNumbers = n
768813
}
769814
}
815+
// LLM-built TOC tree (PageIndex-style). Same truthy-string set
816+
// as the other ingest toggles; numeric overrides require a
817+
// positive int so a typo doesn't silently flip the default.
818+
if v := os.Getenv("VLE_INGEST_TOC_ENABLED"); v != "" {
819+
switch strings.ToLower(strings.TrimSpace(v)) {
820+
case "1", "true", "yes", "on":
821+
c.Ingest.TOC.Enabled = true
822+
case "0", "false", "no", "off":
823+
c.Ingest.TOC.Enabled = false
824+
}
825+
}
826+
if v := os.Getenv("VLE_INGEST_TOC_MODEL"); v != "" {
827+
c.Ingest.TOC.Model = v
828+
}
829+
if v := os.Getenv("VLE_INGEST_TOC_CONCURRENCY"); v != "" {
830+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
831+
c.Ingest.TOC.Concurrency = n
832+
}
833+
}
834+
if v := os.Getenv("VLE_INGEST_TOC_TOC_CHECK_PAGES"); v != "" {
835+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
836+
c.Ingest.TOC.TOCCheckPages = n
837+
}
838+
}
770839
if v := os.Getenv("VLE_RETRIEVAL_ANSWER_SPAN_ENABLED"); v != "" {
771840
switch strings.ToLower(strings.TrimSpace(v)) {
772841
case "1", "true", "yes", "on":
@@ -978,6 +1047,13 @@ func (c Config) Validate() error {
9781047
return fmt.Errorf("ingest.summary_axes.max_numbers must be >= 0, got %d", c.Ingest.SummaryAxes.MaxNumbers)
9791048
}
9801049

1050+
if c.Ingest.TOC.Concurrency < 0 {
1051+
return fmt.Errorf("ingest.toc.concurrency must be >= 0, got %d", c.Ingest.TOC.Concurrency)
1052+
}
1053+
if c.Ingest.TOC.TOCCheckPages < 0 {
1054+
return fmt.Errorf("ingest.toc.toc_check_pages must be >= 0, got %d", c.Ingest.TOC.TOCCheckPages)
1055+
}
1056+
9811057
if c.Retrieval.Planning.CacheSize < 0 {
9821058
return fmt.Errorf("retrieval.planning.cache_size must be >= 0, got %d", c.Retrieval.Planning.CacheSize)
9831059
}

pkg/config/config_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,55 @@ func TestDefaultValues(t *testing.T) {
7373
if cfg.Log.Level != "info" {
7474
t.Errorf("log.level = %q, want info", cfg.Log.Level)
7575
}
76+
if !cfg.Ingest.TOC.Enabled {
77+
t.Error("ingest.toc.enabled should default to true (opt-out)")
78+
}
79+
if cfg.Ingest.TOC.Concurrency != 4 {
80+
t.Errorf("ingest.toc.concurrency = %d, want 4", cfg.Ingest.TOC.Concurrency)
81+
}
82+
if cfg.Ingest.TOC.TOCCheckPages != 20 {
83+
t.Errorf("ingest.toc.toc_check_pages = %d, want 20", cfg.Ingest.TOC.TOCCheckPages)
84+
}
85+
}
86+
87+
func TestTOCEnvOverride(t *testing.T) {
88+
// Mutates env — restore on exit. Not parallel.
89+
keys := []string{
90+
"VLE_INGEST_TOC_ENABLED",
91+
"VLE_INGEST_TOC_MODEL",
92+
"VLE_INGEST_TOC_CONCURRENCY",
93+
"VLE_INGEST_TOC_TOC_CHECK_PAGES",
94+
}
95+
prev := make(map[string]string, len(keys))
96+
for _, k := range keys {
97+
prev[k] = os.Getenv(k)
98+
}
99+
defer func() {
100+
for k, v := range prev {
101+
os.Setenv(k, v)
102+
}
103+
}()
104+
105+
os.Setenv("VLE_INGEST_TOC_ENABLED", "false")
106+
os.Setenv("VLE_INGEST_TOC_MODEL", "gemini-2.5-pro")
107+
os.Setenv("VLE_INGEST_TOC_CONCURRENCY", "12")
108+
os.Setenv("VLE_INGEST_TOC_TOC_CHECK_PAGES", "30")
109+
110+
cfg := Default()
111+
applyEnvOverrides(&cfg)
112+
113+
if cfg.Ingest.TOC.Enabled {
114+
t.Error("VLE_INGEST_TOC_ENABLED=false should disable the stage")
115+
}
116+
if cfg.Ingest.TOC.Model != "gemini-2.5-pro" {
117+
t.Errorf("VLE_INGEST_TOC_MODEL not applied, got %q", cfg.Ingest.TOC.Model)
118+
}
119+
if cfg.Ingest.TOC.Concurrency != 12 {
120+
t.Errorf("VLE_INGEST_TOC_CONCURRENCY=12 not applied, got %d", cfg.Ingest.TOC.Concurrency)
121+
}
122+
if cfg.Ingest.TOC.TOCCheckPages != 30 {
123+
t.Errorf("VLE_INGEST_TOC_TOC_CHECK_PAGES=30 not applied, got %d", cfg.Ingest.TOC.TOCCheckPages)
124+
}
76125
}
77126

78127
func TestAbstainEnvOverride(t *testing.T) {

pkg/db/documents.go

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ type Document struct {
4343
Metadata map[string]string
4444
CreatedAt time.Time
4545
UpdatedAt time.Time
46+
47+
// TOCTree is the JSONB blob persisted by the ingest pipeline's
48+
// LLM-driven TOC builder ([]tree.TOCNode marshalled). nil
49+
// (NULL in DB) means "not yet generated" — the expected state
50+
// for non-PDF documents, for documents ingested before the
51+
// 0006 migration, and when the builder failed (builder
52+
// failures are non-fatal and leave this column NULL).
53+
//
54+
// Stored raw so the column round-trips byte-identically
55+
// regardless of slice-element ordering inside the encoder.
56+
// Callers that need the typed shape unmarshal at read time.
57+
TOCTree []byte
4658
}
4759

4860
// NewDocument inserts a fresh document row in the "pending" state.
@@ -83,7 +95,7 @@ func (p *Pool) GetDocument(ctx context.Context, id tree.DocumentID, orgID, store
8395
}
8496
q := `
8597
SELECT id, org_id, store_id, title, content_type, source_ref, status, error_message,
86-
byte_size, metadata, created_at, updated_at
98+
byte_size, metadata, created_at, updated_at, toc_tree
8799
FROM documents WHERE id = $1 AND org_id = $2`
88100
args := []any{string(id), orgID}
89101
if storeID != "" {
@@ -94,13 +106,14 @@ func (p *Pool) GetDocument(ctx context.Context, id tree.DocumentID, orgID, store
94106

95107
var d Document
96108
var status string
97-
var rawMeta []byte
109+
var rawMeta, rawTOC []byte
98110
if err := row.Scan(&d.ID, &d.OrgID, &d.StoreID, &d.Title, &d.ContentType, &d.SourceRef, &status,
99-
&d.ErrorMessage, &d.ByteSize, &rawMeta, &d.CreatedAt, &d.UpdatedAt); err != nil {
111+
&d.ErrorMessage, &d.ByteSize, &rawMeta, &d.CreatedAt, &d.UpdatedAt, &rawTOC); err != nil {
100112
return nil, mapErr(err)
101113
}
102114
d.Status = DocumentStatus(status)
103115
d.Metadata = unmarshalMeta(rawMeta)
116+
d.TOCTree = rawTOC
104117
return &d, nil
105118
}
106119

@@ -111,18 +124,19 @@ func (p *Pool) GetDocument(ctx context.Context, id tree.DocumentID, orgID, store
111124
func (p *Pool) GetDocumentForWorker(ctx context.Context, id tree.DocumentID) (*Document, error) {
112125
row := p.QueryRow(ctx, `
113126
SELECT id, org_id, store_id, title, content_type, source_ref, status, error_message,
114-
byte_size, metadata, created_at, updated_at
127+
byte_size, metadata, created_at, updated_at, toc_tree
115128
FROM documents WHERE id = $1`, string(id))
116129

117130
var d Document
118131
var status string
119-
var rawMeta []byte
132+
var rawMeta, rawTOC []byte
120133
if err := row.Scan(&d.ID, &d.OrgID, &d.StoreID, &d.Title, &d.ContentType, &d.SourceRef, &status,
121-
&d.ErrorMessage, &d.ByteSize, &rawMeta, &d.CreatedAt, &d.UpdatedAt); err != nil {
134+
&d.ErrorMessage, &d.ByteSize, &rawMeta, &d.CreatedAt, &d.UpdatedAt, &rawTOC); err != nil {
122135
return nil, mapErr(err)
123136
}
124137
d.Status = DocumentStatus(status)
125138
d.Metadata = unmarshalMeta(rawMeta)
139+
d.TOCTree = rawTOC
126140
return &d, nil
127141
}
128142

@@ -143,6 +157,24 @@ func (p *Pool) SetDocumentTitle(ctx context.Context, id tree.DocumentID, title s
143157
return mapErr(err)
144158
}
145159

160+
// UpdateDocumentTOCTree persists the LLM-built table-of-contents
161+
// tree onto the documents.toc_tree column. treeJSON is the already
162+
// JSON-marshalled []tree.TOCNode; pass a nil slice to clear (writes
163+
// SQL NULL — the "not yet generated" state). Mirrors
164+
// UpdateSectionSummaryAxes so the column can be patched
165+
// independently of the rest of the document row.
166+
func (p *Pool) UpdateDocumentTOCTree(ctx context.Context, id tree.DocumentID, treeJSON []byte) error {
167+
var arg any
168+
if len(treeJSON) > 0 {
169+
arg = treeJSON
170+
}
171+
_, err := p.Exec(ctx, `
172+
UPDATE documents
173+
SET toc_tree = $2, updated_at = now()
174+
WHERE id = $1`, string(id), arg)
175+
return mapErr(err)
176+
}
177+
146178
// ListDocumentsOpts controls pagination + filtering for ListDocuments.
147179
type ListDocumentsOpts struct {
148180
// OrgID restricts the listing to a single tenant. Required.
@@ -197,7 +229,7 @@ func (p *Pool) ListDocuments(ctx context.Context, o ListDocumentsOpts) ([]Docume
197229

198230
q := `
199231
SELECT id, org_id, store_id, title, content_type, source_ref, status, error_message,
200-
byte_size, metadata, created_at, updated_at
232+
byte_size, metadata, created_at, updated_at, toc_tree
201233
FROM documents ` + where + `
202234
ORDER BY created_at DESC
203235
LIMIT $` + itoa(next)
@@ -212,13 +244,14 @@ func (p *Pool) ListDocuments(ctx context.Context, o ListDocumentsOpts) ([]Docume
212244
for rows.Next() {
213245
var d Document
214246
var status string
215-
var rawMeta []byte
247+
var rawMeta, rawTOC []byte
216248
if err := rows.Scan(&d.ID, &d.OrgID, &d.StoreID, &d.Title, &d.ContentType, &d.SourceRef, &status,
217-
&d.ErrorMessage, &d.ByteSize, &rawMeta, &d.CreatedAt, &d.UpdatedAt); err != nil {
249+
&d.ErrorMessage, &d.ByteSize, &rawMeta, &d.CreatedAt, &d.UpdatedAt, &rawTOC); err != nil {
218250
return nil, time.Time{}, err
219251
}
220252
d.Status = DocumentStatus(status)
221253
d.Metadata = unmarshalMeta(rawMeta)
254+
d.TOCTree = rawTOC
222255
out = append(out, d)
223256
}
224257
if err := rows.Err(); err != nil {

0 commit comments

Comments
 (0)