@renseiai/agentfactory-code-intelligence 0.8.8 → 0.8.9
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/dist/src/embedding/__tests__/embedding.test.d.ts +2 -0
- package/dist/src/embedding/__tests__/embedding.test.d.ts.map +1 -0
- package/dist/src/embedding/__tests__/embedding.test.js +339 -0
- package/dist/src/embedding/chunker.d.ts +40 -0
- package/dist/src/embedding/chunker.d.ts.map +1 -0
- package/dist/src/embedding/chunker.js +135 -0
- package/dist/src/embedding/embedding-provider.d.ts +15 -0
- package/dist/src/embedding/embedding-provider.d.ts.map +1 -0
- package/dist/src/embedding/embedding-provider.js +1 -0
- package/dist/src/embedding/voyage-provider.d.ts +39 -0
- package/dist/src/embedding/voyage-provider.d.ts.map +1 -0
- package/dist/src/embedding/voyage-provider.js +146 -0
- package/dist/src/index.d.ts +14 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +10 -1
- package/dist/src/indexing/__tests__/vector-indexing.test.d.ts +2 -0
- package/dist/src/indexing/__tests__/vector-indexing.test.d.ts.map +1 -0
- package/dist/src/indexing/__tests__/vector-indexing.test.js +291 -0
- package/dist/src/indexing/incremental-indexer.d.ts +4 -0
- package/dist/src/indexing/incremental-indexer.d.ts.map +1 -1
- package/dist/src/indexing/incremental-indexer.js +45 -0
- package/dist/src/indexing/vector-indexer.d.ts +63 -0
- package/dist/src/indexing/vector-indexer.d.ts.map +1 -0
- package/dist/src/indexing/vector-indexer.js +197 -0
- package/dist/src/plugin/code-intelligence-plugin.d.ts.map +1 -1
- package/dist/src/plugin/code-intelligence-plugin.js +4 -2
- package/dist/src/reranking/__tests__/reranker.test.d.ts +2 -0
- package/dist/src/reranking/__tests__/reranker.test.d.ts.map +1 -0
- package/dist/src/reranking/__tests__/reranker.test.js +503 -0
- package/dist/src/reranking/cohere-reranker.d.ts +26 -0
- package/dist/src/reranking/cohere-reranker.d.ts.map +1 -0
- package/dist/src/reranking/cohere-reranker.js +110 -0
- package/dist/src/reranking/reranker-provider.d.ts +40 -0
- package/dist/src/reranking/reranker-provider.d.ts.map +1 -0
- package/dist/src/reranking/reranker-provider.js +6 -0
- package/dist/src/reranking/voyage-reranker.d.ts +27 -0
- package/dist/src/reranking/voyage-reranker.d.ts.map +1 -0
- package/dist/src/reranking/voyage-reranker.js +111 -0
- package/dist/src/search/__tests__/hybrid-search.test.d.ts +2 -0
- package/dist/src/search/__tests__/hybrid-search.test.d.ts.map +1 -0
- package/dist/src/search/__tests__/hybrid-search.test.js +437 -0
- package/dist/src/search/__tests__/query-classifier.test.d.ts +2 -0
- package/dist/src/search/__tests__/query-classifier.test.d.ts.map +1 -0
- package/dist/src/search/__tests__/query-classifier.test.js +136 -0
- package/dist/src/search/hybrid-search.d.ts +56 -0
- package/dist/src/search/hybrid-search.d.ts.map +1 -0
- package/dist/src/search/hybrid-search.js +299 -0
- package/dist/src/search/query-classifier.d.ts +20 -0
- package/dist/src/search/query-classifier.d.ts.map +1 -0
- package/dist/src/search/query-classifier.js +58 -0
- package/dist/src/search/score-normalizer.d.ts +16 -0
- package/dist/src/search/score-normalizer.d.ts.map +1 -0
- package/dist/src/search/score-normalizer.js +26 -0
- package/dist/src/types.d.ts +83 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +36 -2
- package/dist/src/vector/__tests__/vector-store.test.d.ts +2 -0
- package/dist/src/vector/__tests__/vector-store.test.d.ts.map +1 -0
- package/dist/src/vector/__tests__/vector-store.test.js +278 -0
- package/dist/src/vector/hnsw-store.d.ts +48 -0
- package/dist/src/vector/hnsw-store.d.ts.map +1 -0
- package/dist/src/vector/hnsw-store.js +437 -0
- package/dist/src/vector/vector-store.d.ts +15 -0
- package/dist/src/vector/vector-store.d.ts.map +1 -0
- package/dist/src/vector/vector-store.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { CohereReranker } from '../cohere-reranker.js';
|
|
3
|
+
import { VoyageReranker } from '../voyage-reranker.js';
|
|
4
|
+
import { SearchEngine } from '../../search/search-engine.js';
|
|
5
|
+
import { HybridSearchEngine } from '../../search/hybrid-search.js';
|
|
6
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
7
|
+
/** Create a mock fetch response. */
|
|
8
|
+
function mockFetchResponse(status, body, headers) {
|
|
9
|
+
return {
|
|
10
|
+
ok: status >= 200 && status < 300,
|
|
11
|
+
status,
|
|
12
|
+
headers: new Headers(headers ?? {}),
|
|
13
|
+
json: async () => body,
|
|
14
|
+
text: async () => JSON.stringify(body),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function makeSymbol(name, kind, filePath, extra) {
|
|
18
|
+
return {
|
|
19
|
+
name,
|
|
20
|
+
kind: kind,
|
|
21
|
+
filePath,
|
|
22
|
+
line: extra?.line ?? 1,
|
|
23
|
+
exported: true,
|
|
24
|
+
...extra,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function makeSearchResult(name, score, extra) {
|
|
28
|
+
return {
|
|
29
|
+
symbol: makeSymbol(name, 'function', `src/${name}.ts`, extra),
|
|
30
|
+
score,
|
|
31
|
+
matchType: 'bm25',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// ── CohereReranker ──────────────────────────────────────────────────
|
|
35
|
+
describe('CohereReranker', () => {
|
|
36
|
+
let originalEnv;
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
originalEnv = process.env.COHERE_API_KEY;
|
|
39
|
+
process.env.COHERE_API_KEY = 'test-cohere-key';
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
if (originalEnv !== undefined) {
|
|
43
|
+
process.env.COHERE_API_KEY = originalEnv;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
delete process.env.COHERE_API_KEY;
|
|
47
|
+
}
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
it('throws on missing API key', () => {
|
|
51
|
+
delete process.env.COHERE_API_KEY;
|
|
52
|
+
expect(() => new CohereReranker()).toThrow('COHERE_API_KEY');
|
|
53
|
+
});
|
|
54
|
+
it('uses default model', () => {
|
|
55
|
+
const reranker = new CohereReranker();
|
|
56
|
+
expect(reranker.model).toBe('rerank-v3.5');
|
|
57
|
+
});
|
|
58
|
+
it('sends correct request format', async () => {
|
|
59
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(200, {
|
|
60
|
+
results: [
|
|
61
|
+
{ index: 0, relevance_score: 0.95 },
|
|
62
|
+
{ index: 1, relevance_score: 0.80 },
|
|
63
|
+
],
|
|
64
|
+
}));
|
|
65
|
+
const reranker = new CohereReranker();
|
|
66
|
+
const documents = [
|
|
67
|
+
{ id: 'doc-0', text: 'function handleRequest() {}' },
|
|
68
|
+
{ id: 'doc-1', text: 'function processData() {}' },
|
|
69
|
+
];
|
|
70
|
+
await reranker.rerank('handle request', documents);
|
|
71
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
72
|
+
const callArgs = fetchSpy.mock.calls[0];
|
|
73
|
+
expect(callArgs[0]).toBe('https://api.cohere.com/v2/rerank');
|
|
74
|
+
const body = JSON.parse(callArgs[1].body);
|
|
75
|
+
expect(body.model).toBe('rerank-v3.5');
|
|
76
|
+
expect(body.query).toBe('handle request');
|
|
77
|
+
expect(body.documents).toEqual([
|
|
78
|
+
'function handleRequest() {}',
|
|
79
|
+
'function processData() {}',
|
|
80
|
+
]);
|
|
81
|
+
expect(body.top_n).toBe(2);
|
|
82
|
+
const headers = callArgs[1].headers;
|
|
83
|
+
expect(headers['Authorization']).toBe('Bearer test-cohere-key');
|
|
84
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
85
|
+
});
|
|
86
|
+
it('parses response correctly and maps to RerankResult[]', async () => {
|
|
87
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(200, {
|
|
88
|
+
results: [
|
|
89
|
+
{ index: 1, relevance_score: 0.95 },
|
|
90
|
+
{ index: 0, relevance_score: 0.72 },
|
|
91
|
+
],
|
|
92
|
+
}));
|
|
93
|
+
const reranker = new CohereReranker();
|
|
94
|
+
const documents = [
|
|
95
|
+
{ id: 'doc-a', text: 'first document' },
|
|
96
|
+
{ id: 'doc-b', text: 'second document' },
|
|
97
|
+
];
|
|
98
|
+
const results = await reranker.rerank('query', documents);
|
|
99
|
+
expect(results).toHaveLength(2);
|
|
100
|
+
expect(results[0]).toEqual({ id: 'doc-b', score: 0.95, index: 1 });
|
|
101
|
+
expect(results[1]).toEqual({ id: 'doc-a', score: 0.72, index: 0 });
|
|
102
|
+
});
|
|
103
|
+
it('returns empty array for empty documents', async () => {
|
|
104
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
105
|
+
const reranker = new CohereReranker();
|
|
106
|
+
const results = await reranker.rerank('query', []);
|
|
107
|
+
expect(results).toHaveLength(0);
|
|
108
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
it('retries on 429', async () => {
|
|
111
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
|
112
|
+
.mockResolvedValueOnce(mockFetchResponse(429, { message: 'Rate limited' }))
|
|
113
|
+
.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
114
|
+
results: [{ index: 0, relevance_score: 0.9 }],
|
|
115
|
+
}));
|
|
116
|
+
const reranker = new CohereReranker({ maxRetries: 3 });
|
|
117
|
+
vi.spyOn(reranker, 'sleep').mockResolvedValue(undefined);
|
|
118
|
+
const results = await reranker.rerank('query', [{ id: 'a', text: 'doc' }]);
|
|
119
|
+
expect(results).toHaveLength(1);
|
|
120
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
121
|
+
});
|
|
122
|
+
it('retries on 5xx', async () => {
|
|
123
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
|
124
|
+
.mockResolvedValueOnce(mockFetchResponse(503, { message: 'Service unavailable' }))
|
|
125
|
+
.mockResolvedValueOnce(mockFetchResponse(500, { message: 'Internal error' }))
|
|
126
|
+
.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
127
|
+
results: [{ index: 0, relevance_score: 0.85 }],
|
|
128
|
+
}));
|
|
129
|
+
const reranker = new CohereReranker({ maxRetries: 3 });
|
|
130
|
+
vi.spyOn(reranker, 'sleep').mockResolvedValue(undefined);
|
|
131
|
+
const results = await reranker.rerank('query', [{ id: 'a', text: 'doc' }]);
|
|
132
|
+
expect(results).toHaveLength(1);
|
|
133
|
+
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
134
|
+
});
|
|
135
|
+
it('throws after exhausting retries', async () => {
|
|
136
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockFetchResponse(429, { message: 'Rate limited' }));
|
|
137
|
+
const reranker = new CohereReranker({ maxRetries: 2 });
|
|
138
|
+
vi.spyOn(reranker, 'sleep').mockResolvedValue(undefined);
|
|
139
|
+
await expect(reranker.rerank('query', [{ id: 'a', text: 'doc' }])).rejects.toThrow('429');
|
|
140
|
+
});
|
|
141
|
+
it('throws immediately on non-retryable errors', async () => {
|
|
142
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(401, { message: 'Invalid API key' }));
|
|
143
|
+
const reranker = new CohereReranker();
|
|
144
|
+
await expect(reranker.rerank('query', [{ id: 'a', text: 'doc' }])).rejects.toThrow('Invalid API key');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
// ── VoyageReranker ──────────────────────────────────────────────────
|
|
148
|
+
describe('VoyageReranker', () => {
|
|
149
|
+
let originalEnv;
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
originalEnv = process.env.VOYAGE_API_KEY;
|
|
152
|
+
process.env.VOYAGE_API_KEY = 'test-voyage-key';
|
|
153
|
+
});
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
if (originalEnv !== undefined) {
|
|
156
|
+
process.env.VOYAGE_API_KEY = originalEnv;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
delete process.env.VOYAGE_API_KEY;
|
|
160
|
+
}
|
|
161
|
+
vi.restoreAllMocks();
|
|
162
|
+
});
|
|
163
|
+
it('throws on missing API key', () => {
|
|
164
|
+
delete process.env.VOYAGE_API_KEY;
|
|
165
|
+
expect(() => new VoyageReranker()).toThrow('VOYAGE_API_KEY');
|
|
166
|
+
});
|
|
167
|
+
it('uses VOYAGE_API_KEY', async () => {
|
|
168
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(200, {
|
|
169
|
+
data: [{ index: 0, relevance_score: 0.9 }],
|
|
170
|
+
}));
|
|
171
|
+
const reranker = new VoyageReranker();
|
|
172
|
+
await reranker.rerank('query', [{ id: 'a', text: 'doc' }]);
|
|
173
|
+
const callArgs = fetchSpy.mock.calls[0];
|
|
174
|
+
const headers = callArgs[1].headers;
|
|
175
|
+
expect(headers['Authorization']).toBe('Bearer test-voyage-key');
|
|
176
|
+
});
|
|
177
|
+
it('uses default model', () => {
|
|
178
|
+
const reranker = new VoyageReranker();
|
|
179
|
+
expect(reranker.model).toBe('rerank-2');
|
|
180
|
+
});
|
|
181
|
+
it('sends correct request format', async () => {
|
|
182
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(200, {
|
|
183
|
+
data: [
|
|
184
|
+
{ index: 0, relevance_score: 0.95 },
|
|
185
|
+
{ index: 1, relevance_score: 0.80 },
|
|
186
|
+
],
|
|
187
|
+
}));
|
|
188
|
+
const reranker = new VoyageReranker();
|
|
189
|
+
const documents = [
|
|
190
|
+
{ id: 'doc-0', text: 'function handleRequest() {}' },
|
|
191
|
+
{ id: 'doc-1', text: 'function processData() {}' },
|
|
192
|
+
];
|
|
193
|
+
await reranker.rerank('handle request', documents);
|
|
194
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
195
|
+
const callArgs = fetchSpy.mock.calls[0];
|
|
196
|
+
expect(callArgs[0]).toBe('https://api.voyageai.com/v1/rerank');
|
|
197
|
+
const body = JSON.parse(callArgs[1].body);
|
|
198
|
+
expect(body.model).toBe('rerank-2');
|
|
199
|
+
expect(body.query).toBe('handle request');
|
|
200
|
+
expect(body.documents).toEqual([
|
|
201
|
+
'function handleRequest() {}',
|
|
202
|
+
'function processData() {}',
|
|
203
|
+
]);
|
|
204
|
+
expect(body.top_k).toBe(2);
|
|
205
|
+
});
|
|
206
|
+
it('parses response correctly and maps to RerankResult[]', async () => {
|
|
207
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(200, {
|
|
208
|
+
data: [
|
|
209
|
+
{ index: 1, relevance_score: 0.92 },
|
|
210
|
+
{ index: 0, relevance_score: 0.68 },
|
|
211
|
+
],
|
|
212
|
+
}));
|
|
213
|
+
const reranker = new VoyageReranker();
|
|
214
|
+
const documents = [
|
|
215
|
+
{ id: 'doc-a', text: 'first document' },
|
|
216
|
+
{ id: 'doc-b', text: 'second document' },
|
|
217
|
+
];
|
|
218
|
+
const results = await reranker.rerank('query', documents);
|
|
219
|
+
expect(results).toHaveLength(2);
|
|
220
|
+
expect(results[0]).toEqual({ id: 'doc-b', score: 0.92, index: 1 });
|
|
221
|
+
expect(results[1]).toEqual({ id: 'doc-a', score: 0.68, index: 0 });
|
|
222
|
+
});
|
|
223
|
+
it('returns empty array for empty documents', async () => {
|
|
224
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
225
|
+
const reranker = new VoyageReranker();
|
|
226
|
+
const results = await reranker.rerank('query', []);
|
|
227
|
+
expect(results).toHaveLength(0);
|
|
228
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
it('retries on 429', async () => {
|
|
231
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
|
232
|
+
.mockResolvedValueOnce(mockFetchResponse(429, { detail: 'Rate limited' }))
|
|
233
|
+
.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
234
|
+
data: [{ index: 0, relevance_score: 0.9 }],
|
|
235
|
+
}));
|
|
236
|
+
const reranker = new VoyageReranker({ maxRetries: 3 });
|
|
237
|
+
vi.spyOn(reranker, 'sleep').mockResolvedValue(undefined);
|
|
238
|
+
const results = await reranker.rerank('query', [{ id: 'a', text: 'doc' }]);
|
|
239
|
+
expect(results).toHaveLength(1);
|
|
240
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
241
|
+
});
|
|
242
|
+
it('retries on 5xx', async () => {
|
|
243
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
|
244
|
+
.mockResolvedValueOnce(mockFetchResponse(503, { detail: 'Service unavailable' }))
|
|
245
|
+
.mockResolvedValueOnce(mockFetchResponse(200, {
|
|
246
|
+
data: [{ index: 0, relevance_score: 0.85 }],
|
|
247
|
+
}));
|
|
248
|
+
const reranker = new VoyageReranker({ maxRetries: 3 });
|
|
249
|
+
vi.spyOn(reranker, 'sleep').mockResolvedValue(undefined);
|
|
250
|
+
const results = await reranker.rerank('query', [{ id: 'a', text: 'doc' }]);
|
|
251
|
+
expect(results).toHaveLength(1);
|
|
252
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
253
|
+
});
|
|
254
|
+
it('throws after exhausting retries', async () => {
|
|
255
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockFetchResponse(429, { detail: 'Rate limited' }));
|
|
256
|
+
const reranker = new VoyageReranker({ maxRetries: 2 });
|
|
257
|
+
vi.spyOn(reranker, 'sleep').mockResolvedValue(undefined);
|
|
258
|
+
await expect(reranker.rerank('query', [{ id: 'a', text: 'doc' }])).rejects.toThrow('429');
|
|
259
|
+
});
|
|
260
|
+
it('throws immediately on non-retryable errors', async () => {
|
|
261
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(401, { detail: 'Invalid API key' }));
|
|
262
|
+
const reranker = new VoyageReranker();
|
|
263
|
+
await expect(reranker.rerank('query', [{ id: 'a', text: 'doc' }])).rejects.toThrow('Invalid API key');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
// ── HybridSearchEngine + Reranker Integration ──────────────────────
|
|
267
|
+
describe('HybridSearchEngine with reranker', () => {
|
|
268
|
+
function createMockRerankerProvider(results) {
|
|
269
|
+
return {
|
|
270
|
+
model: 'mock-reranker',
|
|
271
|
+
rerank: vi.fn().mockResolvedValue((results ?? []).map(r => ({
|
|
272
|
+
id: `${r.index}`,
|
|
273
|
+
score: r.score,
|
|
274
|
+
index: r.index,
|
|
275
|
+
}))),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function createFailingRerankerProvider() {
|
|
279
|
+
return {
|
|
280
|
+
model: 'failing-reranker',
|
|
281
|
+
rerank: vi.fn().mockRejectedValue(new Error('Reranker failed')),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
it('reranks top candidates and returns reranked scores', async () => {
|
|
285
|
+
const symbols = [
|
|
286
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', {
|
|
287
|
+
line: 1,
|
|
288
|
+
language: 'typescript',
|
|
289
|
+
signature: 'function handleRequest(req: Request): Response',
|
|
290
|
+
documentation: 'Handles incoming HTTP requests',
|
|
291
|
+
}),
|
|
292
|
+
makeSymbol('processData', 'function', 'src/data.ts', {
|
|
293
|
+
line: 1,
|
|
294
|
+
language: 'typescript',
|
|
295
|
+
signature: 'function processData(data: unknown): void',
|
|
296
|
+
}),
|
|
297
|
+
makeSymbol('handleError', 'function', 'src/error.ts', {
|
|
298
|
+
line: 1,
|
|
299
|
+
language: 'typescript',
|
|
300
|
+
}),
|
|
301
|
+
];
|
|
302
|
+
const engine = new SearchEngine();
|
|
303
|
+
engine.buildIndex(symbols);
|
|
304
|
+
const mockProvider = createMockRerankerProvider([
|
|
305
|
+
{ index: 2, score: 0.99 }, // handleError gets highest rerank score
|
|
306
|
+
{ index: 0, score: 0.85 }, // handleRequest
|
|
307
|
+
{ index: 1, score: 0.60 }, // processData
|
|
308
|
+
]);
|
|
309
|
+
const rerankerConfig = {
|
|
310
|
+
enabled: true,
|
|
311
|
+
provider: mockProvider,
|
|
312
|
+
topN: 10,
|
|
313
|
+
candidatePool: 50,
|
|
314
|
+
};
|
|
315
|
+
const hybrid = new HybridSearchEngine(engine, null, null, {}, rerankerConfig);
|
|
316
|
+
const results = await hybrid.search({ query: 'handle', maxResults: 20 });
|
|
317
|
+
// Reranker should have been called
|
|
318
|
+
expect(mockProvider.rerank).toHaveBeenCalledTimes(1);
|
|
319
|
+
// Results should have rerankScore set
|
|
320
|
+
for (const r of results) {
|
|
321
|
+
expect(r.rerankScore).toBeDefined();
|
|
322
|
+
}
|
|
323
|
+
// Results should be sorted by rerank score descending
|
|
324
|
+
if (results.length >= 2) {
|
|
325
|
+
expect(results[0].score).toBeGreaterThanOrEqual(results[1].score);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
it('passes through results when reranker is disabled', async () => {
|
|
329
|
+
const symbols = [
|
|
330
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
331
|
+
makeSymbol('processData', 'function', 'src/data.ts', { line: 1, language: 'typescript' }),
|
|
332
|
+
];
|
|
333
|
+
const engine = new SearchEngine();
|
|
334
|
+
engine.buildIndex(symbols);
|
|
335
|
+
const mockProvider = createMockRerankerProvider();
|
|
336
|
+
const rerankerConfig = {
|
|
337
|
+
enabled: false,
|
|
338
|
+
provider: mockProvider,
|
|
339
|
+
topN: 10,
|
|
340
|
+
candidatePool: 50,
|
|
341
|
+
};
|
|
342
|
+
const hybrid = new HybridSearchEngine(engine, null, null, {}, rerankerConfig);
|
|
343
|
+
const results = await hybrid.search({ query: 'handle', maxResults: 20 });
|
|
344
|
+
// Reranker should NOT have been called
|
|
345
|
+
expect(mockProvider.rerank).not.toHaveBeenCalled();
|
|
346
|
+
// Results should not have rerankScore
|
|
347
|
+
for (const r of results) {
|
|
348
|
+
expect(r.rerankScore).toBeUndefined();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
it('gracefully falls back when reranker errors', async () => {
|
|
352
|
+
const symbols = [
|
|
353
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
354
|
+
];
|
|
355
|
+
const engine = new SearchEngine();
|
|
356
|
+
engine.buildIndex(symbols);
|
|
357
|
+
const failingProvider = createFailingRerankerProvider();
|
|
358
|
+
const rerankerConfig = {
|
|
359
|
+
enabled: true,
|
|
360
|
+
provider: failingProvider,
|
|
361
|
+
topN: 10,
|
|
362
|
+
candidatePool: 50,
|
|
363
|
+
};
|
|
364
|
+
const hybrid = new HybridSearchEngine(engine, null, null, {}, rerankerConfig);
|
|
365
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 20 });
|
|
366
|
+
// Should return results despite reranker failure
|
|
367
|
+
expect(results.length).toBeGreaterThan(0);
|
|
368
|
+
expect(results[0].symbol.name).toBe('handleRequest');
|
|
369
|
+
// Results should not have rerankScore (fallback path)
|
|
370
|
+
expect(results[0].rerankScore).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
it('passes through results when rerankerConfig is null', async () => {
|
|
373
|
+
const symbols = [
|
|
374
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
375
|
+
];
|
|
376
|
+
const engine = new SearchEngine();
|
|
377
|
+
engine.buildIndex(symbols);
|
|
378
|
+
const hybrid = new HybridSearchEngine(engine, null, null, {}, null);
|
|
379
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 20 });
|
|
380
|
+
expect(results.length).toBeGreaterThan(0);
|
|
381
|
+
expect(results[0].rerankScore).toBeUndefined();
|
|
382
|
+
});
|
|
383
|
+
it('respects topN limit from reranker config', async () => {
|
|
384
|
+
const symbols = [
|
|
385
|
+
makeSymbol('a', 'function', 'a.ts', { line: 1, language: 'typescript' }),
|
|
386
|
+
makeSymbol('ab', 'function', 'ab.ts', { line: 1, language: 'typescript' }),
|
|
387
|
+
makeSymbol('abc', 'function', 'abc.ts', { line: 1, language: 'typescript' }),
|
|
388
|
+
makeSymbol('abcd', 'function', 'abcd.ts', { line: 1, language: 'typescript' }),
|
|
389
|
+
makeSymbol('abcde', 'function', 'abcde.ts', { line: 1, language: 'typescript' }),
|
|
390
|
+
];
|
|
391
|
+
const engine = new SearchEngine();
|
|
392
|
+
engine.buildIndex(symbols);
|
|
393
|
+
const mockProvider = createMockRerankerProvider([
|
|
394
|
+
{ index: 0, score: 0.9 },
|
|
395
|
+
{ index: 1, score: 0.8 },
|
|
396
|
+
{ index: 2, score: 0.7 },
|
|
397
|
+
{ index: 3, score: 0.6 },
|
|
398
|
+
{ index: 4, score: 0.5 },
|
|
399
|
+
]);
|
|
400
|
+
const rerankerConfig = {
|
|
401
|
+
enabled: true,
|
|
402
|
+
provider: mockProvider,
|
|
403
|
+
topN: 2,
|
|
404
|
+
candidatePool: 50,
|
|
405
|
+
};
|
|
406
|
+
const hybrid = new HybridSearchEngine(engine, null, null, {}, rerankerConfig);
|
|
407
|
+
const results = await hybrid.search({ query: 'a', maxResults: 20 });
|
|
408
|
+
expect(results.length).toBeLessThanOrEqual(2);
|
|
409
|
+
});
|
|
410
|
+
it('respects candidatePool limit', async () => {
|
|
411
|
+
// Create many symbols that will all match the query "handle"
|
|
412
|
+
const symbols = Array.from({ length: 20 }, (_, i) => makeSymbol(`handleFunc${i}`, 'function', `src/f${i}.ts`, { line: 1, language: 'typescript' }));
|
|
413
|
+
const engine = new SearchEngine();
|
|
414
|
+
engine.buildIndex(symbols);
|
|
415
|
+
// Reranker returns results for all docs it receives
|
|
416
|
+
const mockProvider = {
|
|
417
|
+
model: 'mock-reranker',
|
|
418
|
+
rerank: vi.fn().mockImplementation((_query, docs) => {
|
|
419
|
+
return docs.map((d, i) => ({
|
|
420
|
+
id: d.id,
|
|
421
|
+
score: 0.9 - i * 0.01,
|
|
422
|
+
index: i,
|
|
423
|
+
}));
|
|
424
|
+
}),
|
|
425
|
+
};
|
|
426
|
+
const rerankerConfig = {
|
|
427
|
+
enabled: true,
|
|
428
|
+
provider: mockProvider,
|
|
429
|
+
topN: 10,
|
|
430
|
+
candidatePool: 5,
|
|
431
|
+
};
|
|
432
|
+
const hybrid = new HybridSearchEngine(engine, null, null, {}, rerankerConfig);
|
|
433
|
+
await hybrid.search({ query: 'handle', maxResults: 20 });
|
|
434
|
+
// The reranker should have been called
|
|
435
|
+
expect(mockProvider.rerank).toHaveBeenCalledTimes(1);
|
|
436
|
+
// The reranker should have been called with at most candidatePool documents
|
|
437
|
+
const rerankCall = mockProvider.rerank.mock.calls[0];
|
|
438
|
+
const docsPassedToReranker = rerankCall[1];
|
|
439
|
+
expect(docsPassedToReranker.length).toBeLessThanOrEqual(5);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
// ── Document preparation ────────────────────────────────────────────
|
|
443
|
+
describe('Document preparation from SearchResult', () => {
|
|
444
|
+
it('includes signature and documentation in rerank text', async () => {
|
|
445
|
+
const symbols = [
|
|
446
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', {
|
|
447
|
+
line: 1,
|
|
448
|
+
language: 'typescript',
|
|
449
|
+
signature: 'function handleRequest(req: Request): Response',
|
|
450
|
+
documentation: 'Handles incoming HTTP requests and returns a Response object.',
|
|
451
|
+
}),
|
|
452
|
+
];
|
|
453
|
+
const engine = new SearchEngine();
|
|
454
|
+
engine.buildIndex(symbols);
|
|
455
|
+
const mockProvider = {
|
|
456
|
+
model: 'mock',
|
|
457
|
+
rerank: vi.fn().mockImplementation((_query, docs) => {
|
|
458
|
+
// Verify the document text includes signature, docs, name, and kind
|
|
459
|
+
const text = docs[0].text;
|
|
460
|
+
expect(text).toContain('function handleRequest(req: Request): Response');
|
|
461
|
+
expect(text).toContain('Handles incoming HTTP requests');
|
|
462
|
+
expect(text).toContain('function handleRequest');
|
|
463
|
+
return [{ id: docs[0].id, score: 0.9, index: 0 }];
|
|
464
|
+
}),
|
|
465
|
+
};
|
|
466
|
+
const rerankerConfig = {
|
|
467
|
+
enabled: true,
|
|
468
|
+
provider: mockProvider,
|
|
469
|
+
topN: 10,
|
|
470
|
+
candidatePool: 50,
|
|
471
|
+
};
|
|
472
|
+
const hybrid = new HybridSearchEngine(engine, null, null, {}, rerankerConfig);
|
|
473
|
+
await hybrid.search({ query: 'handleRequest', maxResults: 10 });
|
|
474
|
+
expect(mockProvider.rerank).toHaveBeenCalledTimes(1);
|
|
475
|
+
});
|
|
476
|
+
it('includes name and kind when no signature or documentation', async () => {
|
|
477
|
+
const symbols = [
|
|
478
|
+
makeSymbol('processData', 'function', 'src/data.ts', {
|
|
479
|
+
line: 1,
|
|
480
|
+
language: 'typescript',
|
|
481
|
+
}),
|
|
482
|
+
];
|
|
483
|
+
const engine = new SearchEngine();
|
|
484
|
+
engine.buildIndex(symbols);
|
|
485
|
+
const mockProvider = {
|
|
486
|
+
model: 'mock',
|
|
487
|
+
rerank: vi.fn().mockImplementation((_query, docs) => {
|
|
488
|
+
const text = docs[0].text;
|
|
489
|
+
expect(text).toContain('function processData');
|
|
490
|
+
return [{ id: docs[0].id, score: 0.8, index: 0 }];
|
|
491
|
+
}),
|
|
492
|
+
};
|
|
493
|
+
const rerankerConfig = {
|
|
494
|
+
enabled: true,
|
|
495
|
+
provider: mockProvider,
|
|
496
|
+
topN: 10,
|
|
497
|
+
candidatePool: 50,
|
|
498
|
+
};
|
|
499
|
+
const hybrid = new HybridSearchEngine(engine, null, null, {}, rerankerConfig);
|
|
500
|
+
await hybrid.search({ query: 'processData', maxResults: 10 });
|
|
501
|
+
expect(mockProvider.rerank).toHaveBeenCalledTimes(1);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-encoder reranker using the Cohere Rerank API.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Model: rerank-v3.5
|
|
6
|
+
* - Exponential backoff retry on 429 / 5xx errors
|
|
7
|
+
* - Uses Node.js built-in fetch
|
|
8
|
+
*
|
|
9
|
+
* Requires the COHERE_API_KEY environment variable.
|
|
10
|
+
*/
|
|
11
|
+
import type { RerankerProvider, RerankDocument, RerankResult } from './reranker-provider.js';
|
|
12
|
+
export declare class CohereReranker implements RerankerProvider {
|
|
13
|
+
readonly model: string;
|
|
14
|
+
private maxRetries;
|
|
15
|
+
private apiKey;
|
|
16
|
+
constructor(config?: {
|
|
17
|
+
model?: string;
|
|
18
|
+
maxRetries?: number;
|
|
19
|
+
});
|
|
20
|
+
rerank(query: string, documents: RerankDocument[]): Promise<RerankResult[]>;
|
|
21
|
+
private callAPI;
|
|
22
|
+
/** Calculate retry delay with exponential backoff, respecting Retry-After header. */
|
|
23
|
+
private getRetryDelay;
|
|
24
|
+
private sleep;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=cohere-reranker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cohere-reranker.d.ts","sourceRoot":"","sources":["../../../src/reranking/cohere-reranker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AAc5F,qBAAa,cAAe,YAAW,gBAAgB;IACrD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,MAAM,CAAQ;gBAEV,MAAM,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAO;IAa1D,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;YAmBnE,OAAO;IAuDrB,qFAAqF;IACrF,OAAO,CAAC,aAAa;IAWrB,OAAO,CAAC,KAAK;CAGd"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-encoder reranker using the Cohere Rerank API.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Model: rerank-v3.5
|
|
6
|
+
* - Exponential backoff retry on 429 / 5xx errors
|
|
7
|
+
* - Uses Node.js built-in fetch
|
|
8
|
+
*
|
|
9
|
+
* Requires the COHERE_API_KEY environment variable.
|
|
10
|
+
*/
|
|
11
|
+
const COHERE_RERANK_URL = 'https://api.cohere.com/v2/rerank';
|
|
12
|
+
const DEFAULT_MODEL = 'rerank-v3.5';
|
|
13
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
14
|
+
const BASE_RETRY_DELAY_MS = 1000;
|
|
15
|
+
export class CohereReranker {
|
|
16
|
+
model;
|
|
17
|
+
maxRetries;
|
|
18
|
+
apiKey;
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
const apiKey = process.env.COHERE_API_KEY;
|
|
21
|
+
if (!apiKey) {
|
|
22
|
+
throw new Error('COHERE_API_KEY environment variable is required. '
|
|
23
|
+
+ 'Get your API key at https://dashboard.cohere.com/');
|
|
24
|
+
}
|
|
25
|
+
this.apiKey = apiKey;
|
|
26
|
+
this.model = config.model ?? DEFAULT_MODEL;
|
|
27
|
+
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
28
|
+
}
|
|
29
|
+
async rerank(query, documents) {
|
|
30
|
+
if (documents.length === 0)
|
|
31
|
+
return [];
|
|
32
|
+
const body = JSON.stringify({
|
|
33
|
+
model: this.model,
|
|
34
|
+
query,
|
|
35
|
+
documents: documents.map(d => d.text),
|
|
36
|
+
top_n: documents.length,
|
|
37
|
+
});
|
|
38
|
+
const response = await this.callAPI(body);
|
|
39
|
+
return response.results.map(r => ({
|
|
40
|
+
id: documents[r.index].id,
|
|
41
|
+
score: r.relevance_score,
|
|
42
|
+
index: r.index,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
async callAPI(body) {
|
|
46
|
+
let lastError;
|
|
47
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(COHERE_RERANK_URL, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
54
|
+
},
|
|
55
|
+
body,
|
|
56
|
+
});
|
|
57
|
+
if (response.ok) {
|
|
58
|
+
return (await response.json());
|
|
59
|
+
}
|
|
60
|
+
// Retry on rate limit or server errors
|
|
61
|
+
if (response.status === 429 || response.status >= 500) {
|
|
62
|
+
const errorBody = await response.text();
|
|
63
|
+
lastError = new Error(`Cohere Rerank API returned ${response.status}: ${errorBody}`);
|
|
64
|
+
if (attempt < this.maxRetries) {
|
|
65
|
+
await this.sleep(this.getRetryDelay(attempt, response));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Non-retryable error
|
|
71
|
+
const errorBody = await response.text();
|
|
72
|
+
let message;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(errorBody);
|
|
75
|
+
message = parsed.message ?? errorBody;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
message = errorBody;
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`Cohere Rerank API error (${response.status}): ${message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof Error && error.message.startsWith('Cohere Rerank API error')) {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
88
|
+
if (attempt < this.maxRetries) {
|
|
89
|
+
await this.sleep(BASE_RETRY_DELAY_MS * Math.pow(2, attempt));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw lastError ?? new Error('Cohere Rerank API request failed after retries');
|
|
95
|
+
}
|
|
96
|
+
/** Calculate retry delay with exponential backoff, respecting Retry-After header. */
|
|
97
|
+
getRetryDelay(attempt, response) {
|
|
98
|
+
const retryAfter = response.headers.get('retry-after');
|
|
99
|
+
if (retryAfter) {
|
|
100
|
+
const seconds = Number(retryAfter);
|
|
101
|
+
if (!Number.isNaN(seconds)) {
|
|
102
|
+
return seconds * 1000;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
106
|
+
}
|
|
107
|
+
sleep(ms) {
|
|
108
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract interfaces for cross-encoder reranking providers.
|
|
3
|
+
* Rerankers rescore search results using a cross-encoder model
|
|
4
|
+
* for higher-precision final rankings.
|
|
5
|
+
*/
|
|
6
|
+
/** A document to be reranked against a query. */
|
|
7
|
+
export interface RerankDocument {
|
|
8
|
+
/** Unique identifier for the document. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Document content to score against the query. */
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
/** A single reranking result with relevance score. */
|
|
14
|
+
export interface RerankResult {
|
|
15
|
+
/** Unique identifier matching the input document. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** Relevance score in [0, 1]. */
|
|
18
|
+
score: number;
|
|
19
|
+
/** Original position in the input documents array. */
|
|
20
|
+
index: number;
|
|
21
|
+
}
|
|
22
|
+
/** Abstract interface for cross-encoder reranking providers. */
|
|
23
|
+
export interface RerankerProvider {
|
|
24
|
+
/** The model name used for reranking. */
|
|
25
|
+
readonly model: string;
|
|
26
|
+
/** Rerank documents against a query, returning results sorted by relevance. */
|
|
27
|
+
rerank(query: string, documents: RerankDocument[]): Promise<RerankResult[]>;
|
|
28
|
+
}
|
|
29
|
+
/** Configuration for the reranking stage in the hybrid search pipeline. */
|
|
30
|
+
export interface RerankerConfig {
|
|
31
|
+
/** Feature flag — set to false to disable reranking. Default: true */
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
/** The reranking provider to use. */
|
|
34
|
+
provider: RerankerProvider;
|
|
35
|
+
/** Return top N results after reranking. Default: 10 */
|
|
36
|
+
topN: number;
|
|
37
|
+
/** Feed top N candidates from hybrid search to the reranker. Default: 50 */
|
|
38
|
+
candidatePool: number;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=reranker-provider.d.ts.map
|