@totalreclaw/totalreclaw 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/publish.yml +39 -0
- package/README.md +104 -0
- package/SKILL.md +687 -0
- package/api-client.ts +300 -0
- package/crypto.ts +351 -0
- package/embedding.ts +84 -0
- package/extractor.ts +210 -0
- package/generate-mnemonic.ts +14 -0
- package/hot-cache-wrapper.ts +126 -0
- package/index.ts +1885 -0
- package/llm-client.ts +418 -0
- package/lsh.test.ts +463 -0
- package/lsh.ts +257 -0
- package/package.json +40 -0
- package/porter-stemmer.d.ts +4 -0
- package/reranker.test.ts +594 -0
- package/reranker.ts +537 -0
- package/semantic-dedup.test.ts +392 -0
- package/semantic-dedup.ts +100 -0
- package/subgraph-search.ts +278 -0
- package/subgraph-store.ts +342 -0
package/reranker.ts
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TotalReclaw Plugin - Client-Side Re-Ranker
|
|
3
|
+
*
|
|
4
|
+
* Replaces the naive `textScore` word-overlap scorer with a proper ranking
|
|
5
|
+
* pipeline:
|
|
6
|
+
* 1. Okapi BM25 — term frequency / inverse document frequency
|
|
7
|
+
* 2. Cosine similarity — between query and fact embeddings
|
|
8
|
+
* 3. Importance — normalized importance score (0-1)
|
|
9
|
+
* 4. Recency — time-decay with 1-week half-life
|
|
10
|
+
* 5. Weighted RRF (Reciprocal Rank Fusion) — combines all ranking lists
|
|
11
|
+
* 6. MMR (Maximal Marginal Relevance) — promotes diversity in results
|
|
12
|
+
*
|
|
13
|
+
* All functions are pure TypeScript with zero external dependencies (except
|
|
14
|
+
* porter-stemmer for morphological normalization). This module runs
|
|
15
|
+
* CLIENT-SIDE after decrypting candidates from the server.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { stemmer } from 'porter-stemmer';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Tokenization
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Tokenize a text string for BM25 scoring.
|
|
26
|
+
*
|
|
27
|
+
* Matches the tokenization rules used for blind indices in crypto.ts:
|
|
28
|
+
* 1. Lowercase
|
|
29
|
+
* 2. Remove punctuation (keep Unicode letters, numbers, whitespace)
|
|
30
|
+
* 3. Split on whitespace
|
|
31
|
+
* 4. Filter tokens shorter than 2 characters
|
|
32
|
+
*
|
|
33
|
+
* Optionally removes common English stop words (enabled by default) to
|
|
34
|
+
* improve BM25 signal — stop words have low IDF and add noise.
|
|
35
|
+
*/
|
|
36
|
+
const STOP_WORDS = new Set([
|
|
37
|
+
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'do', 'for',
|
|
38
|
+
'from', 'had', 'has', 'have', 'he', 'her', 'him', 'his', 'how', 'if',
|
|
39
|
+
'in', 'into', 'is', 'it', 'its', 'me', 'my', 'no', 'not', 'of', 'on',
|
|
40
|
+
'or', 'our', 'out', 'she', 'so', 'than', 'that', 'the', 'their', 'them',
|
|
41
|
+
'then', 'there', 'these', 'they', 'this', 'to', 'up', 'us', 'was', 'we',
|
|
42
|
+
'were', 'what', 'when', 'where', 'which', 'who', 'whom', 'why', 'will',
|
|
43
|
+
'with', 'you', 'your',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export function tokenize(text: string, removeStopWords: boolean = true): string[] {
|
|
47
|
+
let tokens = text
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
50
|
+
.split(/\s+/)
|
|
51
|
+
.filter((t) => t.length >= 2);
|
|
52
|
+
|
|
53
|
+
if (removeStopWords) {
|
|
54
|
+
tokens = tokens.filter((t) => !STOP_WORDS.has(t));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Stem each token for morphological normalization.
|
|
58
|
+
// This ensures BM25 matches "gaming" with "games" (both stem to "game").
|
|
59
|
+
return tokens.map((t) => stemmer(t));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// BM25 Scoring (Okapi BM25)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute the Okapi BM25 score for a single document against a query.
|
|
68
|
+
*
|
|
69
|
+
* Formula:
|
|
70
|
+
* score = SUM_i IDF(qi) * (f(qi, D) * (k1 + 1)) / (f(qi, D) + k1 * (1 - b + b * |D| / avgdl))
|
|
71
|
+
*
|
|
72
|
+
* where:
|
|
73
|
+
* IDF(qi) = ln((N - n(qi) + 0.5) / (n(qi) + 0.5) + 1)
|
|
74
|
+
* f(qi, D) = frequency of term qi in document D
|
|
75
|
+
* |D| = length of document D (in tokens)
|
|
76
|
+
* avgdl = average document length across the corpus
|
|
77
|
+
* N = total number of documents
|
|
78
|
+
* n(qi) = number of documents containing term qi
|
|
79
|
+
*
|
|
80
|
+
* @param queryTerms - Tokenized query terms
|
|
81
|
+
* @param docTerms - Tokenized document terms
|
|
82
|
+
* @param avgDocLen - Average document length (in tokens) across the candidate corpus
|
|
83
|
+
* @param docCount - Total number of documents in the candidate corpus
|
|
84
|
+
* @param termDocFreqs - Map from term to number of documents containing that term
|
|
85
|
+
* @param k1 - BM25 k1 parameter (default 1.2)
|
|
86
|
+
* @param b - BM25 b parameter (default 0.75)
|
|
87
|
+
*/
|
|
88
|
+
export function bm25Score(
|
|
89
|
+
queryTerms: string[],
|
|
90
|
+
docTerms: string[],
|
|
91
|
+
avgDocLen: number,
|
|
92
|
+
docCount: number,
|
|
93
|
+
termDocFreqs: Map<string, number>,
|
|
94
|
+
k1: number = 1.2,
|
|
95
|
+
b: number = 0.75,
|
|
96
|
+
): number {
|
|
97
|
+
if (docTerms.length === 0 || avgDocLen === 0 || docCount === 0) return 0;
|
|
98
|
+
|
|
99
|
+
// Count term frequencies in this document.
|
|
100
|
+
const docTf = new Map<string, number>();
|
|
101
|
+
for (const term of docTerms) {
|
|
102
|
+
docTf.set(term, (docTf.get(term) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const docLen = docTerms.length;
|
|
106
|
+
let score = 0;
|
|
107
|
+
|
|
108
|
+
for (const qi of queryTerms) {
|
|
109
|
+
const freq = docTf.get(qi) ?? 0;
|
|
110
|
+
if (freq === 0) continue;
|
|
111
|
+
|
|
112
|
+
const nqi = termDocFreqs.get(qi) ?? 0;
|
|
113
|
+
|
|
114
|
+
// IDF with Robertson-Walker floor: ln((N - n + 0.5) / (n + 0.5) + 1)
|
|
115
|
+
// The +1 inside ln ensures IDF is always >= 0 even when n > N/2.
|
|
116
|
+
const idf = Math.log((docCount - nqi + 0.5) / (nqi + 0.5) + 1);
|
|
117
|
+
|
|
118
|
+
// TF saturation with length normalization.
|
|
119
|
+
const tfNorm = (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * docLen / avgDocLen));
|
|
120
|
+
|
|
121
|
+
score += idf * tfNorm;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return score;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Cosine Similarity
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compute cosine similarity between two vectors.
|
|
133
|
+
*
|
|
134
|
+
* Returns dot(a, b) / (||a|| * ||b||).
|
|
135
|
+
* Returns 0 if either vector has zero magnitude (avoids division by zero).
|
|
136
|
+
*/
|
|
137
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
138
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
139
|
+
|
|
140
|
+
const len = Math.min(a.length, b.length);
|
|
141
|
+
let dot = 0;
|
|
142
|
+
let normA = 0;
|
|
143
|
+
let normB = 0;
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < len; i++) {
|
|
146
|
+
dot += a[i] * b[i];
|
|
147
|
+
normA += a[i] * a[i];
|
|
148
|
+
normB += b[i] * b[i];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
152
|
+
if (denom === 0) return 0;
|
|
153
|
+
|
|
154
|
+
return dot / denom;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Reciprocal Rank Fusion (RRF)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export interface RankedItem {
|
|
162
|
+
id: string;
|
|
163
|
+
score: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fuse multiple ranking lists using Reciprocal Rank Fusion.
|
|
168
|
+
*
|
|
169
|
+
* For each document d appearing in any ranking list:
|
|
170
|
+
* rrfScore(d) = SUM_i 1 / (k + rank_i(d))
|
|
171
|
+
*
|
|
172
|
+
* where rank_i(d) is the 1-based rank of document d in the i-th list.
|
|
173
|
+
* Documents not present in a list are not penalized (they simply receive
|
|
174
|
+
* no contribution from that list).
|
|
175
|
+
*
|
|
176
|
+
* @param rankings - Array of ranking lists, each sorted by score descending.
|
|
177
|
+
* Each item has an `id` and a `score`.
|
|
178
|
+
* @param k - RRF smoothing constant (default 60, per the original paper).
|
|
179
|
+
* @returns - Fused ranking sorted by RRF score descending.
|
|
180
|
+
*/
|
|
181
|
+
export function rrfFuse(
|
|
182
|
+
rankings: RankedItem[][],
|
|
183
|
+
k: number = 60,
|
|
184
|
+
): RankedItem[] {
|
|
185
|
+
const fusedScores = new Map<string, number>();
|
|
186
|
+
|
|
187
|
+
for (const ranking of rankings) {
|
|
188
|
+
for (let rank = 0; rank < ranking.length; rank++) {
|
|
189
|
+
const item = ranking[rank];
|
|
190
|
+
const contribution = 1 / (k + rank + 1); // rank is 0-based, formula uses 1-based
|
|
191
|
+
fusedScores.set(item.id, (fusedScores.get(item.id) ?? 0) + contribution);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const fused: RankedItem[] = [];
|
|
196
|
+
for (const [id, score] of fusedScores) {
|
|
197
|
+
fused.push({ id, score });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fused.sort((a, b) => b.score - a.score);
|
|
201
|
+
return fused;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Weighted Reciprocal Rank Fusion
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Fuse multiple ranking lists using Weighted Reciprocal Rank Fusion.
|
|
210
|
+
*
|
|
211
|
+
* Like standard RRF, but each ranking list's contribution is multiplied by
|
|
212
|
+
* its weight, allowing callers to emphasize or de-emphasize specific signals.
|
|
213
|
+
*
|
|
214
|
+
* @param rankings - Array of ranking lists, each sorted by score descending.
|
|
215
|
+
* @param weights - Weight for each ranking list (same length as rankings).
|
|
216
|
+
* @param k - RRF smoothing constant (default 60).
|
|
217
|
+
* @returns - Fused ranking sorted by weighted RRF score descending.
|
|
218
|
+
*/
|
|
219
|
+
export function weightedRrfFuse(
|
|
220
|
+
rankings: RankedItem[][],
|
|
221
|
+
weights: number[],
|
|
222
|
+
k: number = 60,
|
|
223
|
+
): RankedItem[] {
|
|
224
|
+
const fusedScores = new Map<string, number>();
|
|
225
|
+
|
|
226
|
+
for (let r = 0; r < rankings.length; r++) {
|
|
227
|
+
const w = weights[r] ?? 1;
|
|
228
|
+
for (let rank = 0; rank < rankings[r].length; rank++) {
|
|
229
|
+
const item = rankings[r][rank];
|
|
230
|
+
const contribution = w * (1 / (k + rank + 1));
|
|
231
|
+
fusedScores.set(item.id, (fusedScores.get(item.id) ?? 0) + contribution);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const fused: RankedItem[] = [];
|
|
236
|
+
for (const [id, score] of fusedScores) {
|
|
237
|
+
fused.push({ id, score });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
fused.sort((a, b) => b.score - a.score);
|
|
241
|
+
return fused;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Ranking Weights & Interfaces
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
export interface RankingWeights {
|
|
249
|
+
bm25: number;
|
|
250
|
+
cosine: number;
|
|
251
|
+
importance: number;
|
|
252
|
+
recency: number;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const DEFAULT_WEIGHTS: RankingWeights = {
|
|
256
|
+
bm25: 0.25,
|
|
257
|
+
cosine: 0.25,
|
|
258
|
+
importance: 0.25,
|
|
259
|
+
recency: 0.25,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Query Intent Detection (T326)
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/** The detected intent of a user query. */
|
|
267
|
+
export type QueryIntent = 'factual' | 'temporal' | 'semantic';
|
|
268
|
+
|
|
269
|
+
const TEMPORAL_KEYWORDS = /\b(yesterday|today|last\s+week|last\s+month|recently|recent|latest|ago|when|this\s+week|this\s+month|earlier|before|after|since|during|tonight|morning|afternoon)\b/i;
|
|
270
|
+
|
|
271
|
+
const FACTUAL_PATTERNS = /^(what|who|where|which|how\s+many|how\s+much|is\s+|are\s+|does\s+|do\s+|did\s+|was\s+|were\s+)\b/i;
|
|
272
|
+
|
|
273
|
+
/** Ranking weights tuned for each query intent. */
|
|
274
|
+
export const INTENT_WEIGHTS: Record<QueryIntent, RankingWeights> = {
|
|
275
|
+
factual: { bm25: 0.40, cosine: 0.20, importance: 0.25, recency: 0.15 },
|
|
276
|
+
temporal: { bm25: 0.15, cosine: 0.20, importance: 0.20, recency: 0.45 },
|
|
277
|
+
semantic: { bm25: 0.20, cosine: 0.35, importance: 0.25, recency: 0.20 },
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Classify a query into one of three intent types using lightweight heuristics.
|
|
282
|
+
* Temporal is checked first so "What did we discuss yesterday?" → temporal.
|
|
283
|
+
*/
|
|
284
|
+
export function detectQueryIntent(query: string): QueryIntent {
|
|
285
|
+
if (TEMPORAL_KEYWORDS.test(query)) return 'temporal';
|
|
286
|
+
if (FACTUAL_PATTERNS.test(query) && query.length < 80) return 'factual';
|
|
287
|
+
return 'semantic';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface RerankerCandidate {
|
|
291
|
+
id: string;
|
|
292
|
+
text: string;
|
|
293
|
+
embedding?: number[];
|
|
294
|
+
importance?: number; // 0-1 normalized importance score
|
|
295
|
+
createdAt?: number; // Unix timestamp (seconds) when fact was created
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface RerankerResult extends RerankerCandidate {
|
|
299
|
+
rrfScore: number;
|
|
300
|
+
cosineSimilarity?: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Recency Scoring
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Compute a recency score with a 1-week half-life.
|
|
309
|
+
*
|
|
310
|
+
* Score = 1 / (1 + hours_since_creation / 168)
|
|
311
|
+
*
|
|
312
|
+
* A fact created just now scores ~1.0, one week ago scores 0.5,
|
|
313
|
+
* two weeks ago scores ~0.33, etc.
|
|
314
|
+
*
|
|
315
|
+
* @param createdAt - Unix timestamp in seconds
|
|
316
|
+
* @returns - Recency score in (0, 1]
|
|
317
|
+
*/
|
|
318
|
+
function recencyScore(createdAt: number): number {
|
|
319
|
+
const nowSeconds = Date.now() / 1000;
|
|
320
|
+
const hoursSince = (nowSeconds - createdAt) / 3600;
|
|
321
|
+
return 1 / (1 + hoursSince / 168);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// MMR (Maximal Marginal Relevance)
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Apply Maximal Marginal Relevance to promote diversity in results.
|
|
330
|
+
*
|
|
331
|
+
* MMR re-orders a ranked list of candidates so that highly similar candidates
|
|
332
|
+
* are spread out. The algorithm greedily selects the candidate that maximizes:
|
|
333
|
+
*
|
|
334
|
+
* MMR(d) = lambda * relevance(d) - (1 - lambda) * max_sim(d, selected)
|
|
335
|
+
*
|
|
336
|
+
* where:
|
|
337
|
+
* - relevance(d) = position-based score (1.0 for first, linearly decreasing)
|
|
338
|
+
* - max_sim(d, selected) = max cosine similarity between d and any already
|
|
339
|
+
* selected candidate (0 if no embeddings available)
|
|
340
|
+
*
|
|
341
|
+
* @param candidates - Candidates in relevance order (best first)
|
|
342
|
+
* @param lambda - Trade-off between relevance and diversity (default 0.7)
|
|
343
|
+
* @param topK - Number of results to return (default 8)
|
|
344
|
+
* @returns - Re-ordered candidates with diversity
|
|
345
|
+
*/
|
|
346
|
+
export function applyMMR(
|
|
347
|
+
candidates: RerankerCandidate[],
|
|
348
|
+
lambda: number = 0.7,
|
|
349
|
+
topK: number = 8,
|
|
350
|
+
): RerankerCandidate[] {
|
|
351
|
+
if (candidates.length === 0) return [];
|
|
352
|
+
if (candidates.length <= 1) return candidates.slice(0, topK);
|
|
353
|
+
|
|
354
|
+
const remaining = candidates.map((c, i) => ({ candidate: c, index: i }));
|
|
355
|
+
const selected: RerankerCandidate[] = [];
|
|
356
|
+
const n = candidates.length;
|
|
357
|
+
|
|
358
|
+
while (selected.length < topK && remaining.length > 0) {
|
|
359
|
+
let bestIdx = -1;
|
|
360
|
+
let bestMMR = -Infinity;
|
|
361
|
+
|
|
362
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
363
|
+
const { candidate, index } = remaining[i];
|
|
364
|
+
|
|
365
|
+
// Relevance: linear decay from 1.0 (first) to near 0 (last)
|
|
366
|
+
const relevance = 1.0 - index / n;
|
|
367
|
+
|
|
368
|
+
// Max similarity to any already-selected candidate
|
|
369
|
+
let maxSim = 0;
|
|
370
|
+
if (candidate.embedding && candidate.embedding.length > 0) {
|
|
371
|
+
for (const sel of selected) {
|
|
372
|
+
if (sel.embedding && sel.embedding.length > 0) {
|
|
373
|
+
const sim = cosineSimilarity(candidate.embedding, sel.embedding);
|
|
374
|
+
if (sim > maxSim) maxSim = sim;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const mmr = lambda * relevance - (1 - lambda) * maxSim;
|
|
380
|
+
if (mmr > bestMMR) {
|
|
381
|
+
bestMMR = mmr;
|
|
382
|
+
bestIdx = i;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (bestIdx >= 0) {
|
|
387
|
+
selected.push(remaining[bestIdx].candidate);
|
|
388
|
+
remaining.splice(bestIdx, 1);
|
|
389
|
+
} else {
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return selected;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Combined Re-Ranker
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Re-rank decrypted candidates using BM25 + Cosine + Importance + Recency
|
|
403
|
+
* with Weighted RRF fusion and MMR diversity.
|
|
404
|
+
*
|
|
405
|
+
* Pipeline:
|
|
406
|
+
* 1. Tokenize query and all candidate texts
|
|
407
|
+
* 2. Build corpus statistics (term document frequencies, average doc length)
|
|
408
|
+
* 3. Score each candidate with BM25
|
|
409
|
+
* 4. Score each candidate with cosine similarity (if embedding available)
|
|
410
|
+
* 5. Score each candidate by importance
|
|
411
|
+
* 6. Score each candidate by recency
|
|
412
|
+
* 7. Fuse all 4 rankings with weighted RRF
|
|
413
|
+
* 8. Apply MMR for diversity
|
|
414
|
+
* 9. Return top-k candidates sorted by fused score
|
|
415
|
+
*
|
|
416
|
+
* Backward compatibility:
|
|
417
|
+
* - Candidates without embeddings get cosine score = 0 and are excluded
|
|
418
|
+
* from the cosine ranking list. They can still rank well via other signals.
|
|
419
|
+
* - If NO candidates have embeddings, cosine ranking is omitted.
|
|
420
|
+
* - Candidates without importance get neutral score (0.5).
|
|
421
|
+
* - Candidates without createdAt get neutral recency score (0.5).
|
|
422
|
+
* - topK defaults to 8, weights default to equal (0.25 each).
|
|
423
|
+
*
|
|
424
|
+
* @param query - The user's search query (plaintext)
|
|
425
|
+
* @param queryEmbedding - Embedding vector for the query
|
|
426
|
+
* @param candidates - Decrypted candidates with text and optional embeddings
|
|
427
|
+
* @param topK - Number of results to return (default 8)
|
|
428
|
+
* @param weights - Optional partial ranking weights (merged with defaults)
|
|
429
|
+
* @returns - Top-k candidates sorted by fused score, with scores attached
|
|
430
|
+
*/
|
|
431
|
+
export function rerank(
|
|
432
|
+
query: string,
|
|
433
|
+
queryEmbedding: number[],
|
|
434
|
+
candidates: RerankerCandidate[],
|
|
435
|
+
topK: number = 8,
|
|
436
|
+
weights?: Partial<RankingWeights>,
|
|
437
|
+
): RerankerResult[] {
|
|
438
|
+
if (candidates.length === 0) return [];
|
|
439
|
+
|
|
440
|
+
// Merge caller weights with defaults
|
|
441
|
+
const w: RankingWeights = { ...DEFAULT_WEIGHTS, ...weights };
|
|
442
|
+
|
|
443
|
+
// --- Step 1: Tokenize ---
|
|
444
|
+
const queryTerms = tokenize(query);
|
|
445
|
+
const candidateTerms = candidates.map((c) => tokenize(c.text));
|
|
446
|
+
|
|
447
|
+
// --- Step 2: Corpus statistics ---
|
|
448
|
+
const docCount = candidates.length;
|
|
449
|
+
let totalDocLen = 0;
|
|
450
|
+
|
|
451
|
+
// Count how many documents contain each term.
|
|
452
|
+
const termDocFreqs = new Map<string, number>();
|
|
453
|
+
for (const terms of candidateTerms) {
|
|
454
|
+
totalDocLen += terms.length;
|
|
455
|
+
const uniqueTerms = new Set(terms);
|
|
456
|
+
for (const term of uniqueTerms) {
|
|
457
|
+
termDocFreqs.set(term, (termDocFreqs.get(term) ?? 0) + 1);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const avgDocLen = docCount > 0 ? totalDocLen / docCount : 0;
|
|
462
|
+
|
|
463
|
+
// --- Step 3: BM25 scores ---
|
|
464
|
+
const bm25Ranking: RankedItem[] = [];
|
|
465
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
466
|
+
const score = bm25Score(queryTerms, candidateTerms[i], avgDocLen, docCount, termDocFreqs);
|
|
467
|
+
bm25Ranking.push({ id: candidates[i].id, score });
|
|
468
|
+
}
|
|
469
|
+
bm25Ranking.sort((a, b) => b.score - a.score);
|
|
470
|
+
|
|
471
|
+
// --- Step 4: Cosine similarity scores ---
|
|
472
|
+
const cosineScores = new Map<string, number>();
|
|
473
|
+
const cosineRanking: RankedItem[] = [];
|
|
474
|
+
for (const candidate of candidates) {
|
|
475
|
+
if (candidate.embedding && candidate.embedding.length > 0) {
|
|
476
|
+
const score = cosineSimilarity(queryEmbedding, candidate.embedding);
|
|
477
|
+
cosineScores.set(candidate.id, score);
|
|
478
|
+
cosineRanking.push({ id: candidate.id, score });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
cosineRanking.sort((a, b) => b.score - a.score);
|
|
482
|
+
|
|
483
|
+
// --- Step 5: Importance ranking ---
|
|
484
|
+
const importanceRanking: RankedItem[] = candidates.map((c) => ({
|
|
485
|
+
id: c.id,
|
|
486
|
+
score: c.importance ?? 0.5,
|
|
487
|
+
}));
|
|
488
|
+
importanceRanking.sort((a, b) => b.score - a.score);
|
|
489
|
+
|
|
490
|
+
// --- Step 6: Recency ranking ---
|
|
491
|
+
const recencyRanking: RankedItem[] = candidates.map((c) => ({
|
|
492
|
+
id: c.id,
|
|
493
|
+
score: c.createdAt != null ? recencyScore(c.createdAt) : 0.5,
|
|
494
|
+
}));
|
|
495
|
+
recencyRanking.sort((a, b) => b.score - a.score);
|
|
496
|
+
|
|
497
|
+
// --- Step 7: Weighted RRF fusion ---
|
|
498
|
+
const rankings: RankedItem[][] = [bm25Ranking];
|
|
499
|
+
const rankWeights: number[] = [w.bm25];
|
|
500
|
+
|
|
501
|
+
if (cosineRanking.length > 0) {
|
|
502
|
+
rankings.push(cosineRanking);
|
|
503
|
+
rankWeights.push(w.cosine);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
rankings.push(importanceRanking);
|
|
507
|
+
rankWeights.push(w.importance);
|
|
508
|
+
|
|
509
|
+
rankings.push(recencyRanking);
|
|
510
|
+
rankWeights.push(w.recency);
|
|
511
|
+
|
|
512
|
+
const fused = weightedRrfFuse(rankings, rankWeights);
|
|
513
|
+
|
|
514
|
+
// --- Step 8: Build result objects with scores ---
|
|
515
|
+
const candidateMap = new Map<string, RerankerCandidate>();
|
|
516
|
+
for (const c of candidates) {
|
|
517
|
+
candidateMap.set(c.id, c);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const rrfResults: RerankerResult[] = [];
|
|
521
|
+
for (const item of fused) {
|
|
522
|
+
const candidate = candidateMap.get(item.id);
|
|
523
|
+
if (candidate) {
|
|
524
|
+
rrfResults.push({
|
|
525
|
+
...candidate,
|
|
526
|
+
rrfScore: item.score,
|
|
527
|
+
cosineSimilarity: cosineScores.get(item.id),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// --- Step 9: Apply MMR for diversity, then return top-k ---
|
|
533
|
+
const mmrResults = applyMMR(rrfResults, 0.7, topK);
|
|
534
|
+
|
|
535
|
+
// Preserve rrfScore and cosineSimilarity through MMR
|
|
536
|
+
return mmrResults as RerankerResult[];
|
|
537
|
+
}
|