-
Notifications
You must be signed in to change notification settings - Fork 47
Expand file tree
/
Copy pathreranker.py
More file actions
443 lines (353 loc) · 14.8 KB
/
Copy pathreranker.py
File metadata and controls
443 lines (353 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
"""
TrueMemory Cross-Encoder Reranker
===============================
Reranks retrieval results using a cross-encoder model that jointly encodes
(query, document) pairs for more accurate relevance scoring than embedding-
based similarity alone.
The reranker model is resolved per tier via ``get_reranker_name_for_tier``:
Edge → ``cross-encoder/ms-marco-MiniLM-L-6-v2`` (22M, CPU-friendly, paper
§2.0 Edge reranker), Base / Pro → ``Alibaba-NLP/gte-reranker-modernbert-base``
(149M, GPU recommended). Callers with explicit needs can override by passing
``model_name=...`` to ``get_reranker``. Can optionally use GPU if available.
Usage::
from truememory.reranker import rerank
results = search_hybrid(conn, query, limit=50)
reranked = rerank(query, results, top_k=10)
Dependencies:
- sentence-transformers (``pip install sentence-transformers``)
"""
from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING
from truememory import config
log = logging.getLogger(__name__)
if TYPE_CHECKING:
pass
# ---------------------------------------------------------------------------
# Singleton model loader
# ---------------------------------------------------------------------------
_model = None
_model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
_lock = threading.Lock()
_inference_lock = threading.Lock() # Protects concurrent model.predict() calls
# v0.4.0 paper §2.0 models:
# Edge uses the lightweight MiniLM cross-encoder (22M params, CPU-friendly).
# Base and Pro use gte-reranker-modernbert-base (149M, GPU recommended) —
# required to reach the 91.5% / 91.8% LoCoMo targets for those tiers.
#
# The active tier is cached in _active_tier. It's seeded lazily on first
# get_current_reranker_name() call (from TRUEMEMORY_EMBED_MODEL env var or
# ~/.truememory/config.json), and can be updated at runtime via
# set_active_tier() — the MCP server calls this on truememory_configure.
_active_tier: str | None = None # None = not yet resolved; resolved lazily
def get_reranker_name_for_tier(tier: str) -> str:
"""Pure mapping from tier name ("edge" / "base" / "pro" / "custom") to reranker HF ID.
Case-insensitive. Unknown or empty tier names fall back to the Edge
default (MiniLM). Does not load any model — use ``get_reranker`` for that.
"""
return config.get_tier_config(tier)["reranker"]
def _resolve_tier_from_env_and_config() -> str:
"""Read the active tier from TRUEMEMORY_EMBED_MODEL env var, then from
~/.truememory/config.json. Safe on missing / malformed config / any OS
error — returns "edge" as the ultimate fallback.
This is called at most once per process (cached in _active_tier) unless
set_active_tier() is called explicitly.
"""
import os
env = os.environ.get("TRUEMEMORY_EMBED_MODEL", "").strip().lower()
if env in ("edge", "base", "pro", "custom"):
return env
try:
from pathlib import Path
import json
cfg_path = Path.home() / ".truememory" / "config.json"
if cfg_path.exists():
data = json.loads(cfg_path.read_text())
tier = (data.get("tier") or "").strip().lower()
if tier in ("edge", "base", "pro", "custom"):
return tier
except (json.JSONDecodeError, OSError) as e:
# Hunter F04 duplicate: previously a bare `except Exception: pass`
# that hid corrupt config.json exactly like mcp_server._load_config
# did. Log a warning so the user knows the fallback fired — the MCP
# server's _load_config (called earlier in the startup sequence)
# handles the corrupt-file rename.
import sys as _sys
print(
f"truememory: could not read tier from config.json ({type(e).__name__}: "
f"{e}); falling back to Edge. Run `truememory-mcp --setup` to fix.",
file=_sys.stderr,
)
return "edge"
def set_active_tier(tier: str) -> None:
"""Update the cached active tier.
Called by the MCP server's ``truememory_configure`` when the user changes
tier at runtime so subsequent ``get_reranker(model_name=None)`` calls
resolve to the new tier's reranker. Empty / unknown tier falls back
to "edge".
"""
global _active_tier
if not tier:
_active_tier = "edge"
return
t = tier.strip().lower()
_active_tier = t if t in ("edge", "base", "pro", "custom") else "edge"
def get_current_reranker_name() -> str:
"""Return the reranker HF model ID for the currently-active tier.
Resolves lazily on first call via _resolve_tier_from_env_and_config;
subsequent calls use the cached _active_tier until set_active_tier()
is called.
"""
global _active_tier
if _active_tier is None:
_active_tier = _resolve_tier_from_env_and_config()
return get_reranker_name_for_tier(_active_tier)
def get_reranker(model_name: str | None = None, device: str | None = None):
"""
Lazy-load the cross-encoder reranker (singleton).
Args:
model_name: HuggingFace model ID. If None (the default), resolves via
``get_current_reranker_name()`` to the tier-correct model
(Edge → MiniLM, Base/Pro → gte-reranker-modernbert-base).
Pass an explicit name for overrides (bench scripts, custom
rerankers).
device: Device string (``"cpu"``, ``"cuda:0"``, etc.).
If None, auto-detects.
Returns:
A ``sentence_transformers.CrossEncoder`` instance.
"""
global _model, _model_name
name = model_name or get_current_reranker_name()
if _model is not None and name == _model_name:
return _model # Fast path, no lock needed
with _lock:
if _model is not None and name == _model_name:
return _model # Another thread loaded it
from sentence_transformers import CrossEncoder
if device is None:
try:
import torch
device = "cuda:0" if torch.cuda.is_available() else "cpu"
except ImportError:
device = "cpu"
_model = CrossEncoder(name, device=device)
_model_name = name
return _model
# ---------------------------------------------------------------------------
# Reranking
# ---------------------------------------------------------------------------
def _normalize_and_fuse(
reranked: list[dict],
rerank_weight: float,
rrf_weight: float,
top_k: int,
) -> list[dict]:
"""Normalize rerank + original scores to [0,1] and fuse."""
if not reranked:
return []
rerank_scores = [r["rerank_score"] for r in reranked]
rr_min, rr_max = min(rerank_scores), max(rerank_scores)
rr_range = rr_max - rr_min if rr_max > rr_min else 1.0
orig_scores = [r.get("score", r.get("rrf_score", 0)) for r in reranked]
orig_min, orig_max = min(orig_scores), max(orig_scores)
orig_range = orig_max - orig_min if orig_max > orig_min else 1.0
for r in reranked:
norm_rerank = (r["rerank_score"] - rr_min) / rr_range
norm_orig = (r.get("score", r.get("rrf_score", 0)) - orig_min) / orig_range
r["fused_score"] = rerank_weight * norm_rerank + rrf_weight * norm_orig
r["score"] = r["fused_score"]
reranked.sort(key=lambda r: r["fused_score"], reverse=True)
return reranked[:top_k]
def rerank(
query: str,
results: list[dict],
top_k: int = 10,
model_name: str | None = None,
device: str | None = None,
batch_size: int = 64,
) -> list[dict]:
"""
Rerank a list of retrieval results using a cross-encoder.
The cross-encoder scores each (query, document.content) pair and returns
the top *top_k* results sorted by cross-encoder score descending.
Args:
query: The search query.
results: List of result dicts (must have ``"content"`` key).
top_k: Number of results to return after reranking.
model_name: Optional override for the cross-encoder model.
device: Optional device string.
batch_size: Batch size for prediction.
Returns:
Top *top_k* results sorted by cross-encoder score, each with an added
``"rerank_score"`` key.
"""
if not results:
return []
if len(results) <= 1:
return results[:top_k]
model = get_reranker(model_name=model_name, device=device)
# Build (query, content) pairs
pairs = [(query, r.get("content", "")) for r in results]
# Score all pairs (locked for thread safety with parallel queries)
with _inference_lock:
scores = model.predict(pairs, batch_size=batch_size, show_progress_bar=False)
# Attach scores and sort
scored = []
for r, score in zip(results, scores):
entry = dict(r) # shallow copy
entry["rerank_score"] = float(score)
scored.append(entry)
scored.sort(key=lambda r: r["rerank_score"], reverse=True)
return scored[:top_k]
def rerank_with_fusion(
query: str,
results: list[dict],
top_k: int = 10,
rrf_weight: float = 0.3,
rerank_weight: float = 0.7,
**kwargs,
) -> list[dict]:
"""
Rerank results and fuse cross-encoder scores with original RRF scores.
This blends the original retrieval ranking with the cross-encoder's
assessment, preventing the reranker from completely overriding useful
signals from keyword/vector search.
Args:
query: The search query.
results: List of result dicts.
top_k: Number of results to return.
rrf_weight: Weight for original RRF/retrieval score.
rerank_weight: Weight for cross-encoder score.
Returns:
Top *top_k* results sorted by fused score.
"""
if not results:
return []
reranked = rerank(query, results, top_k=len(results), **kwargs)
return _normalize_and_fuse(reranked, rerank_weight, rrf_weight, top_k)
# ---------------------------------------------------------------------------
# Modality-aware reranking
# ---------------------------------------------------------------------------
def rerank_with_modality_fusion(
query: str,
results: list[dict],
top_k: int = 10,
rrf_weight: float = 0.3,
rerank_weight: float = 0.7,
**kwargs,
) -> list[dict]:
"""
Rerank with cross-encoder, then apply modality-aware score adjustments.
Episodes and facts are summaries; their scores are adjusted based on
question type:
- Detail questions (specific names, dates, numbers) → prefer raw messages
- Synthesis questions (explain, describe, why) → boost episodes/facts
- General questions → no modality adjustment
"""
if not results:
return []
reranked = rerank(query, results, top_k=len(results), **kwargs)
question_type = _classify_question_type(query)
for r in reranked:
modality = r.get("modality", "conversation")
if question_type == "detail":
if modality in ("episode", "fact"):
r["rerank_score"] = r["rerank_score"] * 0.7
elif question_type == "synthesis":
if modality in ("episode", "fact"):
r["rerank_score"] = r["rerank_score"] * 1.2
return _normalize_and_fuse(reranked, rerank_weight, rrf_weight, top_k)
def _classify_question_type(query: str) -> str:
"""
Classify question as 'detail', 'synthesis', or 'general'.
Detail: specific facts, names, dates, numbers
Synthesis: explanations, summaries, reasoning
"""
import re
q = query.lower().strip()
detail_patterns = [
r"\bwhat date\b", r"\bwhat time\b", r"\bwhat is the name\b",
r"\bhow many\b", r"\bhow much\b", r"\bwhat number\b",
r"\bwhat.*address\b", r"\bwhat.*phone\b", r"\bwhat.*email\b",
r"\bwhen did\b", r"\bwhen was\b", r"\bwhen is\b",
r"\bwhere did\b", r"\bwhere was\b", r"\bwhere is\b",
r"\bwho is\b", r"\bwho was\b", r"\bwho did\b",
]
synthesis_patterns = [
r"^explain\b", r"^describe\b", r"^summarize\b",
r"\bwhat kind of\b", r"\bwhat type of\b",
r"^why\b", r"\bhow does\b", r"\bhow do\b",
r"\bwhat fields\b", r"\bwhat activities\b",
r"\brelationship\b", r"\blikely\b",
]
if any(re.search(p, q) for p in detail_patterns):
return "detail"
if any(re.search(p, q) for p in synthesis_patterns):
return "synthesis"
return "general"
# ---------------------------------------------------------------------------
# LLM-based reranking
# ---------------------------------------------------------------------------
_RERANK_PROMPT = """Given the question below, rate each document's relevance from 0-10.
0 = completely irrelevant, 10 = directly answers the question or contains key evidence.
Question: {query}
Documents:
{documents}
For each document, output ONLY a line like "D1: 8" (document number: score).
Output ALL {n} scores, one per line:"""
def rerank_with_llm(
query: str,
results: list[dict],
llm_fn,
top_k: int = 15,
) -> list[dict]:
"""
Rerank results using an LLM judge for relevance scoring.
Much more accurate than cross-encoder models for conversational
content because the LLM understands context, paraphrasing, and
can reason about relevance.
Args:
query: The search query.
results: Candidate results to rerank.
llm_fn: Callable that takes a prompt and returns text.
top_k: Number of results to return.
Returns:
Top *top_k* results sorted by LLM-assigned relevance score.
"""
if not results or len(results) <= top_k:
return results[:top_k]
# Build document list for prompt (truncate long content)
doc_lines = []
for i, r in enumerate(results):
content = r.get("content", "")[:200]
_sender = r.get("sender", "")
doc_lines.append(f"D{i+1}: {content}")
documents = "\n".join(doc_lines)
prompt = _RERANK_PROMPT.format(
query=query, documents=documents, n=len(results),
)
try:
response = llm_fn(prompt)
# Parse scores from "D1: 8" format
import re
scores = {}
for line in response.strip().split("\n"):
m = re.match(r'D(\d+)\s*:\s*(\d+)', line.strip())
if m:
idx = int(m.group(1)) - 1 # 0-based
score = int(m.group(2))
if 0 <= idx < len(results):
scores[idx] = score
# Assign scores to results
scored = []
for i, r in enumerate(results):
entry = dict(r)
entry["llm_rerank_score"] = scores.get(i, 0)
entry["score"] = scores.get(i, 0)
scored.append(entry)
scored.sort(key=lambda r: (-r["llm_rerank_score"], -r.get("rrf_score", 0)))
return scored[:top_k]
except Exception as e:
log.warning("LLM reranking failed: %s — returning original order", e)
return results[:top_k]