@tencentcloud/sdk-mcp 1.7.7 → 1.7.8

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +7 -9
  3. package/index.js +8 -8
  4. package/knowledge/doc-url-resolver.js +16 -9
  5. package/knowledge/embedding/build-vectors.js +92 -0
  6. package/knowledge/embedding/embed-model.js +41 -0
  7. package/knowledge/embedding/vector-augment.js +126 -0
  8. package/knowledge/embedding/vector-index.js +70 -0
  9. package/knowledge/search/exact-lookup.js +145 -16
  10. package/knowledge/search/filter-pool.js +68 -32
  11. package/knowledge/search/requires-framework.js +1 -1
  12. package/knowledge/search/search-core.js +18 -12
  13. package/knowledge/search/search-knowledge.js +24 -34
  14. package/knowledge/search/search-pass.js +373 -168
  15. package/knowledge/search/url-row-scorer.js +24 -6
  16. package/package.json +3 -1
  17. package/resource/index/manifest.json +1 -1
  18. package/resource/index/vectors/index.json +4 -0
  19. package/resource/integration/chat/preset/react/integration.md +9 -5
  20. package/resource/integration/chat/preset/uni-app/integration.md +4 -0
  21. package/resource/integration/chat/preset/vue/integration.md +10 -6
  22. package/resource/integration/roomkit/preset/vue/integration.md +154 -208
  23. package/resource/knowledge/callkit/preset/miniprogram/integration.md +2 -1
  24. package/resource/knowledge/chat/preset/ios-uikit/integration.md +0 -16
  25. package/resource/knowledge/chat/preset/react/h5/integration.md +16 -5
  26. package/resource/knowledge/chat/preset/vue/features/conversation-list.md +6 -3
  27. package/resource/knowledge/chat/preset/vue/h5/integration.md +7 -4
  28. package/resource/knowledge/push/config/android/honor.md +16 -1
  29. package/resource/knowledge/push/config/android/huawei.md +20 -4
  30. package/resource/knowledge/push/config/android/meizu.md +19 -5
  31. package/resource/knowledge/push/config/android/vivo.md +20 -1
  32. package/resource/knowledge/push/config/harmonyos.md +21 -5
  33. package/resource/knowledge/push/integration/flutter.md +1 -1
  34. package/resource/knowledge/roomkit/preset/uniapp-app/integration.md +18 -14
  35. package/resource/knowledge/rtcengine/web/plugin/23-advanced-support-detection.md +30 -0
  36. package/resource/knowledge/rtcengine/web/plugin/31-advanced-plugins-overview.md +1 -1
  37. package/resource/knowledge/rtcengine/web/plugin/35-advanced-ai-denoiser.md +1 -1
  38. package/resource/knowledge/rtcengine/web/plugin/36-advanced-virtual-background.md +4 -0
  39. package/resource/knowledge/trtc/android/TRTCCloud.md +94 -85
  40. package/resource/knowledge/trtc/android/TRTCCloudDef.md +48 -46
  41. package/resource/knowledge/trtc/android/TRTCCloudListener.md +48 -48
  42. package/resource/knowledge/trtc/android/TXAudioEffectManager.md +18 -18
  43. package/resource/knowledge/trtc/android/TXDeviceManager.md +4 -4
  44. package/resource/knowledge/trtc/android/TXLiteAVCode.md +1 -0
  45. package/resource/knowledge/trtc/c++/ITRTCCloud.md +90 -90
  46. package/resource/knowledge/trtc/c++/ITXAudioEffectManager.md +18 -18
  47. package/resource/knowledge/trtc/c++/ITXDeviceManager.md +9 -9
  48. package/resource/knowledge/trtc/c++/TRTCCloudCallback.md +47 -47
  49. package/resource/knowledge/trtc/c++/TRTCTypeDef.md +47 -45
  50. package/resource/knowledge/trtc/c++/TXLiteAVCode.md +1 -0
  51. package/resource/knowledge/trtc/ios/TRTCCloud.md +93 -93
  52. package/resource/knowledge/trtc/ios/TRTCCloudDelegate.md +50 -50
  53. package/resource/knowledge/trtc/ios/TXAudioEffectManager.md +8 -8
  54. package/resource/knowledge/trtc/ios/TXDeviceManager.md +9 -9
  55. package/resource/knowledge/trtc/ios/TXLiteAVCode.md +1 -0
  56. package/resource/knowledge/trtc/ios/TXLiveSDKTypeDef.md +48 -46
  57. package/server-config.js +8 -9
  58. package/server.js +1 -1
  59. package/tools/common/present_framework_choice.js +1 -1
  60. package/tools/common/search_trtc_knowledge.js +20 -1
  61. package/utils/preflight-route.js +8 -0
  62. package/utils/report-cls-client.js +1 -1
  63. package/utils/report-es-client.js +1 -1
@@ -1,103 +1,40 @@
1
- import { attachPoolContent } from '../content-loader.js';
1
+ import { attachChunkContent, attachPoolContent } from '../content-loader.js';
2
2
  import { buildReferences } from '../doc-url-resolver.js';
3
3
  import { rankSubsetWithIndex, tokenize } from '../bm25.js';
4
4
  import { getProductsBM25Index } from '../bm25-cache.js';
5
- import { filterPool } from './filter-pool.js';
5
+ import { filterPool, filterPoolHard } from './filter-pool.js';
6
6
  import { runExactLookup } from './exact-lookup.js';
7
7
  import { computeGenericAnchorBoosts } from './generic-rerank.js';
8
+ import { embedQuery, withQueryInstruction } from '../embedding/embed-model.js';
9
+ import { queryVectors } from '../embedding/vector-index.js';
8
10
  import { applyRankMultipliers, computeApiNameCoverage, computeMissingApiTokens, isUrlMappingSource, postProcess, } from './search-core.js';
9
11
  function isBestPracticeSource(source) {
10
12
  return /\/best-practice\//i.test(source);
11
13
  }
