Skip to content

Commit e183ca7

Browse files
authored
Merge pull request #25 from hallelx2/feat/pageindex-strategy
feat(retrieval): PageIndex-style page-based agentic strategy (PR-B)
2 parents 28ffc33 + 432524c commit e183ca7

11 files changed

Lines changed: 3597 additions & 21 deletions

File tree

cmd/engine/main.go

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -196,24 +196,41 @@ func run() error {
196196
}
197197
q.Register(queue.KindIngestDocument, pipeline.Handler())
198198

199+
// /v1/answer/pageindex gets its OWN PageIndexStrategy instance,
200+
// independent of whatever selection strategy is configured in
201+
// retrieval.strategy. This way the endpoint is always available
202+
// (gated by retrieval.pageindex.enabled), even on a deployment
203+
// using chunked-tree as its default selection path.
204+
var pageIndexStrategy *retrieval.PageIndexStrategy
205+
if cfg.Retrieval.PageIndex.Enabled && llmClient != nil {
206+
pageIndexStrategy = buildPageIndexStrategy(cfg.Retrieval, llmClient, store)
207+
logger.Info("retrieval: pageindex answer endpoint enabled",
208+
"max_hops", pageIndexStrategy.MaxHops,
209+
"page_content_limit", pageIndexStrategy.PageContentLimit,
210+
"model_override", cfg.Retrieval.PageIndex.Model,
211+
)
212+
}
213+
199214
deps := api.Deps{
200-
Logger: logger,
201-
DB: pool,
202-
Storage: store,
203-
Queue: q,
204-
Strategy: strategy,
205-
Version: version,
206-
MultiDoc: multiDoc,
207-
LLM: llmClient,
208-
LLMModel: modelFor(cfg.LLM),
209-
AnswerSpan: cfg.Retrieval.AnswerSpan,
210-
Answer: cfg.Retrieval.Answer,
211-
Planner: planner,
212-
Planning: cfg.Retrieval.Planning,
213-
ReRanker: reRanker,
214-
ReRank: cfg.Retrieval.ReRank,
215-
Replay: replayStore,
216-
Abstain: cfg.Retrieval.Abstain,
215+
Logger: logger,
216+
DB: pool,
217+
Storage: store,
218+
Queue: q,
219+
Strategy: strategy,
220+
Version: version,
221+
MultiDoc: multiDoc,
222+
LLM: llmClient,
223+
LLMModel: modelFor(cfg.LLM),
224+
AnswerSpan: cfg.Retrieval.AnswerSpan,
225+
Answer: cfg.Retrieval.Answer,
226+
Planner: planner,
227+
Planning: cfg.Retrieval.Planning,
228+
ReRanker: reRanker,
229+
ReRank: cfg.Retrieval.ReRank,
230+
Replay: replayStore,
231+
Abstain: cfg.Retrieval.Abstain,
232+
PageIndexStrategy: pageIndexStrategy,
233+
PageIndex: cfg.Retrieval.PageIndex,
217234
}
218235

219236
srv := &http.Server{
@@ -365,11 +382,36 @@ func buildStrategy(c config.RetrievalConfig, client llmgate.Client, store storag
365382
}
366383
a.ModelOverride = c.Agentic.Model
367384
return a
385+
case "pageindex":
386+
return buildPageIndexStrategy(c, client, store)
368387
default:
369388
return retrieval.NewChunkedTree(client)
370389
}
371390
}
372391

392+
// buildPageIndexStrategy constructs the page-based agentic
393+
// strategy with the storage-backed PageLoader and the configured
394+
// caps. Used by buildStrategy when retrieval.strategy=pageindex AND
395+
// by the /v1/answer/pageindex endpoint setup (which wires its own
396+
// instance regardless of the selection strategy).
397+
//
398+
// The TOCProvider is left nil here. PR-A (toc-tree-builder) adds
399+
// documents.toc_tree + a DB-backed provider; until it lands the
400+
// strategy degrades to its synthesised view, which is the
401+
// documented fallback path.
402+
func buildPageIndexStrategy(c config.RetrievalConfig, client llmgate.Client, store storage.Storage) *retrieval.PageIndexStrategy {
403+
p := retrieval.NewPageIndexStrategy(client)
404+
p.PageLoader = storagePageLoader{s: store}
405+
if c.PageIndex.MaxHops > 0 {
406+
p.MaxHops = c.PageIndex.MaxHops
407+
}
408+
if c.PageIndex.PageContentLimit > 0 {
409+
p.PageContentLimit = c.PageIndex.PageContentLimit
410+
}
411+
p.ModelOverride = c.PageIndex.Model
412+
return p
413+
}
414+
373415
// storageFetcher adapts a storage.Storage to retrieval.ContentFetcher.
374416
// The agentic strategy reads section bodies one at a time, so we
375417
// materialize the full reader contents into a []byte here rather than
@@ -385,6 +427,23 @@ func (sf storageFetcher) Get(ctx context.Context, ref string) ([]byte, error) {
385427
return io.ReadAll(rc)
386428
}
387429

