@chiway/contextweaver 1.0.0 → 1.1.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.
@@ -0,0 +1,886 @@
1
+ import {
2
+ getGraphExpander,
3
+ getIndexer,
4
+ getVectorStore,
5
+ scoreChunkTokenOverlap
6
+ } from "./chunk-6QMYML5V.js";
7
+ import {
8
+ initDb,
9
+ isChunksFtsInitialized,
10
+ isFtsInitialized,
11
+ searchChunksFts,
12
+ searchFilesFts,
13
+ segmentQuery
14
+ } from "./chunk-6Z4JEEVJ.js";
15
+ import {
16
+ isDebugEnabled,
17
+ logger
18
+ } from "./chunk-AMQQK4P7.js";
19
+ import {
20
+ getEmbeddingConfig,
21
+ getRerankerConfig
22
+ } from "./chunk-RJURH22T.js";
23
+
24
+ // src/api/reranker.ts
25
+ var RerankerClient = class {
26
+ config;
27
+ constructor(config) {
28
+ this.config = config || getRerankerConfig();
29
+ }
30
+ /**
31
+ * 对文档进行重排序
32
+ * @param query 查询文本
33
+ * @param documents 待排序的文档文本数组
34
+ * @param options 选项
35
+ */
36
+ async rerank(query, documents, options = {}) {
37
+ if (documents.length === 0) {
38
+ return [];
39
+ }
40
+ const { topN = this.config.topN, maxChunksPerDoc, chunkOverlap, retries = 3 } = options;
41
+ const requestBody = {
42
+ model: this.config.model,
43
+ query,
44
+ documents,
45
+ top_n: Math.min(topN, documents.length),
46
+ return_documents: false
47
+ };
48
+ if (maxChunksPerDoc !== void 0) {
49
+ requestBody.max_chunks_per_doc = maxChunksPerDoc;
50
+ }
51
+ if (chunkOverlap !== void 0) {
52
+ requestBody.overlap = chunkOverlap;
53
+ }
54
+ for (let attempt = 1; attempt <= retries; attempt++) {
55
+ try {
56
+ const response = await fetch(this.config.baseUrl, {
57
+ method: "POST",
58
+ headers: {
59
+ "Content-Type": "application/json",
60
+ Authorization: `Bearer ${this.config.apiKey}`
61
+ },
62
+ body: JSON.stringify(requestBody)
63
+ });
64
+ const contentType = response.headers.get("content-type") || void 0;
65
+ const rawText = await response.text();
66
+ if (!rawText) {
67
+ throw new Error(`Rerank API \u8FD4\u56DE\u7A7A\u54CD\u5E94: HTTP ${response.status}`);
68
+ }
69
+ let data;
70
+ try {
71
+ data = JSON.parse(rawText);
72
+ } catch {
73
+ const snippet = rawText.slice(0, 200);
74
+ const meta = contentType ? `, content-type=${contentType}` : "";
75
+ throw new Error(
76
+ `Rerank API \u8FD4\u56DE\u975E JSON \u54CD\u5E94: HTTP ${response.status}${meta}, body=${JSON.stringify(snippet)}`
77
+ );
78
+ }
79
+ if (!response.ok || data.error || data.code || data.message) {
80
+ const errorMsg = data.error?.message || data.message || (data.code ? `${data.code}` : "") || `HTTP ${response.status}`;
81
+ throw new Error(`Rerank API \u9519\u8BEF: ${errorMsg}`);
82
+ }
83
+ const rerankResults = data.results ?? data.output?.results;
84
+ if (!rerankResults || !Array.isArray(rerankResults)) {
85
+ throw new Error("Rerank API \u8FD4\u56DE\u683C\u5F0F\u4E0D\u7B26\u5408\u9884\u671F\uFF1A\u7F3A\u5C11 results/output.results");
86
+ }
87
+ const results = rerankResults.map((item) => ({
88
+ originalIndex: item.index,
89
+ score: item.relevance_score,
90
+ text: documents[item.index]
91
+ }));
92
+ logger.debug(
93
+ {
94
+ query: query.slice(0, 50),
95
+ inputCount: documents.length,
96
+ outputCount: results.length
97
+ },
98
+ "Rerank \u5B8C\u6210"
99
+ );
100
+ return results;
101
+ } catch (err) {
102
+ const error = err;
103
+ const isRateLimited = error.message?.includes("429") || error.message?.includes("rate");
104
+ if (attempt < retries) {
105
+ const delay = isRateLimited ? 1e3 * attempt : 500 * attempt;
106
+ logger.warn(
107
+ { attempt, maxRetries: retries, delay, error: error.message },
108
+ "Rerank \u8BF7\u6C42\u5931\u8D25\uFF0C\u51C6\u5907\u91CD\u8BD5"
109
+ );
110
+ await sleep(delay);
111
+ } else {
112
+ logger.error(
113
+ { error: error.message, stack: error.stack, query: query.slice(0, 50) },
114
+ "Rerank \u8BF7\u6C42\u6700\u7EC8\u5931\u8D25"
115
+ );
116
+ throw err;
117
+ }
118
+ }
119
+ }
120
+ throw new Error("Rerank \u5904\u7406\u5F02\u5E38");
121
+ }
122
+ /**
123
+ * 对带有元数据的文档进行重排序
124
+ * @param query 查询文本
125
+ * @param items 文档项数组
126
+ * @param textExtractor 从文档项中提取文本的函数
127
+ * @param options 选项
128
+ */
129
+ async rerankWithData(query, items, textExtractor, options = {}) {
130
+ if (items.length === 0) {
131
+ return [];
132
+ }
133
+ const texts = items.map(textExtractor);
134
+ const results = await this.rerank(query, texts, options);
135
+ return results.map((result) => ({
136
+ ...result,
137
+ data: items[result.originalIndex]
138
+ }));
139
+ }
140
+ /**
141
+ * 获取当前配置
142
+ */
143
+ getConfig() {
144
+ return { ...this.config };
145
+ }
146
+ };
147
+ var defaultClient = null;
148
+ function getRerankerClient() {
149
+ if (!defaultClient) {
150
+ defaultClient = new RerankerClient();
151
+ }
152
+ return defaultClient;
153
+ }
154
+ function sleep(ms) {
155
+ return new Promise((resolve) => setTimeout(resolve, ms));
156
+ }
157
+
158
+ // src/search/ContextPacker.ts
159
+ var ContextPacker = class {
160
+ projectId;
161
+ config;
162
+ constructor(projectId, config) {
163
+ this.projectId = projectId;
164
+ this.config = config;
165
+ }
166
+ /**
167
+ * 打包:合并 chunks → 按文件聚合段落 → 预算裁剪
168
+ */
169
+ async pack(chunks, db) {
170
+ if (chunks.length === 0) return [];
171
+ const byFile = this.groupByFile(chunks);
172
+ const result = [];
173
+ let totalChars = 0;
174
+ const sortedFiles = Object.entries(byFile).map(([filePath, fileChunks]) => ({
175
+ filePath,
176
+ chunks: fileChunks,
177
+ maxScore: Math.max(...fileChunks.map((c) => c.score))
178
+ })).sort((a, b) => b.maxScore - a.maxScore);
179
+ const allFilePaths = sortedFiles.map((f) => f.filePath);
180
+ const placeholders = allFilePaths.map(() => "?").join(",");
181
+ const rows = db.prepare(`SELECT path, content FROM files WHERE path IN (${placeholders})`).all(...allFilePaths);
182
+ const contentMap = new Map(rows.map((r) => [r.path, r.content]));
183
+ for (const { filePath, chunks: fileChunks } of sortedFiles) {
184
+ const content = contentMap.get(filePath);
185
+ if (!content) continue;
186
+ const segments = this.mergeAndSlice(fileChunks, content);
187
+ const topSegments = segments.sort((a, b) => b.score - a.score).slice(0, this.config.maxSegmentsPerFile).sort((a, b) => a.rawStart - b.rawStart);
188
+ const budgetedSegments = [];
189
+ for (const seg of topSegments) {
190
+ if (totalChars + seg.text.length > this.config.maxTotalChars) {
191
+ break;
192
+ }
193
+ totalChars += seg.text.length;
194
+ budgetedSegments.push(seg);
195
+ }
196
+ if (budgetedSegments.length > 0) {
197
+ result.push({ filePath, segments: budgetedSegments });
198
+ }
199
+ if (totalChars >= this.config.maxTotalChars) break;
200
+ }
201
+ return result;
202
+ }
203
+ /**
204
+ * 按文件分组
205
+ */
206
+ groupByFile(chunks) {
207
+ const byFile = {};
208
+ for (const chunk of chunks) {
209
+ const key = chunk.filePath;
210
+ if (!byFile[key]) byFile[key] = [];
211
+ byFile[key].push(chunk);
212
+ }
213
+ return byFile;
214
+ }
215
+ /**
216
+ * 合并重叠区间 + 从原文件切片
217
+ */
218
+ mergeAndSlice(chunks, content) {
219
+ if (chunks.length === 0) return [];
220
+ const sorted = [...chunks].sort((a, b) => a.record.raw_start - b.record.raw_start);
221
+ const intervals = [];
222
+ for (const chunk of sorted) {
223
+ const start = chunk.record.raw_start;
224
+ const end = chunk.record.raw_end;
225
+ const last = intervals[intervals.length - 1];
226
+ if (last && start <= last.end) {
227
+ last.end = Math.max(last.end, end);
228
+ last.score = Math.max(last.score, chunk.score);
229
+ last.chunks.push(chunk);
230
+ } else {
231
+ intervals.push({
232
+ start,
233
+ end,
234
+ score: chunk.score,
235
+ breadcrumb: chunk.record.breadcrumb,
236
+ chunks: [chunk]
237
+ });
238
+ }
239
+ }
240
+ return intervals.map((iv) => {
241
+ const startLine = this.offsetToLine(content, iv.start);
242
+ const endLine = this.offsetToLine(content, iv.end);
243
+ return {
244
+ filePath: chunks[0].filePath,
245
+ rawStart: iv.start,
246
+ rawEnd: iv.end,
247
+ startLine,
248
+ endLine,
249
+ score: iv.score,
250
+ breadcrumb: iv.breadcrumb,
251
+ text: content.slice(iv.start, iv.end)
252
+ };
253
+ });
254
+ }
255
+ /**
256
+ * 将字符偏移量转换为行号(1-indexed)
257
+ */
258
+ offsetToLine(content, offset) {
259
+ let line = 1;
260
+ for (let i = 0; i < offset && i < content.length; i++) {
261
+ if (content[i] === "\n") {
262
+ line++;
263
+ }
264
+ }
265
+ return line;
266
+ }
267
+ };
268
+
269
+ // src/search/config.ts
270
+ var DEFAULT_CONFIG = {
271
+ // ── Recall (向量 + 词法召回) ──
272
+ vectorTopK: 80,
273
+ // Vector ANN candidates before dedup. Range: 40–200. Higher = better recall, more compute.
274
+ vectorTopM: 60,
275
+ // Vectors kept after dedup. Range: 30–100.
276
+ ftsTopKFiles: 20,
277
+ // Max files returned by FTS5 full-text search. Range: 10–50.
278
+ lexChunksPerFile: 2,
279
+ // Chunks to pull per FTS-matched file. Range: 1–5. Low keeps diversity across files.
280
+ lexTotalChunks: 40,
281
+ // Hard cap on total lexical chunks. Range: 20–80.
282
+ // ── RRF Fusion (向量 + 词法分数融合) ──
283
+ rrfK0: 20,
284
+ // RRF smoothing constant. Range: 10–60. Lower amplifies top ranks.
285
+ wVec: 0.6,
286
+ // Vector weight in fused score. Range: 0.3–0.8. Semantic relevance emphasis.
287
+ wLex: 0.4,
288
+ // Lexical weight in fused score. wVec + wLex should equal 1.0.
289
+ fusedTopM: 60,
290
+ // Candidates after fusion, fed into reranker. Range: 30–100.
291
+ // ── Rerank (精排) ──
292
+ rerankTopN: 10,
293
+ // Final top-N results after reranking. Range: 5–20.
294
+ maxRerankChars: 1e3,
295
+ // Max chars per chunk sent to reranker. Truncated beyond this. Range: 500–2000.
296
+ maxBreadcrumbChars: 250,
297
+ // Max chars for breadcrumb context in rerank input. Range: 100–500.
298
+ headRatio: 0.67,
299
+ // Ratio of head vs tail when truncating chunks. Range: 0.5–0.8.
300
+ // ── Expansion (上下文扩展: E1 邻居 / E2 面包屑 / E3 跨文件导入) ──
301
+ neighborHops: 2,
302
+ // E1: How many sibling chunks to expand in each direction. Range: 1–3.
303
+ breadcrumbExpandLimit: 3,
304
+ // E2: Max ancestor breadcrumbs (class/function scope). Range: 1–5.
305
+ importFilesPerSeed: 3,
306
+ // E3: Cross-file import files to resolve per seed chunk. Range: 0–5. Set to 3 to enable import-graph expansion for better cross-file context.
307
+ chunksPerImportFile: 3,
308
+ // E3: Chunks to pull from each resolved import file. Range: 1–5. Set to 3 for balanced coverage of imported symbols.
309
+ decayNeighbor: 0.8,
310
+ // Score decay per E1 hop. Range: 0.5–0.9. Higher = neighbors stay relevant longer.
311
+ decayBreadcrumb: 0.7,
312
+ // Score decay per E2 level. Range: 0.4–0.8.
313
+ decayImport: 0.6,
314
+ // Score decay for E3 import chunks. Range: 0.3–0.7. Lower than E1/E2 since cross-file is less certain.
315
+ decayDepth: 0.7,
316
+ // General depth decay multiplier. Range: 0.5–0.9.
317
+ // ── ContextPacker (上下文打包) ──
318
+ maxSegmentsPerFile: 3,
319
+ // Max non-contiguous segments per file in output. Range: 1–5. Prevents excessive fragmentation.
320
+ maxTotalChars: 48e3,
321
+ // Token budget expressed as chars (~12k tokens). Range: 20000–80000.
322
+ // ── Smart TopK (动态结果数量) ──
323
+ enableSmartTopK: true,
324
+ // Dynamically adjust result count based on score distribution.
325
+ smartTopScoreRatio: 0.5,
326
+ // Min score as ratio of top-1 score to remain included. Range: 0.3–0.7.
327
+ smartTopScoreDeltaAbs: 0.25,
328
+ // Max absolute score drop from top-1 before cutting off. Range: 0.1–0.4.
329
+ smartMinScore: 0.25,
330
+ // Hard floor: chunks below this score are always excluded. Range: 0.1–0.4.
331
+ smartMinK: 2,
332
+ // Minimum results to return regardless of scores. Range: 1–3.
333
+ smartMaxK: 8
334
+ // Maximum results when smart topK is active. Range: 5–15.
335
+ };
336
+
337
+ // src/search/SearchService.ts
338
+ var SearchService = class {
339
+ projectId;
340
+ indexer = null;
341
+ vectorStore = null;
342
+ db = null;
343
+ config;
344
+ constructor(projectId, _projectPath, config) {
345
+ this.projectId = projectId;
346
+ this.config = { ...DEFAULT_CONFIG, ...config };
347
+ }
348
+ async init() {
349
+ const embeddingConfig = getEmbeddingConfig();
350
+ this.indexer = await getIndexer(this.projectId, embeddingConfig.dimensions);
351
+ this.vectorStore = await getVectorStore(this.projectId, embeddingConfig.dimensions);
352
+ this.db = initDb(this.projectId);
353
+ }
354
+ // 公开接口
355
+ /**
356
+ * 构建上下文包(用于问答/生成)
357
+ */
358
+ async buildContextPack(query) {
359
+ const timingMs = {};
360
+ let t0 = Date.now();
361
+ const candidates = await this.hybridRetrieve(query);
362
+ timingMs.retrieve = Date.now() - t0;
363
+ t0 = Date.now();
364
+ const topM = candidates.sort((a, b) => b.score - a.score).slice(0, this.config.fusedTopM);
365
+ const reranked = await this.rerank(query, topM);
366
+ timingMs.rerank = Date.now() - t0;
367
+ t0 = Date.now();
368
+ const seeds = this.applySmartCutoff(reranked);
369
+ timingMs.smartCutoff = Date.now() - t0;
370
+ t0 = Date.now();
371
+ const queryTokens = this.extractQueryTokens(query);
372
+ const expanded = await this.expand(seeds, queryTokens);
373
+ timingMs.expand = Date.now() - t0;
374
+ t0 = Date.now();
375
+ const packer = new ContextPacker(this.projectId, this.config);
376
+ const files = await packer.pack([...seeds, ...expanded], this.db);
377
+ timingMs.pack = Date.now() - t0;
378
+ return {
379
+ query,
380
+ seeds,
381
+ expanded,
382
+ files,
383
+ debug: {
384
+ wVec: this.config.wVec,
385
+ wLex: this.config.wLex,
386
+ timingMs
387
+ }
388
+ };
389
+ }
390
+ // 召回方法
391
+ /**
392
+ * 混合召回:向量 + 词法
393
+ */
394
+ async hybridRetrieve(query) {
395
+ const [vectorResults, lexicalResults] = await Promise.all([
396
+ this.vectorRetrieve(query),
397
+ this.lexicalRetrieve(query)
398
+ ]);
399
+ logger.debug(
400
+ {
401
+ vectorCount: vectorResults.length,
402
+ lexicalCount: lexicalResults.length
403
+ },
404
+ "\u6DF7\u5408\u53EC\u56DE\u5B8C\u6210"
405
+ );
406
+ if (lexicalResults.length === 0) {
407
+ return vectorResults;
408
+ }
409
+ return this.fuse(vectorResults, lexicalResults);
410
+ }
411
+ /**
412
+ * 向量召回
413
+ */
414
+ async vectorRetrieve(query) {
415
+ if (!this.indexer) throw new Error("SearchService not initialized");
416
+ const results = await this.indexer.textSearch(query, this.config.vectorTopK);
417
+ if (!results) return [];
418
+ return results.sort((a, b) => a._distance - b._distance).slice(0, this.config.vectorTopM).map((r, rank) => ({
419
+ filePath: r.file_path,
420
+ chunkIndex: r.chunk_index,
421
+ score: 1 / (1 + r._distance),
422
+ // 转为相似度(用于调试)
423
+ source: "vector",
424
+ record: r,
425
+ _rank: rank
426
+ // 用于 RRF
427
+ }));
428
+ }
429
+ /**
430
+ * 词法召回(FTS)
431
+ *
432
+ * 优先使用 chunk 级 FTS(更精准)
433
+ * 如果 chunks_fts 不可用,降级到文件级 FTS + overlap 下钻
434
+ */
435
+ async lexicalRetrieve(query) {
436
+ if (!this.db || !this.vectorStore) return [];
437
+ if (isChunksFtsInitialized(this.db)) {
438
+ return this.lexicalRetrieveFromChunksFts(query);
439
+ }
440
+ if (isFtsInitialized(this.db)) {
441
+ return this.lexicalRetrieveFromFilesFts(query);
442
+ }
443
+ logger.debug("FTS \u672A\u521D\u59CB\u5316\uFF0C\u8DF3\u8FC7\u8BCD\u6CD5\u53EC\u56DE");
444
+ return [];
445
+ }
446
+ /**
447
+ * 从 chunks_fts 直接搜索(最优方案)
448
+ */
449
+ async lexicalRetrieveFromChunksFts(query) {
450
+ const chunkResults = searchChunksFts(
451
+ this.db,
452
+ query,
453
+ this.config.lexTotalChunks
454
+ );
455
+ if (chunkResults.length === 0) {
456
+ logger.debug("Chunk FTS \u65E0\u547D\u4E2D");
457
+ return [];
458
+ }
459
+ const allChunks = [];
460
+ const fileChunksMap = /* @__PURE__ */ new Map();
461
+ for (const result of chunkResults) {
462
+ if (!fileChunksMap.has(result.filePath)) {
463
+ fileChunksMap.set(result.filePath, /* @__PURE__ */ new Map());
464
+ }
465
+ fileChunksMap.get(result.filePath)?.set(result.chunkIndex, result.score);
466
+ }
467
+ const allFilePaths = Array.from(fileChunksMap.keys());
468
+ const chunksMap = await this.vectorStore?.getFilesChunks(allFilePaths);
469
+ if (!chunksMap) return allChunks;
470
+ for (const [filePath, chunkScores] of fileChunksMap) {
471
+ const chunks = chunksMap.get(filePath) ?? [];
472
+ for (const chunk of chunks) {
473
+ const score = chunkScores.get(chunk.chunk_index);
474
+ if (score !== void 0) {
475
+ allChunks.push({
476
+ filePath: chunk.file_path,
477
+ chunkIndex: chunk.chunk_index,
478
+ score,
479
+ source: "lexical",
480
+ record: { ...chunk, _distance: 0 }
481
+ });
482
+ }
483
+ }
484
+ }
485
+ logger.debug(
486
+ {
487
+ totalChunks: allChunks.length,
488
+ filesWithChunks: fileChunksMap.size
489
+ },
490
+ "Chunk FTS \u53EC\u56DE\u5B8C\u6210"
491
+ );
492
+ return allChunks.sort((a, b) => b.score - a.score).map((chunk, rank) => ({ ...chunk, _rank: rank }));
493
+ }
494
+ /**
495
+ * 从 files_fts 搜索 + overlap 下钻(降级方案)
496
+ */
497
+ async lexicalRetrieveFromFilesFts(query) {
498
+ const fileResults = searchFilesFts(
499
+ this.db,
500
+ query,
501
+ this.config.ftsTopKFiles
502
+ );
503
+ if (fileResults.length === 0) {
504
+ logger.debug("FTS \u65E0\u547D\u4E2D\u6587\u4EF6");
505
+ return [];
506
+ }
507
+ const queryTokens = this.extractQueryTokens(query);
508
+ if (isDebugEnabled()) {
509
+ logger.debug(
510
+ {
511
+ fileCount: fileResults.length,
512
+ queryTokens: Array.from(queryTokens).slice(0, 10)
513
+ },
514
+ "FTS \u53EC\u56DE\u5F00\u59CB chunk \u9009\u62E9"
515
+ );
516
+ }
517
+ const allFilePaths = fileResults.map((r) => r.path);
518
+ const chunksMap = await this.vectorStore?.getFilesChunks(allFilePaths);
519
+ if (!chunksMap) return [];
520
+ const allChunks = [];
521
+ let totalChunks = 0;
522
+ let skippedFiles = 0;
523
+ for (const { path: filePath, score: fileScore } of fileResults) {
524
+ if (totalChunks >= this.config.lexTotalChunks) break;
525
+ const chunks = chunksMap.get(filePath);
526
+ if (!chunks || chunks.length === 0) continue;
527
+ const scoredChunks = chunks.map((chunk) => ({
528
+ chunk,
529
+ overlapScore: scoreChunkTokenOverlap(chunk, queryTokens)
530
+ }));
531
+ const maxOverlap = Math.max(...scoredChunks.map((c) => c.overlapScore));
532
+ if (maxOverlap === 0) {
533
+ skippedFiles++;
534
+ continue;
535
+ }
536
+ const topChunks = scoredChunks.filter((c) => c.overlapScore > 0).sort((a, b) => b.overlapScore - a.overlapScore).slice(0, this.config.lexChunksPerFile);
537
+ for (const { chunk, overlapScore } of topChunks) {
538
+ if (totalChunks >= this.config.lexTotalChunks) break;
539
+ const combinedScore = fileScore * (1 + overlapScore * 0.5);
540
+ allChunks.push({
541
+ filePath: chunk.file_path,
542
+ chunkIndex: chunk.chunk_index,
543
+ score: combinedScore,
544
+ source: "lexical",
545
+ record: { ...chunk, _distance: 0 }
546
+ });
547
+ totalChunks++;
548
+ }
549
+ }
550
+ if (skippedFiles > 0) {
551
+ logger.debug({ skippedFiles }, "FTS \u8DF3\u8FC7 overlap=0 \u7684\u6587\u4EF6");
552
+ }
553
+ logger.debug(
554
+ {
555
+ totalChunks: allChunks.length,
556
+ filesWithChunks: new Set(allChunks.map((c) => c.filePath)).size
557
+ },
558
+ "FTS chunk \u9009\u62E9\u5B8C\u6210"
559
+ );
560
+ return allChunks.sort((a, b) => b.score - a.score).map((chunk, rank) => ({ ...chunk, _rank: rank }));
561
+ }
562
+ /**
563
+ * 提取查询中的 tokens
564
+ *
565
+ * 直接复用 fts.ts 中的 segmentQuery,确保召回和评分逻辑一致
566
+ */
567
+ extractQueryTokens(query) {
568
+ const tokens = segmentQuery(query);
569
+ return new Set(tokens);
570
+ }
571
+ // =========================================
572
+ // 融合方法
573
+ // =========================================
574
+ /**
575
+ * RRF (Reciprocal Rank Fusion) 融合
576
+ *
577
+ * 公式: score = Σ w_i / (k + rank_i)
578
+ * 其中 k 是平滑常数,rank 从 0 开始
579
+ */
580
+ fuse(vectorResults, lexicalResults) {
581
+ const { rrfK0, wVec, wLex } = this.config;
582
+ const fusedScores = /* @__PURE__ */ new Map();
583
+ const getKey = (chunk) => `${chunk.filePath}#${chunk.chunkIndex}`;
584
+ for (const result of vectorResults) {
585
+ const key = getKey(result);
586
+ const rank = result._rank ?? 0;
587
+ const rrfScore = wVec / (rrfK0 + rank);
588
+ const existing = fusedScores.get(key);
589
+ if (existing) {
590
+ existing.score += rrfScore;
591
+ existing.sources.add("vector");
592
+ } else {
593
+ fusedScores.set(key, {
594
+ score: rrfScore,
595
+ chunk: result,
596
+ sources: /* @__PURE__ */ new Set(["vector"])
597
+ });
598
+ }
599
+ }
600
+ for (const result of lexicalResults) {
601
+ const key = getKey(result);
602
+ const rank = result._rank ?? 0;
603
+ const rrfScore = wLex / (rrfK0 + rank);
604
+ const existing = fusedScores.get(key);
605
+ if (existing) {
606
+ existing.score += rrfScore;
607
+ existing.sources.add("lexical");
608
+ } else {
609
+ fusedScores.set(key, {
610
+ score: rrfScore,
611
+ chunk: result,
612
+ sources: /* @__PURE__ */ new Set(["lexical"])
613
+ });
614
+ }
615
+ }
616
+ const fused = Array.from(fusedScores.values()).map(({ score, chunk, sources }) => ({
617
+ ...chunk,
618
+ score,
619
+ source: sources.size > 1 ? "vector" : chunk.source
620
+ // 保留原始来源
621
+ })).sort((a, b) => b.score - a.score);
622
+ if (isDebugEnabled()) {
623
+ logger.debug(
624
+ {
625
+ vectorCount: vectorResults.length,
626
+ lexicalCount: lexicalResults.length,
627
+ fusedCount: fused.length,
628
+ bothSources: Array.from(fusedScores.values()).filter((v) => v.sources.size > 1).length
629
+ },
630
+ "RRF \u878D\u5408\u5B8C\u6210"
631
+ );
632
+ }
633
+ return fused;
634
+ }
635
+ // Rerank 方法
636
+ /**
637
+ * Rerank
638
+ */
639
+ async rerank(query, candidates) {
640
+ if (candidates.length === 0) return [];
641
+ let reranker;
642
+ try {
643
+ reranker = getRerankerClient();
644
+ } catch (err) {
645
+ const error = err;
646
+ logger.warn({ error: error.message }, "Reranker \u672A\u914D\u7F6E\uFF0C\u8DF3\u8FC7 rerank");
647
+ return candidates;
648
+ }
649
+ const queryTokens = this.extractQueryTokens(query);
650
+ const textExtractor = (chunk) => {
651
+ const bc = this.truncateMiddle(chunk.record.breadcrumb, this.config.maxBreadcrumbChars);
652
+ const budget = Math.max(0, this.config.maxRerankChars - bc.length - 1);
653
+ const code = this.extractAroundHit(chunk.record.display_code, queryTokens, budget);
654
+ return `${bc}
655
+ ${code}`;
656
+ };
657
+ try {
658
+ const reranked = await reranker.rerankWithData(query, candidates, textExtractor, {
659
+ topN: this.config.rerankTopN
660
+ });
661
+ return reranked.filter((r) => r.data !== void 0).map((r) => ({
662
+ ...r.data,
663
+ score: r.score
664
+ }));
665
+ } catch (err) {
666
+ const error = err;
667
+ logger.warn({ error: error.message }, "Rerank \u5931\u8D25\uFF0C\u964D\u7EA7\u4E3A\u672A rerank \u7684\u5019\u9009\u7ED3\u679C");
668
+ return candidates;
669
+ }
670
+ }
671
+ // Smart TopK Cutoff
672
+ /**
673
+ * 智能截断策略(Anchor & Floor + Safe Harbor + Delta Guard)
674
+ *
675
+ * 核心逻辑:
676
+ * 1. 低置信熔断:topScore < floor → 返回 top1(CLI 友好)或空
677
+ * 2. 动态阈值:max(floor, min(ratioThreshold, deltaThreshold))
678
+ * 3. Safe Harbor:前 minK 个只检查 floor,不检查 ratio/delta
679
+ * 4. 去重 + 补齐:cutoff 后去重,不足 minK 时从后续补齐
680
+ */
681
+ applySmartCutoff(candidates) {
682
+ if (!this.config.enableSmartTopK) {
683
+ return candidates;
684
+ }
685
+ if (candidates.length === 0) return [];
686
+ const sorted = candidates.slice().sort((a, b) => b.score - a.score);
687
+ const {
688
+ smartTopScoreRatio: ratio,
689
+ smartTopScoreDeltaAbs: deltaAbs,
690
+ smartMinScore: floor,
691
+ smartMinK: minK,
692
+ smartMaxK: maxK
693
+ } = this.config;
694
+ const topScore = sorted[0].score;
695
+ if (topScore < floor) {
696
+ logger.debug({ topScore, floor }, "SmartTopK: Top1 below floor, returning top1 only");
697
+ return [sorted[0]];
698
+ }
699
+ const ratioThreshold = topScore * ratio;
700
+ const deltaThreshold = topScore - deltaAbs;
701
+ const dynamicThreshold = Math.max(floor, Math.min(ratioThreshold, deltaThreshold));
702
+ const picked = [];
703
+ for (let i = 0; i < sorted.length; i++) {
704
+ if (picked.length >= maxK) break;
705
+ const chunk = sorted[i];
706
+ if (i < minK) {
707
+ if (chunk.score >= floor) {
708
+ picked.push(chunk);
709
+ continue;
710
+ }
711
+ logger.debug(
712
+ { rank: i, score: chunk.score, floor },
713
+ "SmartTopK: Safe harbor chunk below floor, breaking"
714
+ );
715
+ break;
716
+ }
717
+ if (chunk.score < dynamicThreshold) {
718
+ logger.debug(
719
+ {
720
+ rank: i,
721
+ score: chunk.score,
722
+ dynamicThreshold,
723
+ topScore,
724
+ ratioThreshold,
725
+ deltaThreshold
726
+ },
727
+ "SmartTopK: cutoff at dynamic threshold"
728
+ );
729
+ break;
730
+ }
731
+ picked.push(chunk);
732
+ }
733
+ const deduped = this.dedupChunks(picked);
734
+ if (deduped.length < Math.min(minK, maxK)) {
735
+ const seen = new Set(deduped.map((c) => this.chunkKey(c)));
736
+ for (const c of sorted) {
737
+ if (deduped.length >= Math.min(minK, maxK)) break;
738
+ if (c.score < floor) break;
739
+ const key = this.chunkKey(c);
740
+ if (!seen.has(key)) {
741
+ seen.add(key);
742
+ deduped.push(c);
743
+ }
744
+ }
745
+ }
746
+ logger.debug(
747
+ {
748
+ originalCount: candidates.length,
749
+ pickedCount: picked.length,
750
+ finalCount: deduped.length,
751
+ topScore,
752
+ floor,
753
+ ratio,
754
+ deltaAbs,
755
+ ratioThreshold: ratioThreshold.toFixed(3),
756
+ deltaThreshold: deltaThreshold.toFixed(3),
757
+ dynamicThreshold: dynamicThreshold.toFixed(3)
758
+ },
759
+ "SmartTopK: done"
760
+ );
761
+ return deduped;
762
+ }
763
+ /**
764
+ * 生成 chunk 唯一键(用于去重)
765
+ */
766
+ chunkKey(chunk) {
767
+ return `${chunk.filePath}#${chunk.chunkIndex}`;
768
+ }
769
+ /**
770
+ * 按 file_path + chunk_index 去重
771
+ */
772
+ dedupChunks(list) {
773
+ const seen = /* @__PURE__ */ new Set();
774
+ const out = [];
775
+ for (const c of list) {
776
+ const k = this.chunkKey(c);
777
+ if (seen.has(k)) continue;
778
+ seen.add(k);
779
+ out.push(c);
780
+ }
781
+ return out;
782
+ }
783
+ // 扩展方法
784
+ /**
785
+ * 扩展 seed chunks
786
+ *
787
+ * 使用 GraphExpander 执行三种扩展策略:
788
+ * - E1: 同文件邻居
789
+ * - E2: breadcrumb 补段
790
+ * - E3: 相对路径 import 解析
791
+ */
792
+ async expand(seeds, queryTokens) {
793
+ if (seeds.length === 0) return [];
794
+ const expander = await getGraphExpander(this.projectId, this.config);
795
+ const { chunks, stats } = await expander.expand(seeds, queryTokens);
796
+ logger.debug(stats, "\u4E0A\u4E0B\u6587\u6269\u5C55\u7EDF\u8BA1");
797
+ return chunks;
798
+ }
799
+ // 工具方法
800
+ /**
801
+ * 中间省略截断(保留首尾)
802
+ */
803
+ truncateMiddle(text, maxLen) {
804
+ if (text.length <= maxLen) return text;
805
+ const half = Math.floor((maxLen - 3) / 2);
806
+ return `${text.slice(0, half)}...${text.slice(-half)}`;
807
+ }
808
+ /**
809
+ * 头尾截断(备用方法,当无命中行时使用)
810
+ */
811
+ truncateHeadTail(text, maxLen, headRatio) {
812
+ if (text.length <= maxLen) return text;
813
+ const headLen = Math.floor(maxLen * headRatio);
814
+ const tailLen = maxLen - headLen - 3;
815
+ if (tailLen <= 0) return text.slice(0, maxLen);
816
+ return `${text.slice(0, headLen)}...${text.slice(-tailLen)}`;
817
+ }
818
+ /**
819
+ * 围绕命中行截取
820
+ *
821
+ * 找到第一个包含 query token 的行,截取其上下文
822
+ * 如果没有命中,降级为头尾截断
823
+ */
824
+ extractAroundHit(text, queryTokens, maxLen) {
825
+ if (text.length <= maxLen) return text;
826
+ const lines = text.split("\n");
827
+ const _textLower = text.toLowerCase();
828
+ let hitLineIdx = -1;
829
+ let bestScore = 0;
830
+ for (let i = 0; i < lines.length; i++) {
831
+ const lineLower = lines[i].toLowerCase();
832
+ let lineScore = 0;
833
+ for (const token of queryTokens) {
834
+ if (lineLower.includes(token)) {
835
+ lineScore++;
836
+ }
837
+ }
838
+ if (lineScore > bestScore) {
839
+ bestScore = lineScore;
840
+ hitLineIdx = i;
841
+ }
842
+ }
843
+ if (hitLineIdx === -1) {
844
+ return this.truncateHeadTail(text, maxLen, this.config.headRatio);
845
+ }
846
+ let start = hitLineIdx;
847
+ let end = hitLineIdx;
848
+ let currentLen = lines[hitLineIdx].length;
849
+ while (currentLen < maxLen) {
850
+ const canUp = start > 0;
851
+ const canDown = end < lines.length - 1;
852
+ if (!canUp && !canDown) break;
853
+ if (canUp) {
854
+ const upLen = lines[start - 1].length + 1;
855
+ if (currentLen + upLen <= maxLen) {
856
+ start--;
857
+ currentLen += upLen;
858
+ }
859
+ }
860
+ if (canDown) {
861
+ const downLen = lines[end + 1].length + 1;
862
+ if (currentLen + downLen <= maxLen) {
863
+ end++;
864
+ currentLen += downLen;
865
+ }
866
+ }
867
+ if ((start === 0 || lines[start - 1].length + 1 + currentLen > maxLen) && (end === lines.length - 1 || lines[end + 1].length + 1 + currentLen > maxLen)) {
868
+ break;
869
+ }
870
+ }
871
+ const result = lines.slice(start, end + 1).join("\n");
872
+ const prefix = start > 0 ? "..." : "";
873
+ const suffix = end < lines.length - 1 ? "..." : "";
874
+ return prefix + result + suffix;
875
+ }
876
+ /**
877
+ * 获取当前配置
878
+ */
879
+ getConfig() {
880
+ return { ...this.config };
881
+ }
882
+ };
883
+ export {
884
+ SearchService
885
+ };
886
+ //# sourceMappingURL=SearchService-MYPOCM3B.js.map