Skip to content

Commit 4f7b5d8

Browse files
committed
Merge branch 'main' into feat/agentic-retrieval
2 parents f2b8a87 + 2a672cb commit 4f7b5d8

20 files changed

Lines changed: 1370 additions & 82 deletions

cmd/engine/main.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,15 @@ func run() error {
110110
multiDoc := retrieval.NewMultiDoc(strategy, pool.LoadTree)
111111

112112
pipeline := ingest.NewPipeline(ingest.Pipeline{
113-
DB: pool,
114-
Storage: store,
115-
LLM: llmClient,
116-
Parsers: ingest.DefaultRegistry(),
117-
Logger: logger,
113+
DB: pool,
114+
Storage: store,
115+
LLM: llmClient,
116+
Parsers: ingest.DefaultRegistry(),
117+
Logger: logger,
118+
HyDEEnabled: cfg.Ingest.HyDE.Enabled,
119+
HyDEModel: cfg.Ingest.HyDE.Model,
120+
HyDENumQuestions: cfg.Ingest.HyDE.NumQuestions,
121+
HyDEConcurrency: cfg.Ingest.HyDE.Concurrency,
118122
})
119123
q.Register(queue.KindIngestDocument, pipeline.Handler())
120124

cmd/server/main.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,15 @@ func run() error {
155155

156156
// ── Ingest pipeline ───────────────────────────────────────────
157157
pipeline := ingest.NewPipeline(ingest.Pipeline{
158-
DB: pool,
159-
Storage: store,
160-
LLM: llmClient,
161-
Parsers: ingest.DefaultRegistry(),
162-
Logger: logger,
158+
DB: pool,
159+
Storage: store,
160+
LLM: llmClient,
161+
Parsers: ingest.DefaultRegistry(),
162+
Logger: logger,
163+
HyDEEnabled: cfg.Engine.Ingest.HyDE.Enabled,
164+
HyDEModel: cfg.Engine.Ingest.HyDE.Model,
165+
HyDENumQuestions: cfg.Engine.Ingest.HyDE.NumQuestions,
166+
HyDEConcurrency: cfg.Engine.Ingest.HyDE.Concurrency,
163167
})
164168
q.Register(queue.KindIngestDocument, pipeline.Handler())
165169

config.example.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,18 @@ retrieval:
107107
# doesn't own, so the model knows what else exists in the document.
108108
include_sibling_breadcrumbs: true
109109

110+
ingest:
111+
# HyDE candidate-question stage. For each leaf section the pipeline asks
112+
# the LLM to enumerate questions the section answers; those are folded
113+
# into the retrieval prompt at query time to widen recall on queries
114+
# that don't echo the section's exact wording.
115+
hyde:
116+
enabled: true
117+
# Override the LLM model used for HyDE; empty inherits the summary model.
118+
model: ""
119+
num_questions: 5
120+
concurrency: 4
121+
110122
log:
111123
level: "info" # debug | info | warn | error
112124
format: "json" # json | console

config.server.example.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ engine:
9898
max_parallel_calls: 8
9999
include_sibling_breadcrumbs: true
100100

101+
ingest:
102+
# HyDE candidate-question generation per leaf section. Folded into
103+
# the retrieval prompt at query time to widen recall on queries that
104+
# don't echo the section's exact wording.
105+
hyde:
106+
enabled: true
107+
model: "" # empty => same model as summarization
108+
num_questions: 5
109+
concurrency: 4
110+
101111
log:
102112
level: "info" # "debug", "info", "warn", "error"
103113
format: "json" # "json" or "console"

internal/api/server.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ func (d Deps) handleGetSection(w http.ResponseWriter, r *http.Request) {
321321
}
322322
}
323323

324-
writeJSON(w, http.StatusOK, map[string]any{
324+
resp := map[string]any{
325325
"id": sec.ID,
326326
"document_id": sec.DocumentID,
327327
"parent_id": sec.ParentID,
@@ -332,7 +332,17 @@ func (d Deps) handleGetSection(w http.ResponseWriter, r *http.Request) {
332332
"token_count": sec.TokenCount,
333333
"metadata": sec.Metadata,
334334
"content": content,
335-
})
335+
}
336+
if sec.PageStart > 0 {
337+
resp["page_start"] = sec.PageStart
338+
}
339+
if sec.PageEnd > 0 {
340+
resp["page_end"] = sec.PageEnd
341+
}
342+
if len(sec.CandidateQuestions) > 0 {
343+
resp["candidate_questions"] = sec.CandidateQuestions
344+
}
345+
writeJSON(w, http.StatusOK, resp)
336346
}
337347

338348
// --- query ---
@@ -415,14 +425,24 @@ func (d Deps) handleQuery(w http.ResponseWriter, r *http.Request) {
415425
content = string(raw)
416426
}
417427
}
418-
sections = append(sections, map[string]any{
428+
s := map[string]any{
419429
"id": sec.ID,
420430
"parent_id": sec.ParentID,
421431
"title": sec.Title,
422432
"summary": sec.Summary,
423433
"token_count": sec.TokenCount,
424434
"content": content,
425-
})
435+
}
436+
if sec.PageStart > 0 {
437+
s["page_start"] = sec.PageStart
438+
}
439+
if sec.PageEnd > 0 {
440+
s["page_end"] = sec.PageEnd
441+
}
442+
if len(sec.CandidateQuestions) > 0 {
443+
s["candidate_questions"] = sec.CandidateQuestions
444+
}
445+
sections = append(sections, s)
426446
}
427447

