Skip to content

Commit 478e578

Browse files
committed
feat(api): wire content-aware re-rank into /v1/query + /v1/answer
Adds the per-request `enable_rerank` body field (pointer, so absent vs. explicit false are distinguishable) on both QueryRequest and AnswerRequest. When true, or when retrieval.rerank.enabled is on at config level, the engine runs one extra LLM call after candidate content is loaded but before answer-span extraction / synthesis. The pass scores each candidate on 0-100 and reorders the slice descending by score. retrieval.rerank.top_k (when non-zero) truncates the survivors. Sections that ran through re-rank surface a `score` field in /v1/query.sections[] and /v1/answer.citations[]. Failure semantics carried through from pkg/retrieval/rerank.go: - No LLM client or no ReRanker wired → re-rank silently skipped. - Empty candidate slice → no LLM call, no scores. - Transport / parse failure → warn-log, strategy order preserved. - Hallucinated IDs dropped; missing IDs surface unscored at the bottom. No candidate is ever lost. For /v1/answer the ReRanker's Usage is accumulated into totalUsage so the response's usage block reflects the real cost; the synthesis prompt then sees the post-rerank top-k. cmd/engine/main.go instantiates a ReRanker against the configured LLM whenever a client is wired (mirrors the planner pattern) — opt-in behaviour is purely flag-driven from there. Boot logs the model + content cap + top-k when re-rank is enabled at config level. openapi.yaml: enable_rerank field on QueryRequest + AnswerRequest; score field on QuerySection + AnswerCitation. Schema descriptions document the 0-100 scale and the "never drops sections" envelope. config.example.yaml: documented retrieval.rerank block with cost + opt-in commentary so operators understand why this is off by default.
1 parent a5c4d8b commit 478e578

4 files changed

Lines changed: 260 additions & 1 deletion

File tree

cmd/engine/main.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,28 @@ func run() error {
129129
}
130130
}
131131

