Skip to content

Commit cd42ae8

Browse files
authored
Merge pull request #20 from hallelx2/feat/pdftable-integration
feat: pdftable integration - table-aware ingest (Phase 1.3.F)
2 parents 75efc0c + 9b77f36 commit cd42ae8

13 files changed

Lines changed: 979 additions & 120 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ Or via environment variables: `VLE_TLS_CERT_FILE`, `VLE_TLS_KEY_FILE`.
222222
| Markdown | `goldmark` | ATX + Setext headings become section boundaries |
223223
| HTML | `golang.org/x/net/html` | Prefers `<main>`/`<article>`; skips nav/footer/script |
224224
| DOCX | stdlib `archive/zip` + `encoding/xml` | `Heading 1…9` styles become section boundaries |
225-
| PDF | `ledongthuc/pdf` | Font-size heuristic recovers headings from unstructured PDFs |
225+
| PDF | `hallelx2/pdftable` + `ledongthuc/pdf` | pdftable extracts positioned words + ruled / borderless tables (Markdown-rendered, `Metadata["table"]="true"`); font-size heuristic recovers headings; ledongthuc supplies `/Outlines` when present |
226226
| Text | stdlib | Single-section fallback |
227227

228228
New parsers drop in behind a one-method `Parser` interface — see [`pkg/parser/`](pkg/parser/).

cmd/engine/main.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/hallelx2/vectorless-engine/pkg/config"
2828
"github.com/hallelx2/vectorless-engine/pkg/db"
2929
"github.com/hallelx2/vectorless-engine/pkg/ingest"
30+
"github.com/hallelx2/vectorless-engine/pkg/parser"
3031
"github.com/hallelx2/vectorless-engine/pkg/queue"
3132
"github.com/hallelx2/vectorless-engine/pkg/retrieval"
3233
"github.com/hallelx2/vectorless-engine/pkg/storage"
@@ -171,14 +172,24 @@ func run() error {
171172
DB: pool,
172173
Storage: store,
173174
LLM: llmClient,
174-
Parsers: ingest.DefaultRegistry(),
175+
Parsers: ingest.RegistryFromTableOpts(tableOptsFromConfig(cfg.Ingest.Tables)),
175176
Logger: logger,
176177
HyDEEnabled: cfg.Ingest.HyDE.Enabled,
177178
HyDEModel: cfg.Ingest.HyDE.Model,
178179
HyDENumQuestions: cfg.Ingest.HyDE.NumQuestions,
179180
HyDEConcurrency: cfg.Ingest.HyDE.Concurrency,
180181
GlobalLLMConcurrency: cfg.Ingest.GlobalLLMConcurrency,
181182
})
183+
if cfg.Ingest.Tables.Enabled {
184+
logger.Info("ingest: pdf table extraction enabled",
185+
"vertical_strategy", cfg.Ingest.Tables.VerticalStrategy,
186+
"horizontal_strategy", cfg.Ingest.Tables.HorizontalStrategy,
187+
"min_rows", cfg.Ingest.Tables.MinTableRows,
188+
"min_cols", cfg.Ingest.Tables.MinTableCols,
189+
)
190+
} else {
191+
logger.Info("ingest: pdf table extraction disabled")
192+
}
182193
q.Register(queue.KindIngestDocument, pipeline.Handler())
183194

184195
deps := api.Deps{
@@ -405,3 +416,19 @@ func newLogger(c config.LogConfig) *slog.Logger {
405416
}
406417
return slog.New(h)
407418
}
419+
420+
// tableOptsFromConfig translates the YAML/env Tables block into the
421+
// parser-level TableOpts struct. Returns nil when tables are disabled so
422+
// the PDF parser short-circuits without instantiating pdftable settings.
423+
func tableOptsFromConfig(c config.TablesConfig) *parser.TableOpts {
424+
if !c.Enabled {
425+
return nil
426+
}
427+
return &parser.TableOpts{
428+
Enabled: true,
429+
VerticalStrategy: c.VerticalStrategy,
430+
HorizontalStrategy: c.HorizontalStrategy,
431+
MinTableRows: c.MinTableRows,
432+
MinTableCols: c.MinTableCols,
433+
}
434+
}

cmd/server/main.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333