12
- function normalizeTopicText(text) {
13
- return text
14
- .toLowerCase()
15
- .replace(/[(())【】\[\]{}《》"""'`]/g, '')
16
- .replace(/[\s/\\,,。::;;|·\-—_]+/g, '');
17
- }
18
- const TOPIC_EN_NOISE_WORDS = new Set([
19
- 'a', 'an', 'the', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with',
20
- 'is', 'are', 'was', 'were', 'be', 'how', 'what', 'why', 'when', 'where',
21
- 'chat', 'message', 'messages', 'api', 'sdk', 'doc', 'docs',
22
- ]);
23
- function extractTopicTokens(text) {
24
- const tokens = new Set();
25
- for (const match of text.matchAll(/\b\d{2,}\b/g)) {
26
- tokens.add(match[0]);
27
- }
28
- for (const match of text.matchAll(/\b[a-z][a-z0-9_]{1,}\b/gi)) {
29
- const token = match[0].toLowerCase();
30
- if (TOPIC_EN_NOISE_WORDS.has(token))
31
- continue;
32
- tokens.add(token);
33
- }
34
- const cjkSegments = text.match(/[\u4e00-\u9fff]+/g) ?? [];
35
- for (const seg of cjkSegments) {
36
- if (seg.length >= 2)
37
- tokens.add(seg);
38
- for (let i = 0; i < seg.length - 1; i += 1) {
39
- tokens.add(seg.slice(i, i + 2));
40
- }
41
- }
42
- return [...tokens].filter((token) => token.length >= 2);
43
- }
44
- function countTopicTokenHits(tokens, normalizedTarget) {
45
- if (!tokens.length || !normalizedTarget)
46
- return 0;
47
- let hits = 0;
48
- for (const token of tokens) {
49
- const normalizedToken = normalizeTopicText(token);
50
- if (normalizedToken.length < 2)
51
- continue;
52
- if (normalizedTarget.includes(normalizedToken))
53
- hits += 1;
54
- }
55
- return hits;
56
- }
57
- const TOPIC_GENERIC_GUARD_WORDS = new Set([
58
- ...TOPIC_EN_NOISE_WORDS,
59
- 'web', 'ios', 'android', 'flutter', 'react', 'vue',
60
- '直播', '消息', '文档', '教程', '指南', '重要', '保证', '丢失', '聊天',
61
- ]);
62
- /**
63
- * 提取更强区分度的话题锚点(通用策略,不针对任何单 case):
64
- * - 英文:长度 >= 4 或包含数字(如 AVChatRoom / trtc123)
65
- * - 中文:连续 3 字以上片段(避免 2-gram 过宽)
66
- */
67
- function extractStrongTopicAnchors(text) {
68
- const anchors = new Set();
69
- for (const match of text.matchAll(/\b[a-z][a-z0-9_]{3,}\b/gi)) {
70
- const token = match[0].toLowerCase();
71
- if (TOPIC_GENERIC_GUARD_WORDS.has(token))
72
- continue;
73
- anchors.add(token);
74
- }
75
- for (const segment of text.match(/[\u4e00-\u9fff]{3,}/g) ?? []) {
76
- if (TOPIC_GENERIC_GUARD_WORDS.has(segment))
77
- continue;
78
- anchors.add(segment);
79
- }
80
- return [...anchors];
81
- }
82
14
  function extractPrimaryHeading(content) {
83
15
  const heading = content.split('\n').find((line) => /^#\s+/.test(line.trim()));
84
16
  return heading ? heading.replace(/^#\s+/, '').trim() : '';
85
17
  }
86
- function buildBestPracticeTopicText(chunk) {
87
- if (!chunk)
88
- return '';
89
- return normalizeTopicText([
90
- chunk.title,
91
- ...(chunk.key_phrases ?? []),
92
- extractPrimaryHeading(chunk.content),
93
- ].filter(Boolean).join(' '));
94
- }
95
18
  const DIAGNOSIS_INTENT_RE = /丢消息|消息丢失|message\s*loss|消息可靠性|message\s*reliability|超时|timeout|失败|fail(?:ed|ure)?|排查|诊断|troubleshoot(?:ing)?/i;
96
19
  const DIAGNOSIS_DOC_RE = /diagnosis|troubleshoot|排查|诊断|丢失|message\s*loss|reliability|可靠性|timeout|fail(?:ed|ure)?/i;
97
20
  const TUTORIAL_DOC_RE = /audio[\s-]*message|voice[\s-]*message|语音消息|录音|recorder|create[a-z]+message|sendmessage|standard\s*(approach|usage)|标准用法|如何发送|how\s*to\s*send/i;
98
21
  function isDiagnosisIntent(prompt) {
99
22
  return DIAGNOSIS_INTENT_RE.test(prompt);
100
23
  }
24
+ function decidePromptLockPolicy(prompt) {
25
+ const integrationIntent = /集成|接入|指南|步骤|教程|快速开始|初始化|安装|跑通|demo|sample|example/i.test(prompt);
26
+ const productIntent = /配置|限制|上限|规则|权限|计费|套餐|价格|资费|多端登录|错误码|命名|长度|开通|购买/i.test(prompt);
27
+ if (integrationIntent && !productIntent) {
28
+ return {
29
+ enableContentPriority: true,
30
+ enableFullDocumentLock: true,
31
+ };
32
+ }
33
+ return {
34
+ enableContentPriority: false,
35
+ enableFullDocumentLock: false,
36
+ };
37
+ }
101
38
  function buildChunkIntentText(chunk) {
102
39
  return [
103
40
  chunk.title,
@@ -131,16 +68,21 @@ function applyDiagnosisIntentGuard(ranked, chunksById, prompt) {
131
68
  adjusted = true;
132
69
  return { ...item, score: item.score - penalty };
133
70
  });
134
- return adjusted ? next.sort((a, b) => b.score - a.score) : ranked;
71
+ return adjusted ? stableSortRanked(next) : ranked;
135
72
  }
136
73
  /**
137
- * A3: 同 prompt 下 best-practice 优先(仅在分数接近时触发,避免召回退化)
138
- * 触发条件:
74
+ * A3: best-practice 后处理轻校准(不再做“主通道抢首位”)
75
+ *
76
+ * 目标:
77
+ * - 保留 BP 的可见性优势(在接近相关时可轻微上浮)
78
+ * - 避免强行顶到 Top1 导致“策略叠策略”的排序失真
79
+ *
80
+ * 触发条件(更保守):
139
81
  * - 当前 Top1 不是 best-practice
140
- * - 存在 best-practice 候选
141
- * - 该候选分数与 Top1 差距不超过 18%
82
+ * - Top20 内存在 BP 候选
83
+ * - 候选与 Top1 分差不大(gap <= 20%)且具备基础强度
142
84
  */
143
- function prioritizeBestPracticeWhenCompetitive(ranked, chunksById, prompt) {
85
+ function prioritizeBestPracticeWhenCompetitive(ranked, chunksById, _prompt) {
144
86
  if (ranked.length < 2)
145
87
  return ranked;
146
88
  const top = ranked[0];
@@ -148,14 +90,12 @@ function prioritizeBestPracticeWhenCompetitive(ranked, chunksById, prompt) {
148
90
  if (!topChunk || isBestPracticeSource(topChunk.source) || top.score <= 0) {
149
91
  return ranked;
150
92
  }
151
- // 收集所有 best-practice 候选,取分数最高者,避免"首个 best-practice 不一定最相关"的偏置。
93
+ const searchWindow = Math.min(ranked.length, 20);
152
94
  let bestPracticeIdx = -1;
153
95
  let bestPracticeScore = -Infinity;
154
- for (let i = 1; i < ranked.length; i += 1) {
96
+ for (let i = 1; i < searchWindow; i += 1) {
155
97
  const chunk = chunksById.get(ranked[i].id);
156
- if (!chunk)
157
- continue;
158
- if (!isBestPracticeSource(chunk.source))
98
+ if (!chunk || !isBestPracticeSource(chunk.source))
159
99
  continue;
160
100
  if (ranked[i].score > bestPracticeScore) {
161
101
  bestPracticeScore = ranked[i].score;
@@ -166,71 +106,25 @@ function prioritizeBestPracticeWhenCompetitive(ranked, chunksById, prompt) {
166
106
  return ranked;
167
107
  const candidate = ranked[bestPracticeIdx];
168
108
  const gapRatio = (top.score - candidate.score) / top.score;
169
- // 常规场景:仅在分数接近时提升(避免召回退化)
170
- const normalCompetitive = gapRatio <= 0.35;
171
- // URL 映射/FAQ 抢首位场景:只要 best-practice 达到基础相关阈值,也应优先返回可执行内容
172
- // 这样可避免"只有 URL 映射行或 FAQ 简答,没有本地内容"的体验退化。
173
- // chat/urls 的 URL 行:需额外校验 BP 和 URL 行是否同一话题,
174
- // 避免"问地理位置消息,URL 行是地理位置,但 BP 是语音消息"的跨话题 shadow。
175
- const topIsUrlMapping = Boolean(isUrlMappingSource(topChunk.source));
176
- const topIsChatUrlRow = topIsUrlMapping && topChunk.source.includes('knowledge/chat/urls/');
177
- const topIsFaq = topChunk.source.includes('/faq/');
178
- // chat/urls 话题一致性:收紧为“高精度 + 多信号”
179
- // 1) prompt 中数字/错误码(高区分信号)
180
- // 2) prompt 切词与 BP 主题 token 命中
181
- // 3) URL 行标题切词与 BP 主题 token 命中(跨语言兜底)
182
- // 4) 通用强锚点(长 token / CJK>=3)需与 BP 主题有交集(若 prompt 存在强锚点)
183
- let chatUrlRowTopicMatch = true;
184
- let chatUrlTopicSignalCount = 0;
185
- if (topIsChatUrlRow) {
186
- const bpChunk = chunksById.get(candidate.id);
187
- const bpTopicText = buildBestPracticeTopicText(bpChunk);
188
- const promptDigits = prompt.match(/\b\d{2,}\b/g) ?? [];
189
- const digitMatch = promptDigits.some((d) => bpTopicText.includes(d));
190
- const promptTokens = extractTopicTokens(prompt);
191
- const promptTokenHitCount = countTopicTokenHits(promptTokens, bpTopicText);
192
- const promptTokenMatch = promptTokenHitCount >= 1;
193
- const urlTopicRaw = (topChunk.title ?? '').replace(/[((].*$/, '').trim();
194
- const urlTopicTokens = extractTopicTokens(urlTopicRaw);
195
- const urlTitleHitCount = countTopicTokenHits(urlTopicTokens, bpTopicText);
196
- const urlTitleMatch = urlTitleHitCount >= 1;
197
- const strongAnchors = extractStrongTopicAnchors(prompt);
198
- const strongAnchorHitCount = countTopicTokenHits(strongAnchors, bpTopicText);
199
- const strongAnchorMatch = strongAnchors.length === 0 || strongAnchorHitCount >= 1;
200
- chatUrlTopicSignalCount = Number(digitMatch) + Number(promptTokenMatch) + Number(urlTitleMatch);
201
- const hasHighPrecisionSignal = digitMatch || urlTitleMatch;
202
- chatUrlRowTopicMatch = hasHighPrecisionSignal
203
- && chatUrlTopicSignalCount >= 2
204
- && strongAnchorMatch;
205
- }
206
- const bestPracticeStrongEnough = candidate.score >= Math.max(12, top.score * 0.05);
207
- const urlRatioThreshold = topIsChatUrlRow ? 0.045 : 0.05;
208
- const urlAbsoluteThreshold = topIsChatUrlRow ? 20 : 16;
209
- const urlStrongEnough = candidate.score >= Math.max(urlAbsoluteThreshold, top.score * urlRatioThreshold);
210
- const urlMappingShadowing = topIsUrlMapping
211
- && urlStrongEnough
212
- && chatUrlRowTopicMatch;
213
- const faqShadowing = topIsFaq && bestPracticeStrongEnough;
214
- // Feature/API 文档抢首位场景:top1 是功能文档(非 integration、非 BP),
215
- // BP 的精准实践应优先于功能文档的泛匹配。
216
- // 条件更严格:BP 分数需达到 top 的 40%(避免完全不相关的 BP 被误提)。
217
- const topIsFeatureDoc = !topIsUrlMapping && !topIsFaq
218
- && !isBestPracticeSource(topChunk.source)
219
- && !isIntegrationSource(topChunk.source);
220
- const featureDocShadowing = topIsFeatureDoc && candidate.score >= top.score * 0.4;
221
- if (!normalCompetitive && !urlMappingShadowing && !faqShadowing && !featureDocShadowing)
109
+ const competitiveEnough = gapRatio <= 0.2;
110
+ const strengthEnough = candidate.score >= Math.max(14, top.score * 0.3);
111
+ if (!competitiveEnough || !strengthEnough)
112
+ return ranked;
113
+ // 轻量校准:最多只允许轻微越过 top,避免“强提权抢首位”。
114
+ const lift = Math.max(top.score * 0.03, 1.2);
115
+ const boostedScore = Math.min(top.score * 1.01, candidate.score + lift);
116
+ if (boostedScore <= candidate.score)
222
117
  return ranked;
223
118
  const next = [...ranked];
224
- next[bestPracticeIdx] = { ...candidate, score: top.score + 2 };
225
- return next.sort((a, b) => b.score - a.score);
119
+ next[bestPracticeIdx] = { ...candidate, score: boostedScore };
120
+ return stableSortRanked(next);
226
121
  }
227
122
  /**
228
- * P0-R1/P0-R2:best-practice 单 chunk 直通。
229
- * 在高置信且无强竞争时,直接锁定 preferredSource + fullDocumentMode。
230
- * 当 promote 已发生(faqShadowing 触发后 best-practice 排到首位),
231
- * 即使分数未达 80 也信任 promote 结果执行收敛。
123
+ * P0-R1/P0-R2:best-practice 单 chunk 收敛(高置信门控版)。
124
+ * 仅当 BP 在跨来源竞争中具备明显优势时,才锁定 preferredSource + fullDocumentMode。
125
+ * 不再因为轻量校准后的微小领先而触发整文锁定。
232
126
  */
233
- function pickDominantBestPracticeSource(ranked, chunksById) {
127
+ function pickDominantBestPracticeSource(ranked, chunksById, fullDocumentMaxChars) {
234
128
  const top = ranked[0];
235
129
  if (!top || top.score <= 0)
236
130
  return { fullDocumentMode: false };
@@ -259,11 +153,12 @@ function pickDominantBestPracticeSource(ranked, chunksById) {
259
153
  .length;
260
154
  const sourceSections = [...chunksById.values()].filter((chunk) => chunk.source === source);
261
155
  const sourceTotalChars = sourceSections.reduce((sum, chunk) => sum + chunk.content.length, 0);
262
- const isPromotedResult = top.score >= 20 && ranked.length >= 2 && (top.score - ranked[1].score) >= 0.05;
263
- const scoreStrongEnough = top.score >= 80 || isPromotedResult;
264
- const dominanceStrongEnough = secondCrossScore === 0 || (top.score / secondCrossScore) >= 1.2 || isPromotedResult;
265
- const crossSourceNotCompeting = strongCrossSourceCount === 0 || isPromotedResult;
266
- const withinLengthBudget = sourceTotalChars <= 7000;
156
+ const scoreStrongEnough = top.score >= 85;
157
+ const dominanceStrongEnough = secondCrossScore === 0
158
+ ? top.score >= 65
159
+ : (top.score / secondCrossScore) >= 1.25;
160
+ const crossSourceNotCompeting = strongCrossSourceCount === 0;
161
+ const withinLengthBudget = sourceTotalChars <= fullDocumentMaxChars;
267
162
  if (scoreStrongEnough && dominanceStrongEnough && crossSourceNotCompeting && withinLengthBudget) {
268
163
  return {
269
164
  preferredSource: source,
@@ -276,6 +171,51 @@ function isIntegrationSource(source) {
276
171
  return /\/integration\.md$/i.test(source)
277
172
  || /\/preset\/.+integration/i.test(source);
278
173
  }
174
+ function isNavigationIntent(prompt) {
175
+ return /文档|链接|地址|url|网址|入口|在哪看|在哪里看|官网|文档地址|sdk文档/i.test(prompt);
176
+ }
177
+ /**
178
+ * 通用语义仲裁(非 integration 特化):
179
+ * 当 top1 落在 urls 映射行(导航型来源),且向量 top1 指向内容型文档时,
180
+ * 对“非导航意图”的 query 优先选择内容文档来源。
181
+ *
182
+ * 目的:避免 BM25 在 urls 索引行上的词面优势压制真实内容页。
183
+ */
184
+ function resolveSemanticSourcePriorityByVector(params) {
185
+ const { ranked, chunksById, vectorTopId, prompt, fullDocumentMaxChars, } = params;
186
+ if (!vectorTopId || ranked.length === 0)
187
+ return null;
188
+ if (isNavigationIntent(prompt))
189
+ return null;
190
+ const top = ranked[0];
191
+ const topChunk = chunksById.get(top.id);
192
+ const vectorTopChunk = chunksById.get(vectorTopId);
193
+ if (!topChunk || !vectorTopChunk)
194
+ return null;
195
+ // 仅在“导航来源(top1) vs 内容来源(vectorTop)”冲突时触发
196
+ if (!isUrlMappingSource(topChunk.source))
197
+ return null;
198
+ if (isUrlMappingSource(vectorTopChunk.source))
199
+ return null;
200
+ if (topChunk.source === vectorTopChunk.source)
201
+ return null;
202
+ const vectorTopRanked = ranked.find((item) => item.id === vectorTopId);
203
+ if (!vectorTopRanked)
204
+ return null;
205
+ // 置信门槛:vector top 不能离 top1 太远,避免误切
206
+ const scoreGapOk = vectorTopRanked.score >= top.score * 0.45;
207
+ if (!scoreGapOk)
208
+ return null;
209
+ const sourceTotalChars = [...chunksById.values()]
210
+ .filter((chunk) => chunk.source === vectorTopChunk.source)
211
+ .reduce((sum, chunk) => sum + chunk.content.length, 0);
212
+ if (sourceTotalChars > fullDocumentMaxChars)
213
+ return null;
214
+ return {
215
+ preferredSource: vectorTopChunk.source,
216
+ fullDocumentMode: true,
217
+ };
218
+ }
279
219
  /**
280
220
  * 覆盖度竞争:在 A3 之前执行。
281
221
  * 当 integration 的多个 section 被查询广泛命中时,选择 integration 完整文档;
@@ -287,16 +227,16 @@ function isIntegrationSource(source) {
287
227
  * - BP 分数远高于 integration(>1.2×)→ 直接返回 null
288
228
  * - 否则 → 返回 null
289
229
  */
290
- function resolveContentPriority(ranked, chunksById) {
230
+ function resolveContentPriority(ranked, chunksById, fullDocumentMaxChars) {
291
231
  if (ranked.length < 2)
292
232
  return null;
293
233
  // 找 BP 最高分(在 top-30 范围内)
294
234
  let bpTopScore = -1;
295
235
  for (let i = 0; i < Math.min(ranked.length, 30); i++) {
296
236
  const chunk = chunksById.get(ranked[i].id);
297
- if (!chunk || !isBestPracticeSource(chunk.source))
237
+ if (!chunk)
298
238
  continue;
299
- if (ranked[i].score > bpTopScore) {
239
+ if (isBestPracticeSource(chunk.source) && ranked[i].score > bpTopScore) {
300
240
  bpTopScore = ranked[i].score;
301
241
  }
302
242
  }
@@ -334,9 +274,13 @@ function resolveContentPriority(ranked, chunksById) {
334
274
  bestIntTopScore = stats.topScore;
335
275
  }
336
276
  }
277
+ const integrationSourceTotalChars = [...chunksById.values()]
278
+ .filter((chunk) => chunk.source === bestIntSource)
279
+ .reduce((sum, chunk) => sum + chunk.content.length, 0);
280
+ const withinLengthBudget = integrationSourceTotalChars <= fullDocumentMaxChars;
337
281
  // 无 BP 候选 → 选 integration(如果有足够 section 命中)
338
282
  if (bpTopScore < 0) {
339
- return bestHitCount >= 2
283
+ return bestHitCount >= 2 && withinLengthBudget
340
284
  ? { preferredSource: bestIntSource, fullDocumentMode: true }
341
285
  : null;
342
286
  }
@@ -345,12 +289,135 @@ function resolveContentPriority(ranked, chunksById) {
345
289
  return null;
346
290
  // 覆盖度判定
347
291
  const BREADTH_THRESHOLD = 3;
348
- if (bestHitCount >= BREADTH_THRESHOLD && bestIntTopScore >= bpTopScore * 0.6) {
292
+ if (bestHitCount >= BREADTH_THRESHOLD && bestIntTopScore >= bpTopScore * 0.6 && withinLengthBudget) {
349
293
  return { preferredSource: bestIntSource, fullDocumentMode: true };
350
294
  }
351
295
  return null;
352
296
  }
353
- export function runSearchPass(params) {
297
+ const DEFAULT_VECTOR_TOP_K = 30;
298
+ const DEFAULT_RRF_K = 60;
299
+ const DEFAULT_RERANK_TOP_K = 20;
300
+ function readEnvNumber(name, fallback) {
301
+ const raw = process.env[name];
302
+ if (raw === undefined || raw === '')
303
+ return fallback;
304
+ const val = Number(raw);
305
+ return Number.isFinite(val) ? val : fallback;
306
+ }
307
+ function stableSortRanked(items) {
308
+ return [...items].sort((a, b) => {
309
+ if (b.score !== a.score)
310
+ return b.score - a.score;
311
+ return a.id.localeCompare(b.id);
312
+ });
313
+ }
314
+ function fuseByRRF(params) {
315
+ const { bm25Ranked, vectorHits, rrfK, baseBeta, alpha, profile, } = params;
316
+ const bmWeight = profile?.bmWeight ?? 1;
317
+ const vecWeight = profile?.vecWeight ?? 1;
318
+ const alphaScale = profile?.alphaScale ?? 1;
319
+ if (!bm25Ranked.length && !vectorHits.length) {
320
+ return { ranked: [], bm25AdjustedCount: 0, vectorOnlyAddedCount: 0 };
321
+ }
322
+ const topBM25 = bm25Ranked[0]?.score ?? baseBeta;
323
+ const bm25ById = new Map(bm25Ranked.map((item) => [item.id, item]));
324
+ const bm25Rank = new Map();
325
+ bm25Ranked.forEach((item, idx) => bm25Rank.set(item.id, idx + 1));
326
+ const vecRank = new Map();
327
+ vectorHits.forEach((hit, idx) => vecRank.set(hit.id, idx + 1));
328
+ const vecMin = vectorHits.length ? Math.min(...vectorHits.map((h) => h.score)) : 0;
329
+ const vecMax = vectorHits.length ? Math.max(...vectorHits.map((h) => h.score)) : 1;
330
+ const vecRange = vecMax - vecMin || 1;
331
+ const vecNorm = new Map();
332
+ for (const hit of vectorHits) {
333
+ vecNorm.set(hit.id, (hit.score - vecMin) / vecRange);
334
+ }
335
+ const unionIds = new Set([
336
+ ...bm25Ranked.map((item) => item.id),
337
+ ...vectorHits.map((item) => item.id),
338
+ ]);
339
+ const rrfRaw = new Map();
340
+ let maxRRF = 0;
341
+ for (const id of unionIds) {
342
+ const bRank = bm25Rank.get(id);
343
+ const vRank = vecRank.get(id);
344
+ const score = (bRank ? bmWeight / (rrfK + bRank) : 0)
345
+ + (vRank ? vecWeight / (rrfK + vRank) : 0);
346
+ rrfRaw.set(id, score);
347
+ if (score > maxRRF)
348
+ maxRRF = score;
349
+ }
350
+ let bm25AdjustedCount = 0;
351
+ let vectorOnlyAddedCount = 0;
352
+ // 类别权重也影响 hybrid 融合权重,避免 rrf 权重与 sim 权重口径分裂。
353
+ const totalWeight = bmWeight + vecWeight || 1;
354
+ const simWeight = vecWeight / totalWeight; // 0..1
355
+ const rrfWeight = 1 - simWeight;
356
+ const fused = [...unionIds].map((id) => {
357
+ const original = bm25ById.get(id);
358
+ const rrfNorm = maxRRF > 0 ? (rrfRaw.get(id) ?? 0) / maxRRF : 0;
359
+ const simNorm = vecNorm.get(id) ?? 0;
360
+ const hybridNorm = rrfWeight * rrfNorm + simWeight * simNorm;
361
+ const effectiveAlpha = alpha * alphaScale;
362
+ if (original) {
363
+ const hasVectorSignal = vecRank.has(id);
364
+ if (hasVectorSignal)
365
+ bm25AdjustedCount += 1;
366
+ const boost = hasVectorSignal ? original.score * effectiveAlpha * 0.25 * hybridNorm : 0;
367
+ return { id, score: original.score + boost };
368
+ }
369
+ vectorOnlyAddedCount += 1;
370
+ return { id, score: topBM25 * baseBeta * (1 + effectiveAlpha * 0.9 * hybridNorm) };
371
+ });
372
+ return {
373
+ ranked: stableSortRanked(fused),
374
+ bm25AdjustedCount,
375
+ vectorOnlyAddedCount,
376
+ };
377
+ }
378
+ function resolveFusionProfile(prompt, queryApiTokens) {
379
+ const trimmed = prompt.trim();
380
+ if (queryApiTokens.length > 0) {
381
+ return { name: 'api_token', bmWeight: 1.3, vecWeight: 0.8, alphaScale: 0.7 };
382
+ }
383
+ // 长自然语言:字符数或分词粒度足够
384
+ const tokenCount = tokenize(trimmed).filter((t) => t.length >= 2).length;
385
+ const isLongNatural = trimmed.length >= 14 || tokenCount >= 6;
386
+ if (isLongNatural) {
387
+ return { name: 'natural_language', bmWeight: 0.85, vecWeight: 1.2, alphaScale: 1.15 };
388
+ }
389
+ const isShortKeyword = trimmed.length <= 6 && tokenCount <= 2;
390
+ if (isShortKeyword) {
391
+ return { name: 'short_keyword', bmWeight: 1.0, vecWeight: 1.0, alphaScale: 0.9 };
392
+ }
393
+ return { name: 'default', bmWeight: 1.0, vecWeight: 1.0, alphaScale: 1.0 };
394
+ }
395
+ function rerankTopK(params) {
396
+ const { prompt, ranked, chunksById, topK } = params;
397
+ if (ranked.length < 2 || topK <= 0)
398
+ return { ranked, applied: false };
399
+ const head = ranked.slice(0, topK);
400
+ const tail = ranked.slice(topK);
401
+ const promptTokens = [...new Set(tokenize(prompt).filter((t) => t.length >= 2))];
402
+ if (!promptTokens.length)
403
+ return { ranked, applied: false };
404
+ const topScore = head[0]?.score ?? 0;
405
+ const bumped = head.map((item) => {
406
+ const chunk = chunksById.get(item.id);
407
+ if (!chunk)
408
+ return item;
409
+ const text = [chunk.title, ...(chunk.key_phrases ?? [])].join(' ').toLowerCase();
410
+ const overlap = promptTokens.reduce((acc, token) => (text.includes(token.toLowerCase()) ? acc + 1 : acc), 0);
411
+ if (overlap <= 0)
412
+ return item;
413
+ const boost = topScore * 0.002 * overlap;
414
+ return { ...item, score: item.score + boost };
415
+ });
416
+ const rerankedHead = stableSortRanked(bumped);
417
+ const applied = rerankedHead.some((item, idx) => item.id !== head[idx]?.id);
418
+ return { ranked: [...rerankedHead, ...tail], applied };
419
+ }
420
+ export async function runSearchPass(params) {
354
421
  const { allChunks, passNorm, retrievalMode, queryApiTokens, opts, } = params;
355
422
  const effectivePassNorm = retrievalMode === 'discovery'
356
423
  ? { ...passNorm, types: undefined }
@@ -360,6 +427,10 @@ export function runSearchPass(params) {
360
427
  if (pool.length === 0 && effectivePassNorm.types?.length) {
361
428
  pool = filterPool(allChunks, { ...effectivePassNorm, types: undefined });
362
429
  }
430
+ // 分层过滤:向量语义召回仅受 Hard 层(product + retrievable_scope)约束,
431
+ // framework/types/scenario 转到 applyRankMultipliers 做软降权,保留向量的跨平台/类型自愈能力。
432
+ const hardPool = filterPoolHard(allChunks, effectivePassNorm);
433
+ const hardChunksById = new Map(hardPool.map((chunk) => [chunk.id, chunk]));
363
434
  const { boosts, exactHits, urlRowHitChunkIds } = runExactLookup(effectivePassNorm.prompt, pool, effectivePassNorm);
364
435
  // A2 补充正向路由:UserSig 问法下允许 share 命中且优先于无关 urls 噪声
365
436
  if (/usersig|sdkappid|secretkey|鉴权|签名|gentestusersig/i.test(passNorm.prompt)) {
@@ -387,36 +458,169 @@ export function runSearchPass(params) {
387
458
  boosts[id] = (boosts[id] ?? 0) + amount;
388
459
  }
389
460
  const ranked = rankSubsetWithIndex(effectivePassNorm.prompt, pool.map((chunk) => chunk.id), productIndex, boosts);
390
- const rankedWithMultipliers = applyRankMultipliers(ranked, chunksById, {
461
+ // 固定链路:Hybrid(BM25 + Vector)→ RRF 融合 → 轻量重排。
462
+ // 兼容开关:DISABLE_VECTOR_AUGMENT=1 时只关闭向量相关步骤,BM25 主链路保持不变。
463
+ const vectorAugmentDisabled = process.env.DISABLE_VECTOR_AUGMENT === '1';
464
+ const alphaOverride = process.env.VECTOR_AUGMENT_ALPHA
465
+ ? Number(process.env.VECTOR_AUGMENT_ALPHA)
466
+ : undefined;
467
+ const alpha = alphaOverride !== undefined && !Number.isNaN(alphaOverride) ? alphaOverride : 0.15;
468
+ const baseBeta = readEnvNumber('VECTOR_AUGMENT_BASE_BETA', 0.3);
469
+ const topK = Math.max(1, Math.floor(readEnvNumber('VECTOR_TOP_K', DEFAULT_VECTOR_TOP_K)));
470
+ const rrfK = Math.max(1, Math.floor(readEnvNumber('VECTOR_RRF_K', DEFAULT_RRF_K)));
471
+ const rerankTopKLimit = Math.max(0, Math.floor(readEnvNumber('VECTOR_RERANK_TOP_K', DEFAULT_RERANK_TOP_K)));
472
+ const fullDocumentMaxChars = Math.max(1000, Math.floor(readEnvNumber('SEARCH_FULL_DOCUMENT_MAX_CHARS', 7000)));
473
+ let rankedFused = ranked;
474
+ // 向量归因埋点采集(终态归因在函数末尾统一构造):
475
+ // - bm25TopIdSnapshot:向量介入前的 BM25 单路头名(即 ranked[0],融合前的原始头名)
476
+ // - vectorTopIdSnapshot:向量单路头名(vecHits[0],经 hardChunksById 过滤后)
477
+ // - vecScoreById:向量单路每个候选的 sim 分,供 final_top1_id 查表得 vector_score_of_final_top1
478
+ // - bm25RankById:向量介入前 BM25 单路的 1-based 名次索引,供 rank_shift 与 promoted_by_vector_count 计算
479
+ const bm25TopIdSnapshot = ranked[0]?.id ?? null;
480
+ const bm25RankById = new Map();
481
+ ranked.forEach((item, idx) => bm25RankById.set(item.id, idx + 1));
482
+ let vectorTopIdSnapshot = null;
483
+ let vecScoreById = new Map();
484
+ if (!vectorAugmentDisabled) {
485
+ try {
486
+ const [qvecInst, qvecRaw] = await Promise.all([
487
+ embedQuery(withQueryInstruction(effectivePassNorm.prompt)),
488
+ embedQuery(effectivePassNorm.prompt),
489
+ ]);
490
+ const [hitsInst, hitsRaw] = await Promise.all([
491
+ queryVectors(passNorm.product, qvecInst, topK),
492
+ queryVectors(passNorm.product, qvecRaw, topK),
493
+ ]);
494
+ const merged = new Map();
495
+ for (const h of hitsInst) {
496
+ merged.set(h.id, Math.max(merged.get(h.id) ?? Number.NEGATIVE_INFINITY, h.score));
497
+ }
498
+ for (const h of hitsRaw) {
499
+ const weighted = h.score * 0.97;
500
+ merged.set(h.id, Math.max(merged.get(h.id) ?? Number.NEGATIVE_INFINITY, weighted));
501
+ }
502
+ const hits = [...merged.entries()]
503
+ .map(([id, score]) => ({ id, score }))
504
+ .sort((a, b) => {
505
+ if (b.score !== a.score)
506
+ return b.score - a.score;
507
+ return a.id.localeCompare(b.id);
508
+ })
509
+ .slice(0, topK);
510
+ // 分层过滤:向量仅受 Hard 层(product + retrievable_scope)约束,
511
+ // framework/types/scenario 交给 applyRankMultipliers 做软降权。
512
+ // 向量补召回但不在 BM25 pool 内的 chunk 需要挂到 chunksById,供下游 rerank / multiplier / postProcess 读取。
513
+ const vecHits = hits.filter((h) => hardChunksById.has(h.id));
514
+ for (const hit of vecHits) {
515
+ if (chunksById.has(hit.id))
516
+ continue;
517
+ const chunk = hardChunksById.get(hit.id);
518
+ if (!chunk)
519
+ continue;
520
+ chunksById.set(hit.id, attachChunkContent(chunk));
521
+ }
522
+ vectorTopIdSnapshot = vecHits[0]?.id ?? null;
523
+ vecScoreById = new Map(vecHits.map((h) => [h.id, h.score]));
524
+ if (vecHits.length > 0) {
525
+ const fusionProfile = resolveFusionProfile(effectivePassNorm.prompt, queryApiTokens);
526
+ const fused = fuseByRRF({
527
+ bm25Ranked: ranked,
528
+ vectorHits: vecHits,
529
+ rrfK,
530
+ baseBeta,
531
+ alpha,
532
+ profile: fusionProfile,
533
+ });
534
+ const reranked = rerankTopK({
535
+ prompt: effectivePassNorm.prompt,
536
+ ranked: fused.ranked,
537
+ chunksById,
538
+ topK: rerankTopKLimit,
539
+ });
540
+ rankedFused = reranked.ranked;
541
+ }
542
+ }
543
+ catch {
544
+ // 向量链路失败(embed/query 异常):保持 rankedFused = ranked,
545
+ // 快照变量维持初始值即可。归因阶段 vector_top1_id 为 null 天然反映降级。
546
+ }
547
+ }
548
+ const rankedWithMultipliers = applyRankMultipliers(rankedFused, chunksById, {
391
549
  product: passNorm.product,
392
550
  framework: passNorm.framework,
393
551
  frameworks: passNorm.frameworks,
394
552
  });
395
553
  const rankedAfterIntentGuard = applyDiagnosisIntentGuard(rankedWithMultipliers, chunksById, passNorm.prompt);
396
- // 覆盖度竞争先于 A3 执行
397
- const integrationPriority = resolveContentPriority(rankedAfterIntentGuard, chunksById);
554
+ const promptLockPolicy = decidePromptLockPolicy(passNorm.prompt);
555
+ const allowContentPriority = promptLockPolicy.enableContentPriority;
556
+ const allowFullDocumentLock = promptLockPolicy.enableFullDocumentLock;
557
+ // 覆盖度竞争先于 A3 执行(仅在前置策略允许时开启)
558
+ // 若覆盖度竞争未触发,再尝试“导航来源 vs 内容来源”的通用语义仲裁。
559
+ const integrationPriority = allowContentPriority
560
+ ? (resolveContentPriority(rankedAfterIntentGuard, chunksById, fullDocumentMaxChars)
561
+ ?? resolveSemanticSourcePriorityByVector({
562
+ ranked: rankedAfterIntentGuard,
563
+ chunksById,
564
+ vectorTopId: vectorTopIdSnapshot,
565
+ prompt: passNorm.prompt,
566
+ fullDocumentMaxChars,
567
+ }))
568
+ : null;
398
569
  let dominantBestPractice;
399
570
  let rankedAfterBestPractice;
400
571
  if (integrationPriority) {
401
572
  // integration 胜出 → 跳过 A3,直接走完整文档路径
402
573
  rankedAfterBestPractice = rankedAfterIntentGuard;
403
- dominantBestPractice = integrationPriority;
574
+ dominantBestPractice = allowFullDocumentLock
575
+ ? integrationPriority
576
+ : { fullDocumentMode: false };
404
577
  }
405
578
  else {
406
579
  // integration 未胜出 → A3 提升 BP → BP 收敛判定
407
580
  rankedAfterBestPractice = prioritizeBestPracticeWhenCompetitive(rankedAfterIntentGuard, chunksById, passNorm.prompt);
408
- dominantBestPractice = pickDominantBestPracticeSource(rankedAfterBestPractice, chunksById);
581
+ dominantBestPractice = allowFullDocumentLock
582
+ ? pickDominantBestPracticeSource(rankedAfterBestPractice, chunksById, fullDocumentMaxChars)
583
+ : { fullDocumentMode: false };
409
584
  }
410
585
  const pinnedUrlRowChunkIds = new Set(urlRowHitChunkIds);
586
+ // 独立信号:存在精确 URL 命中 → 启用 35% 断崖截断。
587
+ // 与 pin 语义解耦:即便 pin 集合发生变化或 BP 校准影响 fragments 组成,
588
+ // 截断策略仍由“是否存在精确 URL 命中”这一确定性信号决定。
589
+ const strictCutoffOnExactUrlHit = pinnedUrlRowChunkIds.size > 0;
411
590
  const fragments = postProcess(rankedAfterBestPractice, chunksById, passNorm.limit, {
412
591
  pinnedUrlRowChunkIds,
592
+ strictCutoffOnExactUrlHit,
413
593
  preferredSource: dominantBestPractice.preferredSource,
414
594
  fullDocumentMode: dominantBestPractice.fullDocumentMode,
595
+ fullDocumentMaxChars,
415
596
  });
416
597
  const urlMappingSources = [...new Set(fragments.map((f) => f.source).filter((source) => isUrlMappingSource(source)))];
417
598
  const references = buildReferences(fragments);
418
599
  const apiNameCoverage = computeApiNameCoverage(queryApiTokens, poolWithContent);
419
600
  const missingApiTokens = computeMissingApiTokens(queryApiTokens, poolWithContent);
601
+ // === 向量归因(终态构造)===
602
+ // final_top1_id:postProcess 之后 fragments[0] 的 chunk_id(enrichL1Fragments 后续 in-place enrich
603
+ // content 但不改动 chunk_id / 顺序,所以此处快照即最终外部感知的 top1)。
604
+ const finalTop1Id = fragments[0]?.chunk_id ?? null;
605
+ const finalTop1BmRank = finalTop1Id ? (bm25RankById.get(finalTop1Id) ?? null) : null;
606
+ const finalTop1VecScore = finalTop1Id ? (vecScoreById.get(finalTop1Id) ?? null) : null;
607
+ // promoted_by_vector_count:BM25 原始名次 > 3 且经融合/后处理进入 final top3 的 chunk 数量。
608
+ // 只需交集 |{chunk : bm25Rank(chunk) > 3 AND chunk ∈ finalTop3}|;ranked 为空自然记 0。
609
+ const finalTop3Ids = fragments.slice(0, 3).map((f) => f.chunk_id);
610
+ let promotedByVectorCount = 0;
611
+ for (const id of finalTop3Ids) {
612
+ const bmRank = bm25RankById.get(id);
613
+ if (bmRank !== undefined && bmRank > 3)
614
+ promotedByVectorCount += 1;
615
+ }
616
+ const vectorDiagnostics = {
617
+ bm25_top1_id: bm25TopIdSnapshot,
618
+ vector_top1_id: vectorTopIdSnapshot,
619
+ final_top1_id: finalTop1Id,
620
+ rank_shift_from_bm25: finalTop1BmRank,
621
+ vector_score_of_final_top1: finalTop1VecScore,
622
+ promoted_by_vector_count: promotedByVectorCount,
623
+ };
420
624
  return {
421
625
  pool,
422
626
  poolWithContent,
@@ -429,6 +633,7 @@ export function runSearchPass(params) {
429
633
  missingApiTokens,
430
634
  urlMapRelaxed: pinnedUrlRowChunkIds.size > 0,
431
635
  urlMapExactHitCount: pinnedUrlRowChunkIds.size,
636
+ vectorDiagnostics,
432
637
  preferredSource: dominantBestPractice.preferredSource,
433
638
  fullDocumentMode: dominantBestPractice.fullDocumentMode,
434
639
  };