Skip to content

Commit 55dd9c1

Browse files
authored
Merge pull request #18 from hallelx2/feat/rerank-with-content
feat: content-aware re-rank pass (Phase 2.3)
2 parents 54ae0d5 + 478e578 commit 55dd9c1

8 files changed

Lines changed: 1388 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)