3434
"github.com/hallelx2/vectorless-engine/pkg/db"
3535
"github.com/hallelx2/vectorless-engine/pkg/ingest"
36+
"github.com/hallelx2/vectorless-engine/pkg/parser"
3637
"github.com/hallelx2/vectorless-engine/pkg/queue"
3738
"github.com/hallelx2/vectorless-engine/pkg/retrieval"
3839
"github.com/hallelx2/vectorless-engine/pkg/storage"
@@ -158,14 +159,24 @@ func run() error {
158159
DB: pool,
159160
Storage: store,
160161
LLM: llmClient,
161-
Parsers: ingest.DefaultRegistry(),
162+
Parsers: ingest.RegistryFromTableOpts(tableOptsFromConfig(cfg.Engine.Ingest.Tables)),
162163
Logger: logger,
163164
HyDEEnabled: cfg.Engine.Ingest.HyDE.Enabled,
164165
HyDEModel: cfg.Engine.Ingest.HyDE.Model,
165166
HyDENumQuestions: cfg.Engine.Ingest.HyDE.NumQuestions,
166167
HyDEConcurrency: cfg.Engine.Ingest.HyDE.Concurrency,
167168
GlobalLLMConcurrency: cfg.Engine.Ingest.GlobalLLMConcurrency,
168169
})
170+
if cfg.Engine.Ingest.Tables.Enabled {
171+
logger.Info("ingest: pdf table extraction enabled",
172+
"vertical_strategy", cfg.Engine.Ingest.Tables.VerticalStrategy,
173+
"horizontal_strategy", cfg.Engine.Ingest.Tables.HorizontalStrategy,
174+
"min_rows", cfg.Engine.Ingest.Tables.MinTableRows,
175+
"min_cols", cfg.Engine.Ingest.Tables.MinTableCols,
176+
)
177+
} else {
178+
logger.Info("ingest: pdf table extraction disabled")
179+
}
169180
q.Register(queue.KindIngestDocument, pipeline.Handler())
170181

171182
// ── Start subsystems ──────────────────────────────────────────
@@ -395,3 +406,20 @@ func newLogger(c enginecfg.LogConfig) *slog.Logger {
395406
}
396407
return slog.New(h)
397408
}
409+
410+
// tableOptsFromConfig translates the engine's TablesConfig (from the
411+
// embedded engine config block) into the parser-level TableOpts. Returns
412+
// nil when tables are disabled so the PDF parser short-circuits without
413+
// instantiating pdftable settings.
414+
func tableOptsFromConfig(c enginecfg.TablesConfig) *parser.TableOpts {
415+
if !c.Enabled {
416+
return nil
417+
}
418+
return &parser.TableOpts{
419+
Enabled: true,
420+
VerticalStrategy: c.VerticalStrategy,
421+
HorizontalStrategy: c.HorizontalStrategy,
422+
MinTableRows: c.MinTableRows,
423+
MinTableCols: c.MinTableCols,
424+
}
425+
}

config.example.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,33 @@ ingest:
222222
num_questions: 5
223223
concurrency: 4
224224

225+
# Tables: pdftable-driven extraction. Every detected table on a PDF
226+
# page becomes its own Section with `Metadata["table"]="true"`, content
227+
# rendered as GitHub-flavoured Markdown. This is the single biggest
228+
# retrieval-quality lever on documents where numeric answers live in
229+
# balance sheets — text-only extraction collapses tables into a
230+
# space-joined run that's effectively unsearchable.
231+
#
232+
# ENABLED BY DEFAULT. Flip to false if a pathological PDF surfaces a
233+
# regression — table-extraction errors never break ingest (text-only
234+
# output still ships), but the flag is the kill switch.
235+
tables:
236+
enabled: true
237+
# Vertical / horizontal edge-detection strategy. One of:
238+
# lines (default) — edges from drawn lines/rects/curves
239+
# lines_strict edges from drawn lines only
240+
# text edges inferred from word alignment
241+
# (best for borderless / narrative tables)
242+
# explicit caller-supplied coordinates (reserved)
243+
# The two axes mix independently, so "lines" vertical + "text"
244+
# horizontal works for half-ruled tables.
245+
vertical_strategy: "lines"
246+
horizontal_strategy: "lines"
247+
# Drop candidate tables smaller than this. 2x2 is the floor — a
248+
# single row or column is a list or a header, not a table.
249+
min_table_rows: 2
250+
min_table_cols: 2
251+
225252
log:
226253
level: "info" # debug | info | warn | error
227254
format: "json" # json | console

