Skip to content

Commit 71c9b03

Browse files
feat(bench): add support for reranking and multi-query retrieval
- Introduce Reranker and MultiQuery options in bench.Config - Implement LLMReranker for retrieving and scoring candidates - Enhance Search method to utilize reranking and multi-query features - Update README.md to document new retrieval boosters: rerank and multi-query - Add tests for the new reranking feature and multi-query expansions
1 parent 9326737 commit 71c9b03

11 files changed

Lines changed: 950 additions & 9 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,36 @@ extra LLM call (only when there are facts in scope to reconcile against) plus a
140140
cheap per-candidate retrieval, and a malformed/failed consolidation response
141141
safely falls back to an additive insert. See `PLAN-v2.md` §4.2.
142142

143+
### Retrieval boosters: rerank + multi-query
144+
145+
Brute-force cosine over a single query phrasing leaves recall on the table: the
146+
fact that answers a question often exists but sits just past the top-k cutoff
147+
(retrieval dilution). Two opt-in `Search` boosters recover it, both leaving the
148+
public `Search` signature unchanged:
149+
150+
```go
151+
m, _ := mneme.New(
152+
mneme.WithReranker(&openai.LLMReranker{LLM: llm}), // over-retrieve, reorder, keep top-k
153+
mneme.WithMultiQuery(3), // expand into 3 phrasings, union the hits
154+
)
155+
```
156+
157+
- **Rerank** (`WithReranker`): `Search` retrieves a wider pool (`DefaultRerankPoolN`,
158+
~20) instead of just `k`, hands it to the `Reranker` to reorder by relevance,
159+
then keeps the leading `k`. The shipped `openai.LLMReranker` scores each
160+
candidate with one LLM call and is parse-defensive — a malformed response
161+
leaves the cosine order untouched. The `Reranker` interface is tiny, so a
162+
cross-encoder or hosted rerank API can drop in later.
163+
- **Multi-query** (`WithMultiQuery(n)`): one LLM call rewrites the query into up
164+
to `n` diverse phrasings; their hits are unioned (deduped by id, best score
165+
wins) before reranking. Targets multi-hop questions where one phrasing won't
166+
surface every needed fact. Expansion is best-effort: if the call or its parse
167+
fails, `Search` falls back to the original query alone.
168+
169+
Each booster adds an LLM call per `Search` — opt in when retrieval accuracy is
170+
worth the cost. The `bench` harness exposes both as `-rerank` and `-multiquery n`.
171+
See `PLAN-v2.md` §4.3.
172+
143173
## Eval harness
144174

145175
The extraction prompt is versioned (`extractionPromptV1`, …) and scored by the

