Skip to content

Commit bd11847

Browse files
committed
refactor: update reranker
1 parent 629f436 commit bd11847

11 files changed

Lines changed: 276 additions & 355 deletions

File tree

README.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,6 @@ await ctx.close();
208208
+------------------------------------------------------------------------+
209209
```
210210

211-
### Module Structure
212-
213-
- **Public API**: `Context`, `QueryOptions`, `QueryResult`, `Document`, `Loader`, `MarkdownLoader`, `JsonLoader`, `TextLoader`, `pathToId`
214-
- **Reranking**: `KeywordReranker`, `createReranker`, `Reranker`, `RerankCandidate`, `RerankResult`, `RerankOptions`
215-
- **Query Expansion**: `SynonymExpander`, `NoopExpander`, `QueryExpander`, `QueryExpansionOptions`
216-
- **Advanced API**: `Embedder`, `TransformersEmbedder`, `EmbedderManager`, `IZvecStore`, `ActualZvecStore`, `Store`
217-
218-
219211
## License
220212

221213
MIT

src/context.ts

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import {
55
ContextOptions,
66
QueryOptions,
77
QueryResult,
8-
Document,
98
LoadedDoc,
10-
LoadPhase,
11-
LoadProgress,
129
} from './types';
1310
import { Embedder } from './embedder';
1411

@@ -18,24 +15,18 @@ import { pathToId } from './utils/doc';
1815
import { Store } from './storage/store';
1916
import type { ZvecDoc } from './storage/zvec-store';
2017
import {
21-
createReranker,
2218
safeJsonParse,
2319
computeContentHash,
2420
loadSampleText,
2521
} from './utils';
2622
import { expand } from './expander';
27-
import type { Reranker, RerankCandidate } from './utils';
28-
29-
// ---------------------------------------------------------------------------
30-
// Context class
31-
// ---------------------------------------------------------------------------
23+
import { applyRerank } from './reranker';
3224

3325
export class Context {
3426
private readonly options: ContextOptions;
3527
private readonly embedder: Embedder;
3628
readonly embedderInfo: EmbedderInfo;
3729
private readonly store: Store;
38-
private readonly reranker: Reranker | null;
3930

4031
private constructor(options: ContextOptions, embedder: Embedder, embedderInfo: EmbedderInfo) {
4132
this.options = {
@@ -46,7 +37,6 @@ export class Context {
4637
this.embedder = embedder;
4738
this.embedderInfo = embedderInfo;
4839
this.store = new Store(this.options.vectorsDir!, embedder, this.options);
49-
this.reranker = createReranker(this.options.rerankWeights);
5040
}
5141

5242
static async create(options: ContextOptions): Promise<Context> {
@@ -163,23 +153,8 @@ export class Context {
163153
};
164154
});
165155

166-
if (rerankEnabled && queryResults.length > topK) {
167-
const docs: RerankCandidate[] = queryResults.map((r) => ({
168-
id: r.id,
169-
content: r.content,
170-
score: r.score,
171-
}));
172-
173-
const reranked = await this.reranker!.rerank(text, docs);
174-
175-
const scoreMap = new Map(reranked.map((r) => [r.id, r.score]));
176-
for (const result of queryResults) {
177-
const newScore = scoreMap.get(result.id);
178-
if (newScore !== undefined) {
179-
result.score = newScore;
180-
result.scoreMode = 'reranked';
181-
}
182-
}
156+
if (rerankEnabled) {
157+
await applyRerank(this.options.rerankWeights, text, queryResults, topK);
183158
}
184159

185160
queryResults.sort((a, b) => b.score - a.score);

src/index.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,2 @@
1-
// ---------------------------------------------------------------------------
2-
// Public API
3-
// ---------------------------------------------------------------------------
4-
51
export { Context } from './context';
6-
export * from './types';
7-
8-
// Reranker
9-
export { KeywordReranker, createReranker } from './utils/reranker';
10-
export type { RerankCandidate, RerankResult } from './utils/reranker';
11-
12-
// Query expansion
13-
export { expand } from './expander';
14-
15-
// Embedder
16-
export { Embedder } from './embedder';
2+
export * from './types';

src/reranker/helpers.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
const WORD_BOUNDARY = /[\s\n.,;:!?"'(\-_]/;
2+
3+
export function tokenizeQuery(query: string): string[] {
4+
const tokens = query.split(/[\s,.!?:;]+/).filter(Boolean);
5+
const result: string[] = [];
6+
for (const r of tokens) {
7+
result.push(r);
8+
if (/[-鿿]{3,}/.test(r)) {
9+
for (let i = 0; i + 2 <= r.length; i++) result.push(r.slice(i, i + 2));
10+
}
11+
}
12+
return [...new Set(result)];
13+
}
14+
15+
export function countOccurrences(text: string, sub: string): number {
16+
let count = 0, pos = 0;
17+
while ((pos = text.indexOf(sub, pos)) !== -1) { count++; pos += sub.length; }
18+
return count;
19+
}
20+
21+
export function countTermMatches(text: string, term: string): number {
22+
let count = 0, pos = 0;
23+
while ((pos = text.indexOf(term, pos)) !== -1) {
24+
if (isWordBoundary(text, term, pos)) count++;
25+
pos += term.length;
26+
}
27+
return count;
28+
}
29+
30+
export function isWordBoundary(text: string, term: string, pos?: number): boolean {
31+
const idx = pos ?? text.indexOf(term);
32+
if (idx === -1) return false;
33+
const before = idx === 0 || WORD_BOUNDARY.test(text[idx - 1]);
34+
const after = idx + term.length >= text.length || WORD_BOUNDARY.test(text[idx + term.length]);
35+
return before && after;
36+
}

src/reranker/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { RerankCandidate, RerankOptions } from './types';
2+
import type { QueryResult } from '../types';
3+
import { rerank } from './reranker';
4+
5+
export type { RerankOptions };
6+
7+
/**
8+
* Apply reranking to query results.
9+
*/
10+
export async function applyRerank(
11+
rerankOptions: RerankOptions | undefined,
12+
query: string,
13+
results: QueryResult[],
14+
topK: number,
15+
): Promise<QueryResult[]> {
16+
if (results.length <= topK) return results;
17+
18+
const candidates: RerankCandidate[] = results.map((r) => ({
19+
id: r.id,
20+
content: r.content,
21+
score: r.score,
22+
}));
23+
24+
const reranked = await rerank(query, candidates, rerankOptions);
25+
26+
const scoreMap = new Map(reranked.map((r) => [r.id, r.score]));
27+
for (const result of results) {
28+
const newScore = scoreMap.get(result.id);
29+
if (newScore !== undefined) {
30+
result.score = newScore;
31+
result.scoreMode = 'reranked';
32+
}
33+
}
34+
35+
return results;
36+
}

src/reranker/reranker.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { RerankCandidate, RerankResult, RerankOptions } from './types';
2+
import { tokenizeQuery, countOccurrences, countTermMatches, isWordBoundary } from './helpers';
3+
4+
const DEFAULTS = {
5+
phraseWeight: 3.0,
6+
phraseRepeatBonus: 0.5,
7+
termWeight: 1.0,
8+
termRepeatBonus: 0.2,
9+
substringWeight: 0.3,
10+
headingTermBonus: 2.0,
11+
headingPhraseBonus: 2.5,
12+
originalScoreCarry: 0.1,
13+
} as const;
14+
15+
/**
16+
* Rerank candidates by keyword / phrase overlap with the query.
17+
* Scores are normalised to [0, 1] via min-max scaling.
18+
*/
19+
export async function rerank(
20+
query: string,
21+
candidates: RerankCandidate[],
22+
options?: RerankOptions,
23+
): Promise<RerankResult[]> {
24+
if (candidates.length === 0) return [];
25+
26+
const w = {
27+
phraseWeight: options?.phraseWeight ?? DEFAULTS.phraseWeight,
28+
phraseRepeatBonus: options?.phraseRepeatBonus ?? DEFAULTS.phraseRepeatBonus,
29+
termWeight: options?.termWeight ?? DEFAULTS.termWeight,
30+
termRepeatBonus: options?.termRepeatBonus ?? DEFAULTS.termRepeatBonus,
31+
substringWeight: options?.substringWeight ?? DEFAULTS.substringWeight,
32+
headingTermBonus: options?.headingTermBonus ?? DEFAULTS.headingTermBonus,
33+
headingPhraseBonus: options?.headingPhraseBonus ?? DEFAULTS.headingPhraseBonus,
34+
originalScoreCarry: options?.originalScoreCarry ?? DEFAULTS.originalScoreCarry,
35+
};
36+
37+
const lowerQuery = query.toLowerCase();
38+
const queryTerms = tokenizeQuery(lowerQuery);
39+
const queryPhrase = lowerQuery.trim();
40+
41+
const scored = candidates.map((c) => {
42+
const content = c.content.toLowerCase();
43+
let score = 0;
44+
45+
// 1. Exact phrase match
46+
if (content.includes(queryPhrase)) {
47+
score += w.phraseWeight + (countOccurrences(content, queryPhrase) - 1) * w.phraseRepeatBonus;
48+
}
49+
50+
// 2. Per-term matching
51+
for (const term of queryTerms) {
52+
if (content.includes(term)) {
53+
score += isWordBoundary(content, term) ? w.termWeight + (countTermMatches(content, term) - 1) * w.termRepeatBonus : w.substringWeight;
54+
}
55+
}
56+
57+
// 3. Heading path bonus
58+
if (c.headingPath) {
59+
const heading = c.headingPath.toLowerCase();
60+
for (const term of queryTerms) {
61+
if (heading.includes(term)) score += w.headingTermBonus;
62+
}
63+
if (queryPhrase.length > 2 && heading.includes(queryPhrase)) {
64+
score += w.headingPhraseBonus;
65+
}
66+
}
67+
68+
// 4. Carry over original score
69+
score += c.score * w.originalScoreCarry;
70+
71+
return { id: c.id, score };
72+
});
73+
74+
// Min-max normalise to [0, 1]
75+
const scores = scored.map((s) => s.score);
76+
const min = Math.min(...scores);
77+
const max = Math.max(...scores);
78+
const range = max - min || 1;
79+
80+
return scored.map((s) => ({ id: s.id, score: (s.score - min) / range }));
81+
}

src/reranker/types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Reranker — second-stage precision scoring for search results.
3+
*/
4+
5+
// ---------------------------------------------------------------------------
6+
// Types
7+
// ---------------------------------------------------------------------------
8+
9+
/** A candidate document for reranking. */
10+
export interface RerankCandidate {
11+
id: string;
12+
content: string;
13+
/** Original score from the coarse search stage. */
14+
score: number;
15+
/** Heading path as a string (e.g. "Line Chart > Tooltip"). */
16+
headingPath?: string;
17+
}
18+
19+
/** A reranked result. */
20+
export interface RerankResult {
21+
id: string;
22+
/** Final score after reranking (higher is better). */
23+
score: number;
24+
}
25+
26+
/** Reranker type */
27+
export type Reranker = {
28+
rerank(query: string, candidates: RerankCandidate[]): Promise<RerankResult[]>;
29+
};
30+
31+
/** Configuration for reranking. */
32+
export interface RerankOptions {
33+
rerankFactor?: number;
34+
minCandidates?: number;
35+
phraseWeight?: number;
36+
phraseRepeatBonus?: number;
37+
termWeight?: number;
38+
termRepeatBonus?: number;
39+
substringWeight?: number;
40+
headingTermBonus?: number;
41+
headingPhraseBonus?: number;
42+
originalScoreCarry?: number;
43+
}

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RerankOptions } from './utils/reranker';
1+
import type { RerankOptions } from './reranker';
22

33
/**
44
* Document structure

src/utils/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,4 @@ export { safeJsonParse } from './common';
44
export { loadSampleText } from './sample';
55
export { isCJK, detectLanguage, tokenizerForLanguage, detectTokenizer } from './tokenizer';
66
export type { LanguageHint } from './tokenizer';
7-
export { containsCJK } from './str';
8-
export { KeywordReranker, createReranker } from './reranker';
9-
export type { Reranker, RerankCandidate, RerankResult } from './reranker';
7+
export { containsCJK } from './str';

0 commit comments

Comments
 (0)