docs/ENGINE.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,15 @@ internals.
234234
pooling.
235235
- **Embedded SQL migrations** via `//go:embed`. No Atlas, no goose, no
236236
Flyway. Migration is ten lines of Go; external tools are overkill.
237-
- **ledongthuc/pdf** for PDF — pure Go, no cgo, cross-compiles cleanly.
238-
Trade-off: no OCR, no encrypted PDFs. Deferred to Phase 2+.
237+
- **hallelx2/pdftable** (primary) + **ledongthuc/pdf** (fallback for
238+
`/Outlines` only) for PDF. pdftable is a pure-Go port of pdfplumber:
239+
positioned-word extraction + pdfplumber-parity table-finding pipeline
240+
(`lines` / `lines_strict` / `text` / `explicit` strategies). Detected
241+
tables become Sections flagged with `Metadata["table"]="true"` and
242+
Markdown-rendered content. Encrypted PDFs are auto-decrypted via
243+
pdfcpu's empty-password path. Trade-off: no OCR (scanned PDFs still
244+
unsupported); single-bookmark / outline access still requires
245+
ledongthuc until pdftable exposes the dictionary.
239246
- **goldmark** for Markdown — the Go community's standard, actively
240247
maintained.
241248
- **`golang.org/x/net/html`** for HTML — stdlib-ish, no third-party dep.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
github.com/go-chi/chi/v5 v5.2.5
1414
github.com/google/uuid v1.6.0
1515
github.com/hallelx2/llmgate v0.2.0
16+
github.com/hallelx2/pdftable v0.3.0
1617
github.com/hibiken/asynq v0.26.0
1718
github.com/jackc/pgx/v5 v5.9.2
1819
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+u
134134
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
135135
github.com/hallelx2/llmgate v0.2.0 h1:x/LNCeHUPZpafn2IXi+LqpnZa7TtEQdLVlpkkJTlzBI=
136136
github.com/hallelx2/llmgate v0.2.0/go.mod h1:MK2Ol/5CIweTQ2/9eSiTJ5g/KSSuobNZL9TD3s57JxY=
137+
github.com/hallelx2/pdftable v0.3.0 h1:SwZPu2z4cIR4R30gP+7bpunGh931StjO1vrsxoldiDw=
138+
github.com/hallelx2/pdftable v0.3.0/go.mod h1:pxNlc4D43wjzis7M6EfgQZvHOsQ4okggm+xqUu+OokI=
137139
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
138140
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
139141
github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8=