bench/run.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ type Config struct {
5050
// harness A/B prompt versions (e.g. v1 vs the conservative v2).
5151
ConsolidationVersion string
5252

53+
// Reranker, when set, enables a rerank pass in Search (PLAN-v2.md §4.3): the
54+
// harness over-retrieves and reorders candidates before truncating to K. Nil
55+
// leaves Search at plain top-k cosine.
56+
Reranker mneme.Reranker
57+
58+
// MultiQuery, when > 1, makes Search expand each question into that many
59+
// phrasings and union the hits before reranking (PLAN-v2.md §4.3).
60+
MultiQuery int
61+
5362
// Progress, if set, is called after each sample is fully scored. Lets the
5463
// runner print live progress without the library writing to stdout.
5564
Progress func(doneSamples, totalSamples int)
@@ -93,12 +102,24 @@ func Run(ctx context.Context, samples []Sample, cfg Config) (Report, error) {
93102
if cfg.ConsolidationVersion != "" {
94103
opts = append(opts, mneme.WithConsolidationVersion(cfg.ConsolidationVersion))
95104
}
105+
if cfg.Reranker != nil {
106+
opts = append(opts, mneme.WithReranker(cfg.Reranker))
107+
}
108+
if cfg.MultiQuery > 1 {
109+
opts = append(opts, mneme.WithMultiQuery(cfg.MultiQuery))
110+
}
96111
mem, err := mneme.New(opts...)
97112
if err != nil {
98113
return Report{}, fmt.Errorf("build memory: %w", err)
99114
}
100115

101-
report := Report{K: cfg.K, Strategy: cfg.Strategy, ConsolidationVersion: cfg.ConsolidationVersion}
116+
report := Report{
117+
K: cfg.K,
118+
Strategy: cfg.Strategy,
119+
ConsolidationVersion: cfg.ConsolidationVersion,
120+
Rerank: cfg.Reranker != nil,
121+
MultiQuery: cfg.MultiQuery,
122+
}
102123
for i, s := range samples {
103124
results, err := runSample(ctx, mem, answerLLM, judge, cfg, s)
104125
if err != nil {

bench/score.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ type Report struct {
3737
// ConsolidationVersion records which consolidation prompt version was used
3838
// (empty for additive runs or the library default), for run provenance.
3939
ConsolidationVersion string
40+
// Rerank records whether a rerank pass was active in Search; MultiQuery
41+
// records the query-expansion fan-out (0/1 = off), both for run provenance.
42+
Rerank bool
43+
MultiQuery int
4044
}
4145

4246
// CategoryStat aggregates one category (or the whole run) into mean metrics.

cmd/bench/main.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"strings"
2424
"time"
2525

26+
"github.com/AccursedGalaxy/mneme"
2627
"github.com/AccursedGalaxy/mneme/bench"
2728
"github.com/AccursedGalaxy/mneme/provider"
2829
"github.com/AccursedGalaxy/mneme/provider/fake"
@@ -49,6 +50,8 @@ func run() error {
4950
k = flag.Int("k", 5, "search top-k facts fed to the answer model")
5051
strategy = flag.String("strategy", bench.StrategyAdditive, "write strategy: additive | consolidate")
5152
cprompt = flag.String("cprompt", "", "consolidation prompt version (e.g. v1 | v2); empty = library default. Only used with -strategy consolidate")
53+
rerank = flag.Bool("rerank", false, "enable the LLM rerank pass in Search (over-retrieve, reorder, truncate to k)")
54+
multiQ = flag.Int("multiquery", 0, "if >1, expand each question into this many search phrasings and union the hits before reranking")
5255
limit = flag.Int("limit", 0, "if >0, run only the first N samples (smoke test)")
5356
maxQ = flag.Int("maxq", 0, "if >0, cap questions per sample (smoke/cost control)")
5457
out = flag.String("out", "bench/RESULTS.md", "results file to write (markdown); empty to skip")
@@ -113,13 +116,20 @@ func run() error {
113116
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
114117
defer cancel()
115118

119+
var reranker mneme.Reranker
120+
if *rerank {
121+
reranker = &openai.LLMReranker{LLM: llm}
122+
}
123+
116124
report, err := bench.Run(ctx, samples, bench.Config{
117125
LLM: llm,
118126
Embedder: embedder,
119127
Store: st,
120128
K: *k,
121129
Strategy: *strategy,
122130
ConsolidationVersion: *cprompt,
131+
Reranker: reranker,
132+
MultiQuery: *multiQ,
123133
Progress: func(done, total int) {
124134
fmt.Fprintf(os.Stderr, "\r scored %d/%d samples", done, total)
125135
if done == total {
@@ -144,6 +154,8 @@ func run() error {
144154
maxQ: *maxQ,
145155
samples: len(samples),
146156
cprompt: *cprompt,
157+
rerank: *rerank,
158+
multiQ: *multiQ,
147159
})
148160
fmt.Print(md)
149161

@@ -211,6 +223,8 @@ type runMeta struct {
211223
dataset, path, model, embedder string
212224
limit, maxQ, samples int
213225
cprompt string // consolidation prompt version, if set
226+
rerank bool // rerank pass active in Search
227+
multiQ int // multi-query fan-out (0/1 = off)
214228
}
215229

216230
// render builds the human + markdown report: a per-category table (the lever
@@ -225,6 +239,12 @@ func render(report bench.Report, m runMeta) string {
225239
if m.cprompt != "" {
226240
fmt.Fprintf(&b, " · cprompt: `%s`", m.cprompt)
227241
}
242+
if m.rerank {
243+
fmt.Fprintf(&b, " · rerank: `on`")
244+
}
245+
if m.multiQ > 1 {
246+
fmt.Fprintf(&b, " · multiquery: `%d`", m.multiQ)
247+
}
228248
b.WriteString("\n\n")
229249
fmt.Fprintf(&b, "Metrics: **EM** = normalized exact match, **F1** = token-overlap F1, **Judge** = LLM semantic match. n = question count.\n\n")
230250

@@ -255,6 +275,12 @@ func render(report bench.Report, m runMeta) string {
255275
if m.cprompt != "" {
256276
fmt.Fprintf(&b, " -cprompt %s", m.cprompt)
257277
}
278+
if m.rerank {
279+
fmt.Fprintf(&b, " -rerank")
280+
}
281+
if m.multiQ > 1 {
282+
fmt.Fprintf(&b, " -multiquery %d", m.multiQ)
283+
}
258284
if m.limit > 0 {
259285
fmt.Fprintf(&b, " -limit %d", m.limit)
260286
}

memory.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ type memory struct {
8484
consolidationVersion string
8585
clock func() time.Time
8686
allowEmbedderMismatch bool
87+
reranker Reranker
88+
multiQueryN int
8789
}
8890

8991
var _ Memory = (*memory)(nil)

parse.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,49 @@ func cleanOps(ops []consolidationOp) []consolidationOp {
129129
return out
130130
}
131131

132+
// queryEnvelope is the documented multi-query expansion response shape:
133+
// {"queries":["...","..."]}.
134+
type queryEnvelope struct {
135+
Queries []string `json:"queries"`
136+
}
137+
138+
// parseQueries pulls the expansion list out of a raw multi-query response
139+
// defensively, mirroring parseExtraction (fences, embedded object, bare array).
140+
// On any unrecoverable failure it returns nil, so Search falls back to the
141+
// original query alone rather than erroring.
142+
func parseQueries(raw string) []string {
143+
s := stripFences(raw)
144+
145+
var env queryEnvelope
146+
if err := json.Unmarshal([]byte(s), &env); err == nil && env.Queries != nil {
147+
return cleanQueries(env.Queries)
148+
}
149+
if obj := firstBalanced(s, '{', '}'); obj != "" {
150+
if err := json.Unmarshal([]byte(obj), &env); err == nil && env.Queries != nil {
151+
return cleanQueries(env.Queries)
152+
}
153+
}
154+
if arr := firstBalanced(s, '[', ']'); arr != "" {
155+
var qs []string
156+
if err := json.Unmarshal([]byte(arr), &qs); err == nil {
157+
return cleanQueries(qs)
158+
}
159+
}
160+
return nil
161+
}
162+
163+
// cleanQueries trims each query and drops empties, so a sloppy model response
164+
// does not produce blank search phrasings.
165+
func cleanQueries(qs []string) []string {
166+
out := make([]string, 0, len(qs))
167+
for _, q := range qs {
168+
if q = strings.TrimSpace(q); q != "" {
169+
out = append(out, q)
170+
}
171+
}
172+
return out
173+
}
174+
132175
// clean drops items with empty text and trims surrounding whitespace, so a
133176
// model that emits {"text":""} or stray spaces does not create junk facts.
134177
func clean(facts []extractedFact) []extractedFact {

pipeline.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,21 +278,37 @@ func (m *memory) consolidate(ctx context.Context, scope Scope, existing []labele
278278

279279
// Search embeds the query and returns the top-k facts in scope by cosine
280280
// similarity, Score populated, highest first.
281+
//
282+
// With a reranker (WithReranker) or multi-query (WithMultiQuery) configured it
283+
// over-retrieves a wider pool (DefaultRerankPoolN per phrasing), unions and
284+
// reorders it, then keeps the leading k — recovering answer facts that raw
285+
// cosine dilutes past the cutoff. The public signature is unchanged; with no
286+
// booster the result is identical to plain top-k cosine.
281287
func (m *memory) Search(ctx context.Context, query string, scope Scope, k int) ([]Fact, error) {
282288
if strings.TrimSpace(query) == "" || k <= 0 {
283289
return nil, nil
284290
}
285-
qvec, err := m.embedOne(ctx, query)
286-
if err != nil {
287-
return nil, fmt.Errorf("embed query: %w", err)
291+
292+
queries := m.searchQueries(ctx, query)
293+
poolN := k
294+
if m.reranker != nil || len(queries) > 1 {
295+
poolN = rerankPoolN(k)
288296
}
289-
hits, err := m.store.Search(ctx, scope, qvec, k)
297+
298+
facts, err := m.gatherCandidates(ctx, queries, scope, poolN)
290299
if err != nil {
291-
return nil, fmt.Errorf("search: %w", err)
300+
return nil, err
292301
}
293-
facts := make([]Fact, len(hits))
294-
for i, h := range hits {
295-
facts[i] = recordToFact(h.Record, h.Score)
302+
303+
if m.reranker != nil && len(facts) > 1 {
304+
facts, err = m.reranker.Rerank(ctx, query, facts)
305+
if err != nil {
306+
return nil, fmt.Errorf("rerank: %w", err)
307+
}
308+
}
309+
310+
if len(facts) > k {
311+
facts = facts[:k]
296312
}
297313
return facts, nil
298314
}

0 commit comments

Comments
 (0)