@renseiai/agentfactory-code-intelligence 0.8.7 → 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 @@
|
|
|
1
|
+
{"version":3,"file":"reranker-provider.d.ts","sourceRoot":"","sources":["../../../src/reranking/reranker-provider.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,iDAAiD;AACjD,MAAM,WAAW,cAAc;IAC7B,0CAA0C;IAC1C,EAAE,EAAE,MAAM,CAAA;IACV,mDAAmD;IACnD,IAAI,EAAE,MAAM,CAAA;CACb;AAED,sDAAsD;AACtD,MAAM,WAAW,YAAY;IAC3B,qDAAqD;IACrD,EAAE,EAAE,MAAM,CAAA;IACV,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAA;CACd;AAID,gEAAgE;AAChE,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,+EAA+E;IAC/E,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;CAC5E;AAID,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,sEAAsE;IACtE,OAAO,EAAE,OAAO,CAAA;IAChB,qCAAqC;IACrC,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAA;IACZ,4EAA4E;IAC5E,aAAa,EAAE,MAAM,CAAA;CACtB"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-encoder reranker using the Voyage AI Rerank API.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Model: rerank-2
|
|
6
|
+
* - Exponential backoff retry on 429 / 5xx errors
|
|
7
|
+
* - Uses Node.js built-in fetch
|
|
8
|
+
* - Shares VOYAGE_API_KEY with the Voyage embedding provider
|
|
9
|
+
*
|
|
10
|
+
* Requires the VOYAGE_API_KEY environment variable.
|
|
11
|
+
*/
|
|
12
|
+
import type { RerankerProvider, RerankDocument, RerankResult } from './reranker-provider.js';
|
|
13
|
+
export declare class VoyageReranker implements RerankerProvider {
|
|
14
|
+
readonly model: string;
|
|
15
|
+
private maxRetries;
|
|
16
|
+
private apiKey;
|
|
17
|
+
constructor(config?: {
|
|
18
|
+
model?: string;
|
|
19
|
+
maxRetries?: number;
|
|
20
|
+
});
|
|
21
|
+
rerank(query: string, documents: RerankDocument[]): Promise<RerankResult[]>;
|
|
22
|
+
private callAPI;
|
|
23
|
+
/** Calculate retry delay with exponential backoff, respecting Retry-After header. */
|
|
24
|
+
private getRetryDelay;
|
|
25
|
+
private sleep;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=voyage-reranker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voyage-reranker.d.ts","sourceRoot":"","sources":["../../../src/reranking/voyage-reranker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-encoder reranker using the Voyage AI Rerank API.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Model: rerank-2
|
|
6
|
+
* - Exponential backoff retry on 429 / 5xx errors
|
|
7
|
+
* - Uses Node.js built-in fetch
|
|
8
|
+
* - Shares VOYAGE_API_KEY with the Voyage embedding provider
|
|
9
|
+
*
|
|
10
|
+
* Requires the VOYAGE_API_KEY environment variable.
|
|
11
|
+
*/
|
|
12
|
+
const VOYAGE_RERANK_URL = 'https://api.voyageai.com/v1/rerank';
|
|
13
|
+
const DEFAULT_MODEL = 'rerank-2';
|
|
14
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
15
|
+
const BASE_RETRY_DELAY_MS = 1000;
|
|
16
|
+
export class VoyageReranker {
|
|
17
|
+
model;
|
|
18
|
+
maxRetries;
|
|
19
|
+
apiKey;
|
|
20
|
+
constructor(config = {}) {
|
|
21
|
+
const apiKey = process.env.VOYAGE_API_KEY;
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
throw new Error('VOYAGE_API_KEY environment variable is required. '
|
|
24
|
+
+ 'Get your API key at https://dash.voyageai.com/');
|
|
25
|
+
}
|
|
26
|
+
this.apiKey = apiKey;
|
|
27
|
+
this.model = config.model ?? DEFAULT_MODEL;
|
|
28
|
+
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
29
|
+
}
|
|
30
|
+
async rerank(query, documents) {
|
|
31
|
+
if (documents.length === 0)
|
|
32
|
+
return [];
|
|
33
|
+
const body = JSON.stringify({
|
|
34
|
+
model: this.model,
|
|
35
|
+
query,
|
|
36
|
+
documents: documents.map(d => d.text),
|
|
37
|
+
top_k: documents.length,
|
|
38
|
+
});
|
|
39
|
+
const response = await this.callAPI(body);
|
|
40
|
+
return response.data.map(r => ({
|
|
41
|
+
id: documents[r.index].id,
|
|
42
|
+
score: r.relevance_score,
|
|
43
|
+
index: r.index,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
async callAPI(body) {
|
|
47
|
+
let lastError;
|
|
48
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(VOYAGE_RERANK_URL, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
55
|
+
},
|
|
56
|
+
body,
|
|
57
|
+
});
|
|
58
|
+
if (response.ok) {
|
|
59
|
+
return (await response.json());
|
|
60
|
+
}
|
|
61
|
+
// Retry on rate limit or server errors
|
|
62
|
+
if (response.status === 429 || response.status >= 500) {
|
|
63
|
+
const errorBody = await response.text();
|
|
64
|
+
lastError = new Error(`Voyage Rerank API returned ${response.status}: ${errorBody}`);
|
|
65
|
+
if (attempt < this.maxRetries) {
|
|
66
|
+
await this.sleep(this.getRetryDelay(attempt, response));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Non-retryable error
|
|
72
|
+
const errorBody = await response.text();
|
|
73
|
+
let message;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(errorBody);
|
|
76
|
+
message = parsed.detail ?? parsed.message ?? errorBody;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
message = errorBody;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Voyage Rerank API error (${response.status}): ${message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error instanceof Error && error.message.startsWith('Voyage Rerank API error')) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
89
|
+
if (attempt < this.maxRetries) {
|
|
90
|
+
await this.sleep(BASE_RETRY_DELAY_MS * Math.pow(2, attempt));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw lastError ?? new Error('Voyage Rerank API request failed after retries');
|
|
96
|
+
}
|
|
97
|
+
/** Calculate retry delay with exponential backoff, respecting Retry-After header. */
|
|
98
|
+
getRetryDelay(attempt, response) {
|
|
99
|
+
const retryAfter = response.headers.get('retry-after');
|
|
100
|
+
if (retryAfter) {
|
|
101
|
+
const seconds = Number(retryAfter);
|
|
102
|
+
if (!Number.isNaN(seconds)) {
|
|
103
|
+
return seconds * 1000;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
107
|
+
}
|
|
108
|
+
sleep(ms) {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hybrid-search.test.d.ts","sourceRoot":"","sources":["../../../../src/search/__tests__/hybrid-search.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { SearchEngine } from '../search-engine.js';
|
|
3
|
+
import { HybridSearchEngine } from '../hybrid-search.js';
|
|
4
|
+
import { minMaxNormalize } from '../score-normalizer.js';
|
|
5
|
+
import { classifyQuery } from '../query-classifier.js';
|
|
6
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
7
|
+
function makeSymbol(name, kind, filePath, extra) {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
kind: kind,
|
|
11
|
+
filePath,
|
|
12
|
+
line: extra?.line ?? 1,
|
|
13
|
+
exported: true,
|
|
14
|
+
...extra,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function makeChunk(filePath, symbolName, startLine, embedding) {
|
|
18
|
+
return {
|
|
19
|
+
id: `${filePath}:${symbolName}:${startLine}`,
|
|
20
|
+
content: `function ${symbolName}() {}`,
|
|
21
|
+
embedding,
|
|
22
|
+
metadata: {
|
|
23
|
+
filePath,
|
|
24
|
+
symbolName,
|
|
25
|
+
symbolKind: 'function',
|
|
26
|
+
startLine,
|
|
27
|
+
endLine: startLine + 10,
|
|
28
|
+
language: 'typescript',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function createMockVectorStore(results) {
|
|
33
|
+
return {
|
|
34
|
+
insert: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
search: vi.fn().mockResolvedValue(results),
|
|
36
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
size: vi.fn().mockReturnValue(results.length > 0 ? 100 : 0),
|
|
38
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
load: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
clear: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function createMockEmbeddingProvider() {
|
|
44
|
+
return {
|
|
45
|
+
model: 'test-model',
|
|
46
|
+
dimensions: 128,
|
|
47
|
+
embed: vi.fn().mockResolvedValue([[0.1, 0.2, 0.3]]),
|
|
48
|
+
embedQuery: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ── Score Normalizer Tests ──────────────────────────────────────────
|
|
52
|
+
describe('minMaxNormalize', () => {
|
|
53
|
+
it('normalizes a range of scores to [0, 1]', () => {
|
|
54
|
+
const result = minMaxNormalize([2, 5, 8]);
|
|
55
|
+
expect(result[0]).toBeCloseTo(0.0); // min
|
|
56
|
+
expect(result[1]).toBeCloseTo(0.5); // mid
|
|
57
|
+
expect(result[2]).toBeCloseTo(1.0); // max
|
|
58
|
+
});
|
|
59
|
+
it('returns empty array for empty input', () => {
|
|
60
|
+
expect(minMaxNormalize([])).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
it('returns [1.0] for single result', () => {
|
|
63
|
+
expect(minMaxNormalize([42])).toEqual([1.0]);
|
|
64
|
+
});
|
|
65
|
+
it('returns all 1.0 when all scores are the same', () => {
|
|
66
|
+
const result = minMaxNormalize([5, 5, 5]);
|
|
67
|
+
expect(result).toEqual([1.0, 1.0, 1.0]);
|
|
68
|
+
});
|
|
69
|
+
it('handles negative scores', () => {
|
|
70
|
+
const result = minMaxNormalize([-10, 0, 10]);
|
|
71
|
+
expect(result[0]).toBeCloseTo(0.0);
|
|
72
|
+
expect(result[1]).toBeCloseTo(0.5);
|
|
73
|
+
expect(result[2]).toBeCloseTo(1.0);
|
|
74
|
+
});
|
|
75
|
+
it('handles two scores', () => {
|
|
76
|
+
const result = minMaxNormalize([3, 7]);
|
|
77
|
+
expect(result[0]).toBeCloseTo(0.0);
|
|
78
|
+
expect(result[1]).toBeCloseTo(1.0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
// ── CCS Fusion Math Tests ───────────────────────────────────────────
|
|
82
|
+
describe('HybridSearchEngine — CCS fusion', () => {
|
|
83
|
+
it('computes correct CCS score with manual calculation', async () => {
|
|
84
|
+
// Setup: 2 symbols that appear in both BM25 and vector results
|
|
85
|
+
const symbols = [
|
|
86
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
87
|
+
makeSymbol('processData', 'function', 'src/data.ts', { line: 5, language: 'typescript' }),
|
|
88
|
+
];
|
|
89
|
+
const engine = new SearchEngine();
|
|
90
|
+
engine.buildIndex(symbols);
|
|
91
|
+
// Vector results matching the same symbols
|
|
92
|
+
const vectorResults = [
|
|
93
|
+
{ chunk: makeChunk('src/data.ts', 'processData', 5), score: 0.95 },
|
|
94
|
+
{ chunk: makeChunk('src/api.ts', 'handleRequest', 1), score: 0.70 },
|
|
95
|
+
];
|
|
96
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
97
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
98
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
99
|
+
alpha: 0.5,
|
|
100
|
+
adaptiveAlpha: false,
|
|
101
|
+
fusionMethod: 'ccs',
|
|
102
|
+
});
|
|
103
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 10 });
|
|
104
|
+
// Both results should have hybrid matchType since they appear in both BM25 and vector
|
|
105
|
+
const handleResult = results.find(r => r.symbol.name === 'handleRequest');
|
|
106
|
+
expect(handleResult).toBeDefined();
|
|
107
|
+
expect(handleResult.bm25Score).toBeDefined();
|
|
108
|
+
expect(handleResult.vectorScore).toBeDefined();
|
|
109
|
+
expect(handleResult.matchType).toBe('hybrid');
|
|
110
|
+
// Score should be between 0 and 1 (normalized fusion)
|
|
111
|
+
expect(handleResult.score).toBeGreaterThanOrEqual(0);
|
|
112
|
+
expect(handleResult.score).toBeLessThanOrEqual(1);
|
|
113
|
+
});
|
|
114
|
+
it('produces correct fusion with alpha=0 (BM25-only weight)', async () => {
|
|
115
|
+
const symbols = [
|
|
116
|
+
makeSymbol('foo', 'function', 'a.ts', { line: 1, language: 'typescript' }),
|
|
117
|
+
];
|
|
118
|
+
const engine = new SearchEngine();
|
|
119
|
+
engine.buildIndex(symbols);
|
|
120
|
+
const vectorResults = [
|
|
121
|
+
{ chunk: makeChunk('a.ts', 'foo', 1), score: 0.9 },
|
|
122
|
+
];
|
|
123
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
124
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
125
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
126
|
+
alpha: 0,
|
|
127
|
+
adaptiveAlpha: false,
|
|
128
|
+
fusionMethod: 'ccs',
|
|
129
|
+
});
|
|
130
|
+
const results = await hybrid.search({ query: 'foo', maxResults: 10 });
|
|
131
|
+
// With alpha=0, vector component should be 0 — score comes entirely from BM25
|
|
132
|
+
// Single BM25 result normalizes to 1.0, so score = (1-0)*1.0 + 0*norm_vector = 1.0
|
|
133
|
+
const fooResult = results.find(r => r.symbol.name === 'foo');
|
|
134
|
+
expect(fooResult).toBeDefined();
|
|
135
|
+
expect(fooResult.score).toBeCloseTo(1.0);
|
|
136
|
+
});
|
|
137
|
+
it('produces correct fusion with alpha=1 (vector-only weight)', async () => {
|
|
138
|
+
const symbols = [
|
|
139
|
+
makeSymbol('foo', 'function', 'a.ts', { line: 1, language: 'typescript' }),
|
|
140
|
+
];
|
|
141
|
+
const engine = new SearchEngine();
|
|
142
|
+
engine.buildIndex(symbols);
|
|
143
|
+
const vectorResults = [
|
|
144
|
+
{ chunk: makeChunk('a.ts', 'foo', 1), score: 0.85 },
|
|
145
|
+
];
|
|
146
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
147
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
148
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
149
|
+
alpha: 1,
|
|
150
|
+
adaptiveAlpha: false,
|
|
151
|
+
fusionMethod: 'ccs',
|
|
152
|
+
});
|
|
153
|
+
const results = await hybrid.search({ query: 'foo', maxResults: 10 });
|
|
154
|
+
// With alpha=1, BM25 component should be 0 — score comes entirely from vector
|
|
155
|
+
// Single vector result normalizes to 1.0, so score = 1*1.0 + 0*bm25 = 1.0
|
|
156
|
+
const fooResult = results.find(r => r.symbol.name === 'foo');
|
|
157
|
+
expect(fooResult).toBeDefined();
|
|
158
|
+
expect(fooResult.score).toBeCloseTo(1.0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
// ── RRF Fusion Tests ────────────────────────────────────────────────
|
|
162
|
+
describe('HybridSearchEngine — RRF fusion', () => {
|
|
163
|
+
it('computes correct RRF score', async () => {
|
|
164
|
+
const symbols = [
|
|
165
|
+
makeSymbol('handleRequest', 'function', 'a.ts', { line: 1, language: 'typescript' }),
|
|
166
|
+
makeSymbol('handleResponse', 'function', 'b.ts', { line: 1, language: 'typescript' }),
|
|
167
|
+
];
|
|
168
|
+
const engine = new SearchEngine();
|
|
169
|
+
engine.buildIndex(symbols);
|
|
170
|
+
// Both should appear in BM25 results for "handle" — handleRequest rank 1, handleResponse rank 2
|
|
171
|
+
// Vector: handleResponse rank 1, handleRequest rank 2
|
|
172
|
+
const vectorResults = [
|
|
173
|
+
{ chunk: makeChunk('b.ts', 'handleResponse', 1), score: 0.95 },
|
|
174
|
+
{ chunk: makeChunk('a.ts', 'handleRequest', 1), score: 0.80 },
|
|
175
|
+
];
|
|
176
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
177
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
178
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
179
|
+
fusionMethod: 'rrf',
|
|
180
|
+
rrfK: 60,
|
|
181
|
+
adaptiveAlpha: false,
|
|
182
|
+
});
|
|
183
|
+
const results = await hybrid.search({ query: 'handle', maxResults: 10 });
|
|
184
|
+
// Both symbols should appear in results from both BM25 and vector
|
|
185
|
+
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
186
|
+
const reqResult = results.find(r => r.symbol.name === 'handleRequest');
|
|
187
|
+
const resResult = results.find(r => r.symbol.name === 'handleResponse');
|
|
188
|
+
expect(reqResult).toBeDefined();
|
|
189
|
+
expect(resResult).toBeDefined();
|
|
190
|
+
expect(reqResult.matchType).toBe('hybrid');
|
|
191
|
+
expect(resResult.matchType).toBe('hybrid');
|
|
192
|
+
expect(reqResult.score).toBeGreaterThan(0);
|
|
193
|
+
expect(resResult.score).toBeGreaterThan(0);
|
|
194
|
+
// RRF scores: both get 1/(60+rank_bm25) + 1/(60+rank_vector)
|
|
195
|
+
// Since they swap ranks between BM25 and vector, they should have similar scores
|
|
196
|
+
expect(Math.abs(reqResult.score - resResult.score)).toBeLessThan(0.01);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
// ── BM25 Fallback Tests ─────────────────────────────────────────────
|
|
200
|
+
describe('HybridSearchEngine — BM25 fallback', () => {
|
|
201
|
+
it('falls back to BM25 when no vector store', async () => {
|
|
202
|
+
const symbols = [
|
|
203
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { language: 'typescript' }),
|
|
204
|
+
];
|
|
205
|
+
const engine = new SearchEngine();
|
|
206
|
+
engine.buildIndex(symbols);
|
|
207
|
+
const hybrid = new HybridSearchEngine(engine, null, null);
|
|
208
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 10 });
|
|
209
|
+
expect(results.length).toBeGreaterThan(0);
|
|
210
|
+
expect(results[0].symbol.name).toBe('handleRequest');
|
|
211
|
+
// Should use BM25 match types, not hybrid
|
|
212
|
+
expect(['exact', 'fuzzy', 'bm25']).toContain(results[0].matchType);
|
|
213
|
+
});
|
|
214
|
+
it('falls back to BM25 when vector store is empty', async () => {
|
|
215
|
+
const symbols = [
|
|
216
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { language: 'typescript' }),
|
|
217
|
+
];
|
|
218
|
+
const engine = new SearchEngine();
|
|
219
|
+
engine.buildIndex(symbols);
|
|
220
|
+
const emptyVectorStore = createMockVectorStore([]);
|
|
221
|
+
emptyVectorStore.size.mockReturnValue(0);
|
|
222
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
223
|
+
const hybrid = new HybridSearchEngine(engine, emptyVectorStore, embeddingProvider);
|
|
224
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 10 });
|
|
225
|
+
expect(results.length).toBeGreaterThan(0);
|
|
226
|
+
expect(results[0].symbol.name).toBe('handleRequest');
|
|
227
|
+
});
|
|
228
|
+
it('falls back to BM25 when no embedding provider', async () => {
|
|
229
|
+
const symbols = [
|
|
230
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { language: 'typescript' }),
|
|
231
|
+
];
|
|
232
|
+
const engine = new SearchEngine();
|
|
233
|
+
engine.buildIndex(symbols);
|
|
234
|
+
const vectorStore = createMockVectorStore([]);
|
|
235
|
+
vectorStore.size.mockReturnValue(10);
|
|
236
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, null);
|
|
237
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 10 });
|
|
238
|
+
expect(results.length).toBeGreaterThan(0);
|
|
239
|
+
expect(results[0].symbol.name).toBe('handleRequest');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
// ── Adaptive Alpha Tests ────────────────────────────────────────────
|
|
243
|
+
describe('HybridSearchEngine — adaptive alpha', () => {
|
|
244
|
+
it('uses lower alpha for identifier queries', () => {
|
|
245
|
+
const classification = classifyQuery('handleRequest');
|
|
246
|
+
expect(classification.type).toBe('identifier');
|
|
247
|
+
expect(classification.alpha).toBe(0.25);
|
|
248
|
+
});
|
|
249
|
+
it('uses higher alpha for natural language queries', () => {
|
|
250
|
+
const classification = classifyQuery('how to handle authentication errors');
|
|
251
|
+
expect(classification.type).toBe('natural');
|
|
252
|
+
expect(classification.alpha).toBe(0.75);
|
|
253
|
+
});
|
|
254
|
+
it('uses balanced alpha for mixed queries', () => {
|
|
255
|
+
const classification = classifyQuery('fix handleRequest error');
|
|
256
|
+
expect(classification.type).toBe('mixed');
|
|
257
|
+
expect(classification.alpha).toBe(0.55);
|
|
258
|
+
});
|
|
259
|
+
it('uses adaptive alpha when enabled (default)', async () => {
|
|
260
|
+
const symbols = [
|
|
261
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
262
|
+
];
|
|
263
|
+
const engine = new SearchEngine();
|
|
264
|
+
engine.buildIndex(symbols);
|
|
265
|
+
const vectorResults = [
|
|
266
|
+
{ chunk: makeChunk('src/api.ts', 'handleRequest', 1), score: 0.9 },
|
|
267
|
+
];
|
|
268
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
269
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
270
|
+
// adaptiveAlpha: true (default) — identifier query should use alpha=0.25
|
|
271
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
272
|
+
adaptiveAlpha: true,
|
|
273
|
+
fusionMethod: 'ccs',
|
|
274
|
+
});
|
|
275
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 10 });
|
|
276
|
+
expect(results.length).toBeGreaterThan(0);
|
|
277
|
+
// With alpha=0.25 (favoring BM25), the BM25 component should dominate
|
|
278
|
+
// For a single matched doc, both normalize to 1.0, so score = 0.25*1.0 + 0.75*1.0 = 1.0
|
|
279
|
+
expect(results[0].score).toBeCloseTo(1.0);
|
|
280
|
+
});
|
|
281
|
+
it('uses fixed alpha when adaptiveAlpha is disabled', async () => {
|
|
282
|
+
const symbols = [
|
|
283
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
284
|
+
];
|
|
285
|
+
const engine = new SearchEngine();
|
|
286
|
+
engine.buildIndex(symbols);
|
|
287
|
+
const vectorResults = [
|
|
288
|
+
{ chunk: makeChunk('src/api.ts', 'handleRequest', 1), score: 0.9 },
|
|
289
|
+
];
|
|
290
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
291
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
292
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
293
|
+
alpha: 0.6,
|
|
294
|
+
adaptiveAlpha: false,
|
|
295
|
+
fusionMethod: 'ccs',
|
|
296
|
+
});
|
|
297
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 10 });
|
|
298
|
+
expect(results.length).toBeGreaterThan(0);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
// ── Filter Application Tests ────────────────────────────────────────
|
|
302
|
+
describe('HybridSearchEngine — filters', () => {
|
|
303
|
+
it('filters by symbolKinds in hybrid mode', async () => {
|
|
304
|
+
const symbols = [
|
|
305
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
306
|
+
makeSymbol('RequestHandler', 'class', 'src/handler.ts', { line: 1, language: 'typescript' }),
|
|
307
|
+
];
|
|
308
|
+
const engine = new SearchEngine();
|
|
309
|
+
engine.buildIndex(symbols);
|
|
310
|
+
const vectorResults = [
|
|
311
|
+
{ chunk: makeChunk('src/api.ts', 'handleRequest', 1), score: 0.9 },
|
|
312
|
+
{ chunk: makeChunk('src/handler.ts', 'RequestHandler', 1), score: 0.85 },
|
|
313
|
+
];
|
|
314
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
315
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
316
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
317
|
+
adaptiveAlpha: false,
|
|
318
|
+
alpha: 0.5,
|
|
319
|
+
});
|
|
320
|
+
const results = await hybrid.search({
|
|
321
|
+
query: 'request',
|
|
322
|
+
maxResults: 10,
|
|
323
|
+
symbolKinds: ['function'],
|
|
324
|
+
});
|
|
325
|
+
for (const r of results) {
|
|
326
|
+
expect(r.symbol.kind).toBe('function');
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
it('filters by language in hybrid mode', async () => {
|
|
330
|
+
const symbols = [
|
|
331
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
332
|
+
makeSymbol('handle_request', 'function', 'src/api.py', { line: 1, language: 'python' }),
|
|
333
|
+
];
|
|
334
|
+
const engine = new SearchEngine();
|
|
335
|
+
engine.buildIndex(symbols);
|
|
336
|
+
const vectorResults = [
|
|
337
|
+
{ chunk: makeChunk('src/api.ts', 'handleRequest', 1), score: 0.9 },
|
|
338
|
+
{
|
|
339
|
+
chunk: {
|
|
340
|
+
id: 'src/api.py:handle_request:1',
|
|
341
|
+
content: 'def handle_request():',
|
|
342
|
+
metadata: {
|
|
343
|
+
filePath: 'src/api.py',
|
|
344
|
+
symbolName: 'handle_request',
|
|
345
|
+
symbolKind: 'function',
|
|
346
|
+
startLine: 1,
|
|
347
|
+
endLine: 10,
|
|
348
|
+
language: 'python',
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
score: 0.85,
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
355
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
356
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
357
|
+
adaptiveAlpha: false,
|
|
358
|
+
alpha: 0.5,
|
|
359
|
+
});
|
|
360
|
+
const results = await hybrid.search({
|
|
361
|
+
query: 'handle request',
|
|
362
|
+
maxResults: 10,
|
|
363
|
+
language: 'typescript',
|
|
364
|
+
});
|
|
365
|
+
for (const r of results) {
|
|
366
|
+
expect(r.symbol.language).toBe('typescript');
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
it('filters by filePattern in hybrid mode', async () => {
|
|
370
|
+
const symbols = [
|
|
371
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
372
|
+
makeSymbol('handleError', 'function', 'lib/error.ts', { line: 1, language: 'typescript' }),
|
|
373
|
+
];
|
|
374
|
+
const engine = new SearchEngine();
|
|
375
|
+
engine.buildIndex(symbols);
|
|
376
|
+
const vectorResults = [
|
|
377
|
+
{ chunk: makeChunk('src/api.ts', 'handleRequest', 1), score: 0.9 },
|
|
378
|
+
{ chunk: makeChunk('lib/error.ts', 'handleError', 1), score: 0.85 },
|
|
379
|
+
];
|
|
380
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
381
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
382
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
383
|
+
adaptiveAlpha: false,
|
|
384
|
+
alpha: 0.5,
|
|
385
|
+
});
|
|
386
|
+
const results = await hybrid.search({
|
|
387
|
+
query: 'handle',
|
|
388
|
+
maxResults: 10,
|
|
389
|
+
filePattern: 'src/**',
|
|
390
|
+
});
|
|
391
|
+
for (const r of results) {
|
|
392
|
+
expect(r.symbol.filePath).toMatch(/^src\//);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
// ── Vector-only results ─────────────────────────────────────────────
|
|
397
|
+
describe('HybridSearchEngine — vector-only results', () => {
|
|
398
|
+
it('includes results found only by vector search', async () => {
|
|
399
|
+
const symbols = [
|
|
400
|
+
makeSymbol('handleRequest', 'function', 'src/api.ts', { line: 1, language: 'typescript' }),
|
|
401
|
+
];
|
|
402
|
+
const engine = new SearchEngine();
|
|
403
|
+
engine.buildIndex(symbols);
|
|
404
|
+
// Vector returns an additional result not in BM25
|
|
405
|
+
const vectorResults = [
|
|
406
|
+
{ chunk: makeChunk('src/api.ts', 'handleRequest', 1), score: 0.9 },
|
|
407
|
+
{ chunk: makeChunk('src/auth.ts', 'authenticate', 10), score: 0.8 },
|
|
408
|
+
];
|
|
409
|
+
const vectorStore = createMockVectorStore(vectorResults);
|
|
410
|
+
const embeddingProvider = createMockEmbeddingProvider();
|
|
411
|
+
const hybrid = new HybridSearchEngine(engine, vectorStore, embeddingProvider, {
|
|
412
|
+
adaptiveAlpha: false,
|
|
413
|
+
alpha: 0.5,
|
|
414
|
+
});
|
|
415
|
+
const results = await hybrid.search({ query: 'handleRequest', maxResults: 10 });
|
|
416
|
+
const authResult = results.find(r => r.symbol.name === 'authenticate');
|
|
417
|
+
expect(authResult).toBeDefined();
|
|
418
|
+
expect(authResult.matchType).toBe('semantic');
|
|
419
|
+
expect(authResult.vectorScore).toBeDefined();
|
|
420
|
+
expect(authResult.bm25Score).toBeUndefined();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
// ── maxResults ──────────────────────────────────────────────────────
|
|
424
|
+
describe('HybridSearchEngine — maxResults', () => {
|
|
425
|
+
it('respects maxResults limit', async () => {
|
|
426
|
+
const symbols = [
|
|
427
|
+
makeSymbol('foo', 'function', 'a.ts', { line: 1, language: 'typescript' }),
|
|
428
|
+
makeSymbol('fooBar', 'function', 'b.ts', { line: 1, language: 'typescript' }),
|
|
429
|
+
makeSymbol('fooBarBaz', 'function', 'c.ts', { line: 1, language: 'typescript' }),
|
|
430
|
+
];
|
|
431
|
+
const engine = new SearchEngine();
|
|
432
|
+
engine.buildIndex(symbols);
|
|
433
|
+
const hybrid = new HybridSearchEngine(engine, null, null);
|
|
434
|
+
const results = await hybrid.search({ query: 'foo', maxResults: 1 });
|
|
435
|
+
expect(results.length).toBeLessThanOrEqual(1);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-classifier.test.d.ts","sourceRoot":"","sources":["../../../../src/search/__tests__/query-classifier.test.ts"],"names":[],"mappings":""}
|