@@ -37,6 +37,7 @@ import (
3737 "github.com/hallelx2/vectorless-engine/pkg/queue"
3838 "github.com/hallelx2/vectorless-engine/pkg/retrieval"
3939 "github.com/hallelx2/vectorless-engine/pkg/storage"
40+ "github.com/hallelx2/vectorless-engine/pkg/tree"
4041
4142 "github.com/hallelx2/vectorless-engine/internal/config"
4243 "github.com/hallelx2/vectorless-engine/internal/handler"
@@ -133,7 +134,7 @@ func run() error {
133134 if err != nil {
134135 return fmt .Errorf ("init llm: %w" , err )
135136 }
136- strategy := buildStrategy (cfg .Engine .Retrieval , llmClient , store )
137+ strategy := buildStrategy (cfg .Engine .Retrieval , llmClient , store , pool )
137138
138139 // Wrap with caching if enabled in engine config.
139140 if cfg .Engine .Retrieval .Cache .Enabled {
@@ -154,6 +155,44 @@ func run() error {
154155 // Multi-document query dispatcher.
155156 multiDoc := retrieval .NewMultiDoc (strategy , pool .LoadTree )
156157
158+ // Pre-built set of selectable strategies, keyed by config name.
159+ // Backs the per-request "strategy" override on /v1/query so the
160+ // benchmark can A/B chunked-tree vs pageindex against this same
161+ // running engine without a redeploy. Built from the raw client so
162+ // each override behaves identically to booting with that strategy
163+ // as the default (no shared cache wrapper across overrides).
164+ strategies := buildStrategySet (cfg .Engine .Retrieval , llmClient , store , pool )
165+
166+ // Replay store: every /v1/answer and /v1/answer/pageindex response
167+ // is stamped with a deterministic trace_token and its body bytes
168+ // persisted here so /v1/replay can return them verbatim. On by
169+ // default; operators opt out via retrieval.replay.enabled=false.
170+ var replayStore retrieval.ReplayStore
171+ if cfg .Engine .Retrieval .Replay .Enabled {
172+ replayStore = retrieval .NewLRUReplayStore (retrieval.LRUReplayConfig {
173+ MaxEntries : cfg .Engine .Retrieval .Replay .MaxEntries ,
174+ TTL : time .Duration (cfg .Engine .Retrieval .Replay .TTLSeconds ) * time .Second ,
175+ })
176+ logger .Info ("retrieval: replay store enabled" ,
177+ "max_entries" , cfg .Engine .Retrieval .Replay .MaxEntries ,
178+ "ttl_seconds" , cfg .Engine .Retrieval .Replay .TTLSeconds ,
179+ )
180+ }
181+
182+ // /v1/answer/pageindex gets its OWN PageIndexStrategy instance,
183+ // independent of whatever selection strategy retrieval.strategy
184+ // chose, so the endpoint is always available (gated by
185+ // retrieval.pageindex.enabled) even on a chunked-tree deployment.
186+ var pageIndexStrategy * retrieval.PageIndexStrategy
187+ if cfg .Engine .Retrieval .PageIndex .Enabled && llmClient != nil {
188+ pageIndexStrategy = buildPageIndexStrategy (cfg .Engine .Retrieval , llmClient , store , pool )
189+ logger .Info ("retrieval: pageindex answer endpoint enabled" ,
190+ "max_hops" , pageIndexStrategy .MaxHops ,
191+ "page_content_limit" , pageIndexStrategy .PageContentLimit ,
192+ "model_override" , cfg .Engine .Retrieval .PageIndex .Model ,
193+ )
194+ }
195+
157196 // ── Ingest pipeline ───────────────────────────────────────────
158197 pipeline := ingest .NewPipeline (ingest.Pipeline {
159198 DB : pool ,
@@ -201,14 +240,22 @@ func run() error {
201240 // Only start the HTTP server in "server" role.
202241 if * role == "server" {
203242 deps := handler.Deps {
204- Logger : logger ,
205- DB : pool ,
206- Storage : store ,
207- Queue : q ,
208- Strategy : strategy ,
209- MultiDoc : multiDoc ,
210- Version : version ,
211- Config : cfg ,
243+ Logger : logger ,
244+ DB : pool ,
245+ Storage : store ,
246+ Queue : q ,
247+ Strategy : strategy ,
248+ MultiDoc : multiDoc ,
249+ Version : version ,
250+ Config : cfg ,
251+ Strategies : strategies ,
252+ LLM : llmClient ,
253+ LLMModel : modelFor (cfg .Engine .LLM ),
254+ AnswerSpan : cfg .Engine .Retrieval .AnswerSpan ,
255+ Answer : cfg .Engine .Retrieval .Answer ,
256+ Replay : replayStore ,
257+ PageIndexStrategy : pageIndexStrategy ,
258+ PageIndex : cfg .Engine .Retrieval .PageIndex ,
212259 }
213260
214261 srv := & http.Server {
@@ -324,6 +371,21 @@ func buildQueue(c enginecfg.QueueConfig, dbURL string) (queue.Queue, error) {
324371 }
325372}
326373
374+ // modelFor returns the configured chat/general-purpose model name for
375+ // the selected LLM driver. Used as the engine-default fallback when an
376+ // API request omits an explicit model (answer + answer/pageindex).
377+ func modelFor (c enginecfg.LLMConfig ) string {
378+ switch c .Driver {
379+ case "anthropic" :
380+ return c .Anthropic .Model
381+ case "openai" :
382+ return c .OpenAI .Model
383+ case "gemini" :
384+ return c .Gemini .Model
385+ }
386+ return ""
387+ }
388+
327389func buildLLM (c enginecfg.LLMConfig ) (llmgate.Client , error ) {
328390 switch c .Driver {
329391 case "anthropic" :
@@ -349,7 +411,11 @@ func buildLLM(c enginecfg.LLMConfig) (llmgate.Client, error) {
349411 }
350412}
351413
352- func buildStrategy (c enginecfg.RetrievalConfig , client llmgate.Client , store storage.Storage ) retrieval.Strategy {
414+ // buildStrategy constructs the retrieval strategy named by
415+ // retrieval.strategy. The DB pool is threaded through so the
416+ // pageindex strategy can wire a TOC provider that reads
417+ // documents.toc_tree (the other strategies ignore it).
418+ func buildStrategy (c enginecfg.RetrievalConfig , client llmgate.Client , store storage.Storage , pool * db.Pool ) retrieval.Strategy {
353419 switch c .Strategy {
354420 case "single-pass" :
355421 return retrieval .NewSinglePass (client )
@@ -362,11 +428,109 @@ func buildStrategy(c enginecfg.RetrievalConfig, client llmgate.Client, store sto
362428 }
363429 a .ModelOverride = c .Agentic .Model
364430 return a
431+ case "pageindex" :
432+ return buildPageIndexStrategy (c , client , store , pool )
365433 default :
366434 return retrieval .NewChunkedTree (client )
367435 }
368436}
369437
438+ // buildStrategySet pre-builds one instance of every selectable
439+ // strategy, keyed by its config name. The deployed /v1/query handler
440+ // uses this map to honour a per-request "strategy" override without
441+ // rebuilding a strategy on the hot path: selection is a map lookup.
442+ //
443+ // This is what lets the benchmark A/B chunked-tree vs pageindex
444+ // against the SAME running engine — no redeploy, no config flip. The
445+ // caps (agentic max-hops, pageindex page limits, model overrides) come
446+ // from the same config blocks the default builder reads, so an
447+ // override behaves identically to booting with that strategy as the
448+ // default.
449+ func buildStrategySet (c enginecfg.RetrievalConfig , client llmgate.Client , store storage.Storage , pool * db.Pool ) map [string ]retrieval.Strategy {
450+ agentic := retrieval .NewAgentic (client , storageFetcher {s : store })
451+ if c .Agentic .MaxHops > 0 {
452+ agentic .MaxHops = c .Agentic .MaxHops
453+ }
454+ agentic .ModelOverride = c .Agentic .Model
455+
456+ return map [string ]retrieval.Strategy {
457+ "single-pass" : retrieval .NewSinglePass (client ),
458+ "chunked-tree" : retrieval .NewChunkedTree (client ),
459+ "agentic" : agentic ,
460+ "pageindex" : buildPageIndexStrategy (c , client , store , pool ),
461+ }
462+ }
463+
464+ // buildPageIndexStrategy constructs the page-based agentic strategy
465+ // with the storage-backed PageLoader, a DB-backed TOC provider, and
466+ // the configured caps. Ported from cmd/engine so the DEPLOYED
467+ // cmd/server binary can serve retrieval.strategy=pageindex AND the
468+ // /v1/answer/pageindex endpoint.
469+ //
470+ // The TOC provider reads documents.toc_tree via the worker-scoped
471+ // document lookup. The strategy degrades to its synthesised view
472+ // (built from the loaded section tree) whenever the column is NULL or
473+ // the read errors, so a document ingested before the TOC builder ran
474+ // still navigates cleanly.
475+ func buildPageIndexStrategy (c enginecfg.RetrievalConfig , client llmgate.Client , store storage.Storage , pool * db.Pool ) * retrieval.PageIndexStrategy {
476+ p := retrieval .NewPageIndexStrategy (client )
477+ p .PageLoader = storagePageLoader {s : store }
478+ if pool != nil {
479+ p .TOC = dbTOCProvider {db : pool }
480+ }
481+ if c .PageIndex .MaxHops > 0 {
482+ p .MaxHops = c .PageIndex .MaxHops
483+ }
484+ if c .PageIndex .PageContentLimit > 0 {
485+ p .PageContentLimit = c .PageIndex .PageContentLimit
486+ }
487+ p .ModelOverride = c .PageIndex .Model
488+ return p
489+ }
490+
491+ // storagePageLoader adapts a storage.Storage to
492+ // retrieval.PageContentLoader. Mirrors storageFetcher but lives behind
493+ // a separate interface so the two callers (agentic / pageindex) can be
494+ // wired independently. The PageIndex strategy materialises section
495+ // bodies once per get_pages observation, so reading the full reader
496+ // into a []byte is the right shape.
497+ type storagePageLoader struct { s storage.Storage }
498+
499+ func (l storagePageLoader ) Load (ctx context.Context , ref string ) ([]byte , error ) {
500+ rc , _ , err := l .s .Get (ctx , ref )
501+ if err != nil {
502+ return nil , err
503+ }
504+ defer rc .Close ()
505+ return io .ReadAll (rc )
506+ }
507+
508+ // dbTOCProvider adapts the DB pool to retrieval.TOCProvider. It reads
509+ // the persisted documents.toc_tree JSONB and returns it verbatim for
510+ // the get_document_structure tool. A NULL column (the "not yet
511+ // generated" state) surfaces as retrieval.ErrNoTOC, which the strategy
512+ // treats as a graceful-degrade signal: it synthesises the TOC view
513+ // from the section tree instead of failing the request.
514+ //
515+ // GetTOC carries only a document ID (the TOCProvider contract), so the
516+ // lookup uses the worker-scoped accessor. That is safe here: the
517+ // caller has already resolved + authorised the tree for this document
518+ // via the org-scoped LoadTree before the strategy ever calls GetTOC,
519+ // and the TOC tree is the same structural metadata (titles + page
520+ // ranges, no bodies) already present on that authorised tree.
521+ type dbTOCProvider struct { db * db.Pool }
522+
523+ func (p dbTOCProvider ) GetTOC (ctx context.Context , docID tree.DocumentID ) ([]byte , error ) {
524+ doc , err := p .db .GetDocumentForWorker (ctx , docID )
525+ if err != nil {
526+ return nil , err
527+ }
528+ if len (doc .TOCTree ) == 0 {
529+ return nil , retrieval .ErrNoTOC
530+ }
531+ return doc .TOCTree , nil
532+ }
533+
370534// storageFetcher adapts a storage.Storage to retrieval.ContentFetcher.
371535// The agentic strategy reads section bodies one at a time, so we
372536// materialize the full reader contents into a []byte here rather than
0 commit comments