430+
// storagePageLoader adapts a storage.Storage to
431+
// retrieval.PageContentLoader. Mirrors storageFetcher but lives
432+
// behind a separate interface so the two callers (agentic /
433+
// pageindex) can be wired independently. The PageIndex strategy
434+
// materialises section bodies once per get_pages observation, so
435+
// reading the full reader into a []byte is the right shape.
436+
type storagePageLoader struct{ s storage.Storage }
437+
438+
func (l storagePageLoader) Load(ctx context.Context, ref string) ([]byte, error) {
439+
rc, _, err := l.s.Get(ctx, ref)
440+
if err != nil {
441+
return nil, err
442+
}
443+
defer rc.Close()
444+
return io.ReadAll(rc)
445+
}
446+
388447
// buildTLSConfig returns a *tls.Config when direct TLS is enabled, or nil
389448
// when the engine should serve plaintext (behind a proxy). Returning nil
390449
// leaves http.Server's TLSConfig unset, which is exactly what ListenAndServe

config.example.yaml

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,20 @@ llm:
9595
reasoning_model: "gemini-2.5-pro"
9696

9797
retrieval:
98-
# strategy: single-pass | chunked-tree
98+
# strategy: single-pass | chunked-tree | agentic | pageindex
99+
#
100+
# single-pass: whole tree in one LLM call; fastest, smallest docs.
101+
# chunked-tree: split the tree, reason over slices in parallel, merge.
102+
# The default. Scales to any tree size by trading
103+
# context for parallelism.
104+
# agentic: iterative outline → expand → read → done loop.
105+
# Picks per-section IDs via a tool-using model.
106+
# pageindex: PageIndex-style page-based agentic loop. Three
107+
# tools (get_document_structure / get_pages / done);
108+
# the model navigates by INCLUSIVE PAGE RANGE
109+
# rather than by section ID. Best for paginated
110+
# documents (SEC filings, academic PDFs) where the
111+
# per-section interface is too noisy.
99112
strategy: "chunked-tree"
100113

101114
chunked_tree:
@@ -232,6 +245,54 @@ retrieval:
232245
# audit flows may bump this; tight memory budgets shrink it.
233246
ttl_seconds: 86400
234247

248+
# pageindex: PageIndex-style page-based agentic strategy and its
249+
# dedicated POST /v1/answer/pageindex endpoint.
250+
#
251+
# The strategy runs a three-tool loop:
252+
# 1. get_document_structure() — returns the TOC tree (titles +
253+
# page ranges, no body text).
254+
# 2. get_pages(start_page, end_page) — returns the concatenated
255+
# content of every section whose page range overlaps.
256+
# 3. done(answer, cited_pages, reasoning) — terminates with the
257+
# natural-language answer plus the cited inclusive ranges.
258+
#
259+
# Unlike /v1/answer there's no separate synthesis call — the
260+
# model emits the final answer inside the done tool call. The
261+
# response carries per-page-range citations with answer-span
262+
# quotes, a deterministic trace_token (replayable via
263+
# /v1/replay), and an optional reasoning_trace describing every
264+
# tool call. Streaming via SSE is available with `stream:true`
265+
# on the request body — one event per tool call so callers
266+
# watch the navigation in real time.
267+
#
268+
# OPT-OUT. Default enabled. Disable to unwire the endpoint
269+
# (returns 501); the strategy itself can still be selected by
270+
# setting `retrieval.strategy: pageindex` even when this block
271+
# is disabled.
272+
#
273+
# Works WITHOUT a persisted TOC tree (pre-PR-A state) — the
274+
# strategy synthesises a TOC view from the section list when
275+
# documents.toc_tree is NULL. No request fails because of a
276+
# missing TOC.
277+
pageindex:
278+
enabled: true
279+
# Cap on LLM turns per request, including the terminal done
280+
# turn. The reference PageIndex demo converges in 3-5 hops on
281+
# typical questions; 8 leaves buffer for retries on parse
282+
# failures and the occasional extra get_pages call.
283+
max_hops: 8
284+
# Cap on chars one get_pages tool call returns. 16,000 ≈ 4K
285+
# tokens — enough for a 5-7 page excerpt, well under any
286+
# flagship model's context window. Higher values risk burning
287+
# context budget on stray full-document fetches.
288+
page_content_limit: 16000
289+
# Override the navigation-loop model; empty inherits the
290+
# request's model (which itself falls back to the engine
291+
# default). Most deployments leave this blank — navigation
292+
# and answer happen in the same loop, so a "small model for
293+
# navigation, large for answer" split doesn't apply.
294+
model: ""
295+
235296
ingest:
236297
# The summarize and HyDE stages run concurrently. This caps the total
237298
# number of LLM calls in flight across both stages combined, so the

0 commit comments

Comments
 (0)