Skip to content

Commit 94fc6a3

Browse files
authored
Merge pull request #26 from hallelx2/feat/consolidate-server-redesign
fix(server): wire PageIndex strategy + answer endpoints into the DEPLOYED cmd/server binary
2 parents e2de03d + 702e08a commit 94fc6a3

8 files changed

Lines changed: 1500 additions & 21 deletions

File tree

cmd/server/main.go

Lines changed: 174 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
327389
func 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

Comments
 (0)