428448
writeJSON(w, http.StatusOK, map[string]any{
@@ -512,6 +532,15 @@ func (d Deps) handleQueryMulti(w http.ResponseWriter, r *http.Request) {
512532
"token_count": sec.TokenCount,
513533
"content": content,
514534
}
535+
if sec.PageStart > 0 {
536+
s["page_start"] = sec.PageStart
537+
}
538+
if sec.PageEnd > 0 {
539+
s["page_end"] = sec.PageEnd
540+
}
541+
if len(sec.CandidateQuestions) > 0 {
542+
s["candidate_questions"] = sec.CandidateQuestions
543+
}
515544
sections = append(sections, s)
516545
if body.MaxSections > 0 && len(sections) >= body.MaxSections {
517546
break

openapi.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,17 @@ components:
375375
type: string
376376
token_count:
377377
type: integer
378+
page_start:
379+
type: integer
380+
description: Inclusive first page covered by this section. Omitted for non-paginated formats.
381+
page_end:
382+
type: integer
383+
description: Inclusive last page covered by this section. Omitted for non-paginated formats.
384+
candidate_questions:
385+
type: array
386+
items:
387+
type: string
388+
description: HyDE-generated questions this section can answer. Omitted when not yet generated.
378389
metadata:
379390
type: object
380391
additionalProperties:
@@ -440,6 +451,17 @@ components:
440451
type: string
441452
token_count:
442453
type: integer
454+
page_start:
455+
type: integer
456+
description: Inclusive first page covered by this section. Omitted for non-paginated formats.
457+
page_end:
458+
type: integer
459+
description: Inclusive last page covered by this section. Omitted for non-paginated formats.
460+
candidate_questions:
461+
type: array
462+
items:
463+
type: string
464+
description: HyDE-generated questions this section can answer. Omitted when not yet generated.
443465
content:
444466
type: string
445467
description: Full section content from storage.

pkg/config/config.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"fmt"
1313
"os"
1414
"strconv"
15+
"strings"
1516
"time"
1617

1718
"gopkg.in/yaml.v3"
@@ -25,9 +26,38 @@ type Config struct {
2526
Queue QueueConfig `yaml:"queue"`
2627
LLM LLMConfig `yaml:"llm"`
2728
Retrieval RetrievalConfig `yaml:"retrieval"`
29+
Ingest IngestConfig `yaml:"ingest"`
2830
Log LogConfig `yaml:"log"`
2931
}
3032

33+
// IngestConfig configures retrieval-quality boosters that run during
34+
// the ingest pipeline (between summarize and StatusReady).
35+
type IngestConfig struct {
36+
HyDE HyDEConfig `yaml:"hyde"`
37+
}
38+
39+
// HyDEConfig configures the HyDE candidate-question stage. For each
40+
// leaf section the pipeline asks the LLM to enumerate questions the
41+
// section's content can answer; those are later folded into the
42+
// retrieval prompt to widen lexical/semantic overlap with user queries.
43+
type HyDEConfig struct {
44+
// Enabled toggles the stage. Default: true. Disable to skip an LLM
45+
// call per leaf when ingest budget matters more than recall.
46+
Enabled bool `yaml:"enabled"`
47+
48+
// Model, when non-empty, overrides the LLM model used for HyDE
49+
// generation. Defaults to the same model used for summarization.
50+
Model string `yaml:"model"`
51+
52+
// NumQuestions caps the questions generated per leaf section.
53+
// Default: 5.
54+
NumQuestions int `yaml:"num_questions"`
55+
56+
// Concurrency bounds parallel LLM calls during the HyDE stage.
57+
// Default: 4.
58+
Concurrency int `yaml:"concurrency"`
59+
}
60+
3161
// ServerConfig configures the HTTP server.
3262
//
3363
// TLS is opt-in. If TLS.CertFile and TLS.KeyFile are both set the engine
@@ -241,6 +271,13 @@ func Default() Config {
241271
TTLSeconds: 600,
242272
},
243273
},
274+
Ingest: IngestConfig{
275+
HyDE: HyDEConfig{
276+
Enabled: true,
277+
NumQuestions: 5,
278+
Concurrency: 4,
279+
},
280+
},
244281
Log: LogConfig{Level: "info", Format: "json"},
245282
}
246283
}
@@ -344,6 +381,29 @@ func applyEnvOverrides(c *Config) {
344381
if v := os.Getenv("VLE_RETRIEVAL_AGENTIC_MODEL"); v != "" {
345382
c.Retrieval.Agentic.Model = v
346383
}
384+
// Ingest / HyDE knobs. Booleans accept the usual truthy strings —
385+
// kept narrow so a typo doesn't silently flip the flag.
386+
if v := os.Getenv("VLE_INGEST_HYDE_ENABLED"); v != "" {
387+
switch strings.ToLower(strings.TrimSpace(v)) {
388+
case "1", "true", "yes", "on":
389+
c.Ingest.HyDE.Enabled = true
390+
case "0", "false", "no", "off":
391+
c.Ingest.HyDE.Enabled = false
392+
}
393+
}
394+
if v := os.Getenv("VLE_INGEST_HYDE_MODEL"); v != "" {
395+
c.Ingest.HyDE.Model = v
396+
}
397+
if v := os.Getenv("VLE_INGEST_HYDE_NUM_QUESTIONS"); v != "" {
398+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
399+
c.Ingest.HyDE.NumQuestions = n
400+
}
401+
}
402+
if v := os.Getenv("VLE_INGEST_HYDE_CONCURRENCY"); v != "" {
403+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
404+
c.Ingest.HyDE.Concurrency = n
405+
}
406+
}
347407
}
348408