132+
// ReRanker: opt-in Phase 2.3. Instantiated whenever an LLM client
133+
// is wired — the per-request `enable_rerank` body field overrides
134+
// the config, mirroring the planner pattern.
135+
var reRanker *retrieval.ReRanker
136+
if llmClient != nil {
137+
reRankModel := cfg.Retrieval.ReRank.Model
138+
if reRankModel == "" {
139+
reRankModel = modelFor(cfg.LLM)
140+
}
141+
reRanker = retrieval.NewReRanker(llmClient, reRankModel)
142+
if cfg.Retrieval.ReRank.MaxContentChars > 0 {
143+
reRanker.MaxContentChars = cfg.Retrieval.ReRank.MaxContentChars
144+
}
145+
if cfg.Retrieval.ReRank.Enabled {
146+
logger.Info("retrieval: rerank enabled",
147+
"model", reRankModel,
148+
"max_content_chars", reRanker.MaxContentChars,
149+
"top_k", cfg.Retrieval.ReRank.TopK,
150+
)
151+
}
152+
}
153+
132154
pipeline := ingest.NewPipeline(ingest.Pipeline{
133155
DB: pool,
134156
Storage: store,
@@ -157,6 +179,8 @@ func run() error {
157179
Answer: cfg.Retrieval.Answer,
158180
Planner: planner,
159181
Planning: cfg.Retrieval.Planning,
182+
ReRanker: reRanker,
183+
ReRank: cfg.Retrieval.ReRank,
160184
}
161185

162186
srv := &http.Server{

config.example.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,36 @@ retrieval:
152152
# isolation (plan returned, but retrieval uses the original query).
153153
decompose: true
154154

155+
# rerank: Phase 2.3 content-aware re-rank pass. After the retrieval
156+
# strategy returns candidate sections and their content is loaded,
157+
# one extra LLM call scores each section (0-100) against the query
158+
# and the engine reorders descending by score.
159+
#
160+
# This is the safety net for the case where the strategy reasoned
161+
# over title + summary + HyDE candidate questions and got fooled
162+
# by surface-level matches. Reading the actual content closes that
163+
# gap. ~3-5k input tokens per query on gemini-2.5-flash; ~$0.0003
164+
# per call at typical rates.
165+
#
166+
# OPT-IN. Default disabled. Per-request `enable_rerank` body field
167+
# overrides this block. Failures never drop sections — at worst the
168+
# strategy's order is preserved.
169+
rerank:
170+
enabled: false
171+
# Override the re-rank model; empty inherits the request's model
172+
# (or the engine default). Keep this on a small/fast model — the
173+
# re-rank prompt is short and shouldn't burn the flagship model.
174+
model: ""
175+
# Per-candidate content budget. Higher = more context for the
176+
# model to judge with, lower = tighter cost. 2000 chars ≈ 500
177+
# tokens, comfortable for typical section sizes.
178+
max_content_chars: 2000
179+
# Truncate the post-rerank candidate list to the top K. 0 means
180+
# keep all candidates (re-rank only reorders). Useful when the
181+
# strategy returns a wide candidate list and you want the
182+
# re-rank pass to do the final selection.
183+
top_k: 0
184+
155185
ingest:
156186
# The summarize and HyDE stages run concurrently. This caps the total
157187
# number of LLM calls in flight across both stages combined, so the

internal/api/server.go

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ type Deps struct {
7373
// `enable_planning` field on /v1/query and /v1/answer overrides
7474
// Planning.Enabled.
7575
Planning config.PlanningBlock
76+
77+
// ReRanker runs Phase 2.3 content-aware re-rank on the strategy's
78+
// candidate sections (one extra LLM call per query). Nil disables
79+
// re-rank even when a request opts in via `enable_rerank`.
80+
ReRanker *retrieval.ReRanker
81+
82+
// ReRank carries the server-side re-rank config. The body-level
83+
// `enable_rerank` field on /v1/query and /v1/answer overrides
84+
// ReRank.Enabled. TopK truncates the post-rerank candidate list.
85+
ReRank config.ReRankBlock
7686
}
7787

7888
// Router builds and returns the chi router wired with v1 routes.
@@ -400,6 +410,10 @@ func (d Deps) handleQuery(w http.ResponseWriter, r *http.Request) {
400410
// planner. A pointer so we can distinguish "absent" from
401411
// "explicit false" — absent falls back to the server config.
402412
EnablePlanning *bool `json:"enable_planning"`
413+
// EnableReRank opts this request into the Phase 2.3
414+
// content-aware re-rank pass. Pointer for the same reason as
415+
// EnablePlanning. Overrides retrieval.rerank.enabled.
416+
EnableReRank *bool `json:"enable_rerank"`
403417
}
404418
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
405419
writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
@@ -471,6 +485,15 @@ func (d Deps) handleQuery(w http.ResponseWriter, r *http.Request) {
471485
enriched = append(enriched, sectionWithContent{sec: sec, content: content})
472486
}
473487

488+
// Optional: content-aware re-rank pass. One LLM call that scores
489+
// each loaded section against the query and re-orders the slice
490+
// descending by score. TopK truncates the survivors. Failures
491+
// never drop sections — at worst the strategy's order is
492+
// preserved (see retrieval.ReRanker.ReRank).
493+
if d.reRankEnabled(body.EnableReRank) {
494+
enriched, _ = d.runReRank(r.Context(), enriched, body.Query, body.Model)
495+
}
496+
474497
// Optional: per-section answer-span extraction. Opt-in via config —
475498
// one LLM call per returned section. Failures are non-fatal; the
476499
// section is returned without a span.
@@ -499,11 +522,18 @@ func (d Deps) handleQuery(w http.ResponseWriter, r *http.Request) {
499522
}
500523

501524
// sectionWithContent bundles a tree section with its loaded content
502-
// and an optional answer-span. Used by /v1/query and /v1/answer.
525+
// and optional re-rank score / answer-span. Used by /v1/query and
526+
// /v1/answer.
503527
type sectionWithContent struct {
504528
sec *tree.Section
505529
content string
506530
span *retrieval.AnswerSpan
531+
532+
// hasScore reports whether score was populated by a re-rank pass.
533+
// Distinct from score == 0 since 0 is a legitimate score the
534+
// model can return.
535+
hasScore bool
536+
score float64
507537
}
508538

509539
// sectionWithContentToMap renders the section as the API map shape.
@@ -528,6 +558,9 @@ func sectionWithContentToMap(e sectionWithContent) map[string]any {
528558
if e.span != nil {
529559
s["answer_span"] = e.span
530560
}
561+
if e.hasScore {
562+
s["score"] = e.score
563+
}
531564
return s
532565
}
533566

@@ -620,6 +653,10 @@ func (d Deps) handleAnswer(w http.ResponseWriter, r *http.Request) {
620653
// EnablePlanning opts this request into the Phase 2.1 query
621654
// planner. See handleQuery for the same field's semantics.
622655
EnablePlanning *bool `json:"enable_planning"`
656+
// EnableReRank opts this request into the Phase 2.3 re-rank
657+
// pass. Synthesis then sees the re-ranked top-k. Overrides
658+
// retrieval.rerank.enabled.
659+
EnableReRank *bool `json:"enable_rerank"`
623660
}
624661
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
625662
writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
@@ -699,6 +736,16 @@ func (d Deps) handleAnswer(w http.ResponseWriter, r *http.Request) {
699736
enriched = append(enriched, sectionWithContent{sec: sec, content: content})
700737
}
701738

739+
// Optional: content-aware re-rank before synthesis sees the
740+
// evidence. When TopK is set the synthesis prompt only ever sees
741+
// the post-rerank top-k, keeping the answer focused on the
742+
// best-evidence sections.
743+
if d.reRankEnabled(body.EnableReRank) {
744+
var reRankUsage retrieval.Usage
745+
enriched, reRankUsage = d.runReRank(r.Context(), enriched, body.Query, body.Model)
746+
totalUsage.Add(reRankUsage)
747+
}
748+
702749
// Always extract spans for /v1/answer — they ground each citation.
703750
spanExtractor := d.spanExtractor(body.Model)
704751
runSpansConcurrent(r.Context(), spanExtractor, body.Query, enriched, d.AnswerSpan.MaxConcurrency, d.Logger)
@@ -747,6 +794,9 @@ func (d Deps) handleAnswer(w http.ResponseWriter, r *http.Request) {
747794
c["quote_end"] = e.span.End
748795
}
749796
}
797+
if e.hasScore {
798+
c["score"] = e.score
799+
}
750800
citations = append(citations, c)
751801
}
752802

@@ -1132,6 +1182,123 @@ func (d Deps) shouldDecompose(plan *retrieval.Plan) bool {
11321182
return d.Planning.Decompose
11331183
}
11341184

1185+
// --- re-rank helpers ---
1186+
1187+
// reRankEnabled reports whether the request should go through the
1188+
// re-rank pass. The per-request body field (when present) wins over
1189+
// the server-side config; a nil body field falls back to the config.
1190+
//
1191+
// Returns false when no LLM client is wired or when no ReRanker is
1192+
// configured, regardless of intent — re-rank without an LLM is
1193+
// physically impossible.
1194+
func (d Deps) reRankEnabled(bodyOverride *bool) bool {
1195+
if d.ReRanker == nil || d.LLM == nil {
1196+
return false
1197+
}
1198+
if bodyOverride != nil {
1199+
return *bodyOverride
1200+
}
1201+
return d.ReRank.Enabled
1202+
}
1203+
1204+
// runReRank executes the re-rank pass over the loaded section slice
1205+
// and returns the reordered slice plus the LLM Usage spent. On any
1206+
// failure the original slice is returned (with the same hasScore
1207+
// values it had on input — i.e. unchanged) so the caller never has
1208+
// to think about partial state. The error is LOGGED, not returned —
1209+
// re-rank is best-effort and a failure must never abort the request.
1210+
//
1211+
// requestModel is the model the request asked for. When the
1212+
// ReRanker has its own Model set (the config-level override), that
1213+
// wins; the request model is the fall-through.
1214+
func (d Deps) runReRank(ctx context.Context, enriched []sectionWithContent, query, requestModel string) ([]sectionWithContent, retrieval.Usage) {
1215+
if d.ReRanker == nil || d.LLM == nil || len(enriched) == 0 {
1216+
return enriched, retrieval.Usage{}
1217+
}
1218+
1219+
// Apply the model fall-through: config override → request model →
1220+
// engine default. We don't mutate d.ReRanker since Deps is shared
1221+
// across requests; instead build a shallow copy with the chosen
1222+
// model. This is the same pattern spanExtractor() uses.
1223+
ranker := *d.ReRanker
1224+
if ranker.Model == "" {
1225+
if requestModel != "" {
1226+
ranker.Model = requestModel
1227+
} else {
1228+
ranker.Model = d.LLMModel
1229+
}
1230+
}
1231+
1232+
candidates := make([]retrieval.SectionContent, len(enriched))
1233+
for i, e := range enriched {
1234+
candidates[i] = retrieval.SectionContent{
1235+
ID: e.sec.ID,
1236+
Title: e.sec.Title,
1237+
Content: e.content,
1238+
}
1239+
}
1240+
1241+
scored, usage, err := ranker.ReRank(ctx, query, candidates)
1242+
if err != nil {
1243+
if d.Logger != nil {
1244+
d.Logger.Warn("rerank: failed; preserving strategy order", "err", err)
1245+
}
1246+
// ReRank returns input order on error so we *could* apply it
1247+
// (it'd just stamp score=0 on everything). Skip — the caller
1248+
// shouldn't see score=0 on every section when re-rank
1249+
// physically failed.
1250+
return enriched, usage
1251+
}
1252+
if len(scored) == 0 {
1253+
return enriched, usage
1254+
}
1255+
1256+
reordered := reorderByScore(enriched, scored)
1257+
if d.ReRank.TopK > 0 && len(reordered) > d.ReRank.TopK {
1258+
reordered = reordered[:d.ReRank.TopK]
1259+
}
1260+
return reordered, usage
1261+
}
1262+
1263+
// reorderByScore takes the loaded section slice and the model's
1264+
// scored output (already sorted descending by score by the
1265+
// ReRanker), and returns a new slice in the same order as scored
1266+
// with each entry carrying the per-section score.
1267+
//
1268+
// Defensive: every input enriched section appears in the output
1269+
// exactly once, in the order dictated by scored. If scored is
1270+
// missing an input ID (shouldn't happen — ReRank's contract is to
1271+
// surface every input ID), that section is appended at the end with
1272+
// hasScore=false so the response stays complete.
1273+
func reorderByScore(enriched []sectionWithContent, scored []retrieval.ScoredSection) []sectionWithContent {
1274+
byID := make(map[tree.SectionID]int, len(enriched))
1275+
for i, e := range enriched {
1276+
byID[e.sec.ID] = i
1277+
}
1278+
1279+
out := make([]sectionWithContent, 0, len(enriched))
1280+
taken := make([]bool, len(enriched))
1281+
for _, s := range scored {
1282+
idx, ok := byID[s.ID]
1283+
if !ok || taken[idx] {
1284+
continue
1285+
}
1286+
taken[idx] = true
1287+
e := enriched[idx]
1288+
e.hasScore = true
1289+
e.score = s.Score
1290+
out = append(out, e)
1291+
}
1292+
// Append anything ReRank didn't surface — invariant says this
1293+
// should be empty, but a defence-in-depth check costs nothing.
1294+
for i, e := range enriched {
1295+
if !taken[i] {
1296+
out = append(out, e)
1297+
}
1298+
}
1299+
return out
1300+
}
1301+
11351302
// writePlanHints appends a short, model-readable "Planner notes" block
11361303
// describing the structured plan. Synthesis uses this to orient itself
11371304
// before reading the evidence.

openapi.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,19 @@ components:
462462
retrieval fans out one selection call per sub-question
463463
and unions the results. Overrides the server's
464464
`retrieval.planning.enabled` setting for this request only.
465+
enable_rerank:
466+
type: boolean
467+
description: |
468+
Opt this request into the Phase 2.3 content-aware re-rank
469+
pass. After the retrieval strategy returns candidate
470+
section IDs and the engine loads their content, one extra
471+
LLM call scores each section (0-100) against the query and
472+
sections are reordered descending by score. When
473+
`retrieval.rerank.top_k` is set on the server, only the
474+
top-k sections survive. Failures preserve the strategy's
475+
original order — re-rank never drops sections. Overrides
476+
the server's `retrieval.rerank.enabled` setting for this
477+
request only.
465478
466479
QueryResponse:
467480
type: object
@@ -513,6 +526,14 @@ components:
513526
description: Full section content from storage.
514527
answer_span:
515528
$ref: "#/components/schemas/AnswerSpan"
529+
score:
530+
type: number
531+
description: |
532+
Re-rank relevance score on a 0-100 scale, populated only
533+
when the request opted into the Phase 2.3 re-rank pass
534+
(`enable_rerank`) or the server has
535+
`retrieval.rerank.enabled=true`. Sections are returned
536+
sorted descending by score. Omitted when no re-rank ran.
516537
517538
AnswerSpan:
518539
type: object
@@ -560,6 +581,14 @@ components:
560581
full semantics. When enabled, the synthesis prompt also sees
561582
the planner's structured intent and entity hints, and the
562583
response carries a top-level `plan` field.
584+
enable_rerank:
585+
type: boolean
586+
description: |
587+
Opt this request into the Phase 2.3 content-aware re-rank
588+
pass. See QueryRequest.enable_rerank for full semantics.
589+
When the pass runs, the synthesis prompt sees the
590+
re-ranked top-k (capped by `retrieval.rerank.top_k`), and
591+
each citation in the response carries a `score` field.
563592
564593
AnswerResponse:
565594
type: object
@@ -647,6 +676,9 @@ components:
647676
found one). `quote_start`/`quote_end` give byte offsets into
648677
the source section's content. `page_start`/`page_end` are the
649678
section's page range — omitted for non-paginated formats.
679+
`score` carries the re-rank relevance score on a 0-100 scale,
680+
present only when the request opted into the Phase 2.3
681+
re-rank pass.
650682
properties:
651683
section_id:
652684
type: string
@@ -662,3 +694,9 @@ components:
662694
type: integer
663695
page_end:
664696
type: integer
697+
score:
698+
type: number
699+
description: |
700+
Re-rank relevance score on a 0-100 scale. Omitted when no
701+
re-rank ran. Higher means the section is more directly
702+
relevant to the query.

0 commit comments

Comments
 (0)