pkg/config/config.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ type Config struct {
3535
type IngestConfig struct {
3636
HyDE HyDEConfig `yaml:"hyde"`
3737

38+
// Tables configures pdftable's table-finding pass over PDF inputs.
39+
// Enabled by default — tables are the single biggest retrieval-quality
40+
// boost on FinanceBench-style documents because every numeric question
41+
// hides in a balance sheet that text-only extraction collapses.
42+
Tables TablesConfig `yaml:"tables"`
43+
3844
// GlobalLLMConcurrency caps the total number of LLM calls in flight
3945
// across the summarize and HyDE stages combined, which now run
4046
// concurrently. Each stage still respects its own per-stage cap
@@ -47,6 +53,48 @@ type IngestConfig struct {
4753
GlobalLLMConcurrency int `yaml:"global_llm_concurrency"`
4854
}
4955

56+
// TablesConfig configures the table-extraction stage of the PDF parser.
57+
// The stage runs pdftable's geometry-based finder over every page and
58+
// emits each detected table as its own Section with
59+
// Metadata["table"]="true", so downstream retrieval and the agentic
60+
// navigator can branch on whether a candidate is a numeric table or
61+
// prose.
62+
//
63+
// All knobs are forwarded to pdftable's TableSettings; defaults match
64+
// pdfplumber. See pdftable's docs for the full strategy surface.
65+
type TablesConfig struct {
66+
// Enabled toggles the stage. Default: true. Flip to false to
67+
// restore pre-integration text-only output; one config change is
68+
// enough to roll back if a real-world PDF triggers a regression.
69+
Enabled bool `yaml:"enabled"`
70+
71+
// VerticalStrategy picks the source of vertical column boundaries.
72+
// Allowed values:
73+
// - "lines" (default) edges from drawn lines/rects/curves
74+
// - "lines_strict" edges from drawn lines only
75+
// - "text" edges inferred from word alignment (borderless
76+
// tables — bank statements, narrative 10-Ks)
77+
// - "explicit" caller-supplied coordinates (not yet wired
78+
// through the engine config; reserved)
79+
VerticalStrategy string `yaml:"vertical_strategy"`
80+
81+
// HorizontalStrategy picks the source of horizontal row boundaries.
82+
// Same value set as VerticalStrategy; the two axes can mix
83+
// independently (e.g. "lines" vertical + "text" horizontal).
84+
HorizontalStrategy string `yaml:"horizontal_strategy"`
85+
86+
// MinTableRows drops candidate tables with fewer than this many
87+
// rows. Default: 2. Trivial single-row matches are almost always
88+
// false positives from layout artefacts (form-field grids, ruling
89+
// hairlines on a single line of text).
90+
MinTableRows int `yaml:"min_table_rows"`
91+
92+
// MinTableCols drops candidate tables with fewer than this many
93+
// columns. Default: 2. Same rationale as MinTableRows — a single
94+
// column is a vertical list, not a table.
95+
MinTableCols int `yaml:"min_table_cols"`
96+
}
97+
5098
// HyDEConfig configures the HyDE candidate-question stage. For each
5199
// leaf section the pipeline asks the LLM to enumerate questions the
52100
// section's content can answer; those are later folded into the
@@ -449,6 +497,13 @@ func Default() Config {
449497
NumQuestions: 5,
450498
Concurrency: 4,
451499
},
500+
Tables: TablesConfig{
501+
Enabled: true,
502+
VerticalStrategy: "lines",
503+
HorizontalStrategy: "lines",
504+
MinTableRows: 2,
505+
MinTableCols: 2,
506+
},
452507
},
453508
Log: LogConfig{Level: "info", Format: "json"},
454509
}
@@ -581,6 +636,31 @@ func applyEnvOverrides(c *Config) {
581636
c.Ingest.GlobalLLMConcurrency = n
582637
}
583638
}
639+
// pdftable-driven table extraction.
640+
if v := os.Getenv("VLE_INGEST_TABLES_ENABLED"); v != "" {
641+
switch strings.ToLower(strings.TrimSpace(v)) {
642+
case "1", "true", "yes", "on":
643+
c.Ingest.Tables.Enabled = true
644+
case "0", "false", "no", "off":
645+
c.Ingest.Tables.Enabled = false
646+
}
647+
}
648+
if v := os.Getenv("VLE_INGEST_TABLES_VERTICAL_STRATEGY"); v != "" {
649+
c.Ingest.Tables.VerticalStrategy = v
650+
}
651+
if v := os.Getenv("VLE_INGEST_TABLES_HORIZONTAL_STRATEGY"); v != "" {
652+
c.Ingest.Tables.HorizontalStrategy = v
653+
}
654+
if v := os.Getenv("VLE_INGEST_TABLES_MIN_ROWS"); v != "" {
655+
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
656+
c.Ingest.Tables.MinTableRows = n
657+
}
658+
}
659+
if v := os.Getenv("VLE_INGEST_TABLES_MIN_COLS"); v != "" {
660+
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
661+
c.Ingest.Tables.MinTableCols = n
662+
}
663+
}
584664
if v := os.Getenv("VLE_RETRIEVAL_ANSWER_SPAN_ENABLED"); v != "" {
585665
switch strings.ToLower(strings.TrimSpace(v)) {
586666
case "1", "true", "yes", "on":
@@ -750,6 +830,25 @@ func (c Config) Validate() error {
750830
return fmt.Errorf("ingest.global_llm_concurrency must be >= 0, got %d", c.Ingest.GlobalLLMConcurrency)
751831
}
752832

833+
switch c.Ingest.Tables.VerticalStrategy {
834+
case "", "lines", "lines_strict", "text", "explicit":
835+
default:
836+
return fmt.Errorf("ingest.tables.vertical_strategy must be one of lines|lines_strict|text|explicit, got %q",
837+
c.Ingest.Tables.VerticalStrategy)
838+
}
839+
switch c.Ingest.Tables.HorizontalStrategy {
840+
case "", "lines", "lines_strict", "text", "explicit":
841+
default:
842+
return fmt.Errorf("ingest.tables.horizontal_strategy must be one of lines|lines_strict|text|explicit, got %q",
843+
c.Ingest.Tables.HorizontalStrategy)
844+
}
845+
if c.Ingest.Tables.MinTableRows < 0 {
846+
return fmt.Errorf("ingest.tables.min_table_rows must be >= 0, got %d", c.Ingest.Tables.MinTableRows)
847+
}
848+
if c.Ingest.Tables.MinTableCols < 0 {
849+
return fmt.Errorf("ingest.tables.min_table_cols must be >= 0, got %d", c.Ingest.Tables.MinTableCols)
850+
}
851+
753852
if c.Retrieval.Planning.CacheSize < 0 {
754853
return fmt.Errorf("retrieval.planning.cache_size must be >= 0, got %d", c.Retrieval.Planning.CacheSize)
755854
}

0 commit comments

Comments
 (0)