@@ -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.
503527type 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.
0 commit comments