349409
// Validate checks that required fields for the selected drivers are set.
@@ -416,5 +476,12 @@ func (c Config) Validate() error {
416476
return fmt.Errorf("server.tls.min_version must be 1.2 or 1.3, got %q", v)
417477
}
418478

479+
if c.Ingest.HyDE.NumQuestions < 0 {
480+
return fmt.Errorf("ingest.hyde.num_questions must be >= 0, got %d", c.Ingest.HyDE.NumQuestions)
481+
}
482+
if c.Ingest.HyDE.Concurrency < 0 {
483+
return fmt.Errorf("ingest.hyde.concurrency must be >= 0, got %d", c.Ingest.HyDE.Concurrency)
484+
}
485+
419486
return nil
420487
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
DROP INDEX IF EXISTS sections_doc_pages_idx;
2+
ALTER TABLE sections
3+
DROP COLUMN IF EXISTS candidate_questions,
4+
DROP COLUMN IF EXISTS page_end,
5+
DROP COLUMN IF EXISTS page_start;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- 0004_sections_extras.up.sql — page citations + HyDE candidate questions.
2+
--
3+
-- Two retrieval-quality extensions to the sections table:
4+
--
5+
-- page_start / page_end
6+
-- The inclusive page range each section covers, for parsers that
7+
-- produce page-aware output (PDF today; others leave them NULL/0).
8+
-- Surfaced to API responses so callers can render citations.
9+
--
10+
-- candidate_questions
11+
-- JSONB array of generated questions a section can answer (HyDE).
12+
-- Filled by the ingest pipeline's HyDE stage and woven into the
13+
-- retrieval prompt to widen lexical/semantic overlap with the user
14+
-- query.
15+
16+
ALTER TABLE sections
17+
ADD COLUMN IF NOT EXISTS page_start INTEGER,
18+
ADD COLUMN IF NOT EXISTS page_end INTEGER,
19+
ADD COLUMN IF NOT EXISTS candidate_questions JSONB;
20+
21+
CREATE INDEX IF NOT EXISTS sections_doc_pages_idx
22+
ON sections (document_id, page_start, page_end);

0 commit comments

Comments
 (0)