semcode-mcp 0.1.0__py3-none-any.whl
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.
- semantic_code_mcp/__init__.py +3 -0
- semantic_code_mcp/chunker.py +399 -0
- semantic_code_mcp/embedder.py +192 -0
- semantic_code_mcp/expander.py +114 -0
- semantic_code_mcp/indexer.py +416 -0
- semantic_code_mcp/retriever.py +705 -0
- semantic_code_mcp/server.py +284 -0
- semantic_code_mcp/store.py +488 -0
- semantic_code_mcp/watcher.py +156 -0
- semantic_code_mcp/workspace.py +218 -0
- semcode_mcp-0.1.0.dist-info/METADATA +272 -0
- semcode_mcp-0.1.0.dist-info/RECORD +15 -0
- semcode_mcp-0.1.0.dist-info/WHEEL +4 -0
- semcode_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- semcode_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""检索层:向量 + BM25 hybrid 召回,RRF 融合,rerank 重排,意图感知打分。
|
|
2
|
+
|
|
3
|
+
流程:query -> 向量召回 + BM25 召回 -> RRF 融合 -> (可选)rerank
|
|
4
|
+
-> 调用链意图直查 edges 置顶 -> 测试/barrel/非代码降权(测试与文档意图反转为 boost)
|
|
5
|
+
-> 符号/文件名 boost -> 文件多样性截断 -> call graph 图扩展
|
|
6
|
+
|
|
7
|
+
rerank 后端(SCM_RERANK_BACKEND):
|
|
8
|
+
auto(默认) 有 VOYAGE_API_KEY 用 Voyage rerank-2.5(与 embedding 同一个 key);
|
|
9
|
+
否则有 COHERE_API_KEY 用 Cohere;都没有则跳过 rerank 直接用 RRF 分数
|
|
10
|
+
voyage 强制 Voyage(默认模型 rerank-2.5)
|
|
11
|
+
cohere 强制 Cohere(默认模型 rerank-v3.5)
|
|
12
|
+
off 禁用 rerank
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import fnmatch
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from .embedder import Embedder
|
|
22
|
+
from .expander import create_expander
|
|
23
|
+
from .store import CodeStore, _is_boilerplate_symbol
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import cohere
|
|
27
|
+
except Exception: # 依赖缺失不阻断
|
|
28
|
+
cohere = None
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import voyageai
|
|
32
|
+
except Exception: # 依赖缺失不阻断
|
|
33
|
+
voyageai = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# RRF 融合常数,经验值 60
|
|
37
|
+
_RRF_K = 60
|
|
38
|
+
# 送入 rerank 的单文档最大字符数
|
|
39
|
+
_RERANK_DOC_MAX_CHARS = 4000
|
|
40
|
+
# rerank 429 限速重试:次数与退避基数(试用/未绑卡 key 限速低,
|
|
41
|
+
# 静默降级会让排序质量随机劣化,退避重试把调用速率压回限额内)
|
|
42
|
+
_RERANK_MAX_RETRIES = 2
|
|
43
|
+
_RERANK_BACKOFF_S = 6.0
|
|
44
|
+
# 各后端默认 rerank 模型
|
|
45
|
+
_RERANK_DEFAULT_MODELS = {"voyage": "rerank-2.5", "cohere": "rerank-v3.5"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rerank_min_interval() -> float:
|
|
49
|
+
"""rerank 调用最小间隔(秒),0 = 不节流。懒读 env:评测等密集场景
|
|
50
|
+
用它把速率压到试用 key 限额内,生产零星查询不受影响。"""
|
|
51
|
+
try:
|
|
52
|
+
return float(os.getenv("SCM_RERANK_MIN_INTERVAL", "0") or 0)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return 0.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# 测试文件 score 惩罚系数(实现优先于测试)
|
|
58
|
+
_TEST_FILE_PENALTY = 0.5
|
|
59
|
+
# 测试文件名模式
|
|
60
|
+
_TEST_PATTERNS = ("_test.", "test_", "Test.", ".test.", ".spec.", "_spec.")
|
|
61
|
+
# 查询中完整命中符号名时的 boost(如查询里直接写了函数名)
|
|
62
|
+
_SYMBOL_EXACT_BOOST = 1.5
|
|
63
|
+
# 符号/文件名分词与查询 token 每命中一个的 boost 增量(最多计 3 个)
|
|
64
|
+
_NAME_TOKEN_BOOST = 0.1
|
|
65
|
+
# top_n 结果中同一文件最多保留的块数(文件多样性)
|
|
66
|
+
_MAX_PER_FILE = 2
|
|
67
|
+
# barrel / 纯导出入口文件降权(实现文件优先于 re-export 入口)
|
|
68
|
+
_BARREL_PENALTY = 0.85
|
|
69
|
+
# 非代码文件(文档/配置)降权:分数接近时实现代码优先,不影响文档类查询的大分差命中
|
|
70
|
+
# (doc/config 意图查询走反转 boost 不受此值影响;0.7 治动作型查询里 properties 混尾部)
|
|
71
|
+
_NON_CODE_PENALTY = 0.7
|
|
72
|
+
_NON_CODE_LANGS = {"markdown", "yaml", "json", "toml", "properties", "text"}
|
|
73
|
+
_BARREL_NAMES = {"index.ts", "index.js", "index.tsx", "index.jsx", "index.mjs", "__init__.py", "mod.rs"}
|
|
74
|
+
# 调用链意图:"谁调用/被哪些...调用/谁写入/在哪被使用/who calls/callers of/writes to"
|
|
75
|
+
_CALLER_INTENT_RE = re.compile(
|
|
76
|
+
r"(谁调用|被哪些|哪些[^\s]{0,8}调用|调用了?它|被写入|写入|谁在用|被使用|被引用"
|
|
77
|
+
r"|callers?\s+of|who\s+calls|call\s*sites?|writes?\s+to|who\s+writes|where\s+.{0,20}\bused)", re.I
|
|
78
|
+
)
|
|
79
|
+
# 测试意图:查询本身在找测试/基准 -> 反转测试降权为 boost
|
|
80
|
+
_TEST_INTENT_RE = re.compile(r"(\btests?\b|\bspec\b|benchmark|单测|测试|用例|基准)", re.I)
|
|
81
|
+
_TEST_INTENT_BOOST = 1.2
|
|
82
|
+
# 文档意图:查询在找架构/规范/文档 -> 反转非代码降权为 boost
|
|
83
|
+
_DOC_INTENT_RE = re.compile(
|
|
84
|
+
r"(architect|overview|convention|guideline|readme|documentation|\bdocs?\b|规范|架构|职责|约定|文档|说明)", re.I
|
|
85
|
+
)
|
|
86
|
+
_DOC_INTENT_BOOST = 1.5
|
|
87
|
+
# 配置意图:查询在找配置项/schema -> 配置类文件(yaml/toml/properties/json)反转为 boost
|
|
88
|
+
_CONFIG_INTENT_RE = re.compile(r"(config|configuration|settings|\byaml\b|\btoml\b|配置)", re.I)
|
|
89
|
+
_CONFIG_LANGS = {"yaml", "toml", "properties", "json"}
|
|
90
|
+
_CONFIG_INTENT_BOOST = 1.4
|
|
91
|
+
# 纯声明块(Java interface / @FeignClient 契约)降权:实现优先于声明;
|
|
92
|
+
# 短而密集命中查询词的接口/DTO 在三路召回里天然压过大方法体,需要纠偏
|
|
93
|
+
_DECL_PENALTY = 0.75
|
|
94
|
+
# 实体/DTO 块(注解驱动、无控制流)降权
|
|
95
|
+
_ENTITY_PENALTY = 0.85
|
|
96
|
+
# 查询显式找接口定义/契约时不惩罚声明块
|
|
97
|
+
_DECL_INTENT_RE = re.compile(r"(接口定义|契约|declaration|signature|\binterface\b|feign)", re.I)
|
|
98
|
+
# 实现/过程意图("怎么生成"、"how is X done"):答案在实现代码里,
|
|
99
|
+
# DTO/接口即使语义相似也回答不了"怎么做",加重降权
|
|
100
|
+
_IMPL_INTENT_RE = re.compile(r"(怎么|如何|怎样|流程|逻辑|实现|\bhow\b|implement)", re.I)
|
|
101
|
+
_IMPL_INTENT_EXTRA = 0.75
|
|
102
|
+
# 同名接口/实现配对:结果集同时出现 X 与 XImpl 时,实现继承接口分数排到前面
|
|
103
|
+
_IMPL_PAIR_FACTOR = 1.01
|
|
104
|
+
# 超短块(≤此行数)降权:单行方法声明这类低信息命中信息密度撑不起
|
|
105
|
+
# 独立席位(尾部占位问题);温和系数,分数接近时才让位给实质内容
|
|
106
|
+
_TINY_CHUNK_LINES = 2
|
|
107
|
+
_TINY_CHUNK_PENALTY = 0.85
|
|
108
|
+
_JAVA_INTERFACE_RE = re.compile(r"^\s*(?:public\s+)?(?:@\w+(?:\([^)]*\))?\s+)*interface\s+\w", re.M)
|
|
109
|
+
_CONTROL_FLOW_RE = re.compile(r"\b(if|for|while|switch|return)\b")
|
|
110
|
+
# 块内声明方法名提取(caller 方向图扩展用),每块最多取这么多个
|
|
111
|
+
_MAX_DECL_NAMES_PER_CHUNK = 12
|
|
112
|
+
_DECL_NAME_RES = {
|
|
113
|
+
"java": [
|
|
114
|
+
# 带修饰符的方法声明
|
|
115
|
+
re.compile(r"^\s*(?:public|protected|private)\s+(?:(?:static|final|synchronized|abstract|default|native)\s+)*[\w<>\[\],.\s?]+?\s+(\w+)\s*\(", re.M),
|
|
116
|
+
# 接口方法(无修饰符,; 结尾)
|
|
117
|
+
re.compile(r"^\s*[\w<>\[\],.?]+\s+(\w+)\s*\([^;{}]*\)\s*;", re.M),
|
|
118
|
+
],
|
|
119
|
+
"python": [re.compile(r"^\s*def\s+(\w+)\s*\(", re.M)],
|
|
120
|
+
"go": [re.compile(r"^func\s+(?:\([^)]*\)\s*)?(\w+)\s*\(", re.M)],
|
|
121
|
+
"typescript": [re.compile(r"^\s*(?:public\s+|private\s+|protected\s+|export\s+|async\s+)*(\w+)\s*\([^)]*\)\s*[:{]", re.M)],
|
|
122
|
+
}
|
|
123
|
+
_DECL_NAME_STOP = {"if", "for", "while", "switch", "catch", "return", "new", "super", "else", "throw"}
|
|
124
|
+
# CJK 字符(汉字 + 假名 + 谚文):命中时追加 trigram 召回路
|
|
125
|
+
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
|
|
126
|
+
# 复合意图查询拆分:CJK 连接词 / 加号("生成+清理"、"生成并清理")。
|
|
127
|
+
# 长词优先(以及/并且/同时 先于 和/与/并/及);"合并""并发"类词素误拆由
|
|
128
|
+
# 两段最短长度 + 子查询低权重兼容(主查询权重不变,RRF 容错)。
|
|
129
|
+
# 英文 and 不拆:英文复合名词短语(error wrapping and logging)拆开反而稀释信号
|
|
130
|
+
_COMPOUND_SPLIT_RE = re.compile(
|
|
131
|
+
r"[++]|(?<=[\u4e00-\u9fff])(?:以及|并且|同时|和|与|并|及)(?=[\u4e00-\u9fff])"
|
|
132
|
+
)
|
|
133
|
+
# 拆分后子查询最短长度(去空白);2 覆盖中文双字动词("生成"/"清理")
|
|
134
|
+
_COMPOUND_MIN_LEN = 2
|
|
135
|
+
# 子查询召回路权重(低于主查询,防拆分噪声反客为主)
|
|
136
|
+
_COMPOUND_SUB_WEIGHT = 0.7
|
|
137
|
+
# 子查询保底:rerank 用原始完整 query 重打分会把子意图召回的文档抹平
|
|
138
|
+
# ("生成...并清理"的语义重心压向主意图)。保底触发条件:某子意图召回的
|
|
139
|
+
# 前 N 名文件与最终结果零交集(完全无代表);保底块必须来自已过
|
|
140
|
+
# RRF 候选门槛的 candidates(全库 top-1 直插会把"清理"这种宽泛词的噪声带进来)
|
|
141
|
+
_COMPOUND_PROBE_TOP = 10
|
|
142
|
+
# 子查询 rerank 分数混合:探针块用整句 rerank 分天然吃亏("清理 100 天"
|
|
143
|
+
# 在整句里只占小半权重),单独用子查询文本再 rerank 一次,
|
|
144
|
+
# 最终分取 max(整句分, 子查询分 × 此系数)
|
|
145
|
+
_COMPOUND_SUB_RERANK_MIX = 0.9
|
|
146
|
+
# 子查询分参与混合/保底的最低门槛:实测"清理"这种宽泛短词的字面命中
|
|
147
|
+
# (clearCache/clear 工具方法)全部 ≤0.14,真业务清理实现 ≥0.22,
|
|
148
|
+
# 0.2 是天然分界线;低于门槛的子查询信号不参与任何提分
|
|
149
|
+
_COMPOUND_SUB_MIN_SCORE = 0.2
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Retriever:
|
|
153
|
+
"""混合检索器。"""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
store: CodeStore,
|
|
158
|
+
embedder: Embedder,
|
|
159
|
+
rerank_api_key: str | None = None,
|
|
160
|
+
rerank_model: str | None = None,
|
|
161
|
+
expander=None,
|
|
162
|
+
) -> None:
|
|
163
|
+
self.store = store
|
|
164
|
+
self.embedder = embedder
|
|
165
|
+
self.rerank_backend, self.rerank_client = self._create_rerank_client(rerank_api_key)
|
|
166
|
+
self.rerank_model = (
|
|
167
|
+
rerank_model
|
|
168
|
+
or os.getenv("SCM_RERANK_MODEL")
|
|
169
|
+
or _RERANK_DEFAULT_MODELS.get(self.rerank_backend, "")
|
|
170
|
+
)
|
|
171
|
+
# 节流时间戳无锁:SCM_RERANK_MIN_INTERVAL 仅评测等单线程密集场景启用,
|
|
172
|
+
# 生产默认 0(不节流),并发竞态最坏多发一次请求、由 429 重试兜底
|
|
173
|
+
self._last_rerank_ts = 0.0
|
|
174
|
+
# 查询扩展(HyDE + 多查询变体):默认按 SCM_QUERY_EXPANSION 开关创建
|
|
175
|
+
self.expander = expander if expander is not None else create_expander()
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _create_rerank_client(api_key: str | None = None):
|
|
179
|
+
"""按 SCM_RERANK_BACKEND 选择 rerank 后端,返回 (backend, client)。
|
|
180
|
+
|
|
181
|
+
auto:Voyage 优先(embedding 已必配 VOYAGE_API_KEY,同 key 免额外配置,
|
|
182
|
+
且 rerank-2.5 公开基准优于 Cohere v3.5),其次 Cohere,都无则跳过。
|
|
183
|
+
"""
|
|
184
|
+
backend = os.getenv("SCM_RERANK_BACKEND", "auto").lower()
|
|
185
|
+
if backend == "off":
|
|
186
|
+
return "off", None
|
|
187
|
+
voyage_key = os.getenv("VOYAGE_API_KEY")
|
|
188
|
+
cohere_key = api_key or os.getenv("COHERE_API_KEY")
|
|
189
|
+
if backend in ("auto", "voyage") and voyage_key and voyageai:
|
|
190
|
+
return "voyage", voyageai.Client(api_key=voyage_key)
|
|
191
|
+
if backend in ("auto", "cohere") and cohere_key and cohere:
|
|
192
|
+
return "cohere", cohere.Client(cohere_key)
|
|
193
|
+
return "off", None
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _is_test_file(file_path: str) -> bool:
|
|
197
|
+
"""判断是否是测试文件。"""
|
|
198
|
+
name = file_path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
199
|
+
return any(p in name for p in _TEST_PATTERNS)
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _is_barrel_file(file_path: str) -> bool:
|
|
203
|
+
"""判断是否是 barrel / 纯入口文件。"""
|
|
204
|
+
name = file_path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
205
|
+
return name in _BARREL_NAMES
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _noncode_mult(language: str, doc_intent: bool, config_intent: bool) -> float:
|
|
209
|
+
"""非代码文件的意图感知乘数:文档意图 > 配置意图 > 默认降权。"""
|
|
210
|
+
if language not in _NON_CODE_LANGS:
|
|
211
|
+
return 1.0
|
|
212
|
+
if doc_intent:
|
|
213
|
+
return _DOC_INTENT_BOOST
|
|
214
|
+
if config_intent and language in _CONFIG_LANGS:
|
|
215
|
+
return _CONFIG_INTENT_BOOST
|
|
216
|
+
return _NON_CODE_PENALTY
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _intent_symbols(query: str) -> list[str]:
|
|
220
|
+
"""从调用链意图查询中提取代码标识符(camelCase / snake_case)。"""
|
|
221
|
+
out: list[str] = []
|
|
222
|
+
for ident in re.findall(r"[A-Za-z_][A-Za-z0-9_]{3,}", query):
|
|
223
|
+
if "_" in ident or re.search(r"[a-z][A-Z]", ident):
|
|
224
|
+
out.append(ident)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
def _caller_intent_hits(self, query: str) -> list[dict]:
|
|
228
|
+
"""调用链意图:命中意图模式时直查 edges 表,结构化结果置顶。"""
|
|
229
|
+
if not _CALLER_INTENT_RE.search(query):
|
|
230
|
+
return []
|
|
231
|
+
idents: list[str] = []
|
|
232
|
+
for ident in self._intent_symbols(query):
|
|
233
|
+
idents.append(ident)
|
|
234
|
+
# 表名 → 实体类名(tb_order_item → OrderItem):
|
|
235
|
+
# edges 记录了构造类型名(new OrderItem()),能直接命中写入方
|
|
236
|
+
low = ident.lower()
|
|
237
|
+
if low.startswith(("tb_", "t_")):
|
|
238
|
+
parts = [p for p in ident.split("_")[1:] if p]
|
|
239
|
+
if parts:
|
|
240
|
+
idents.append("".join(p[:1].upper() + p[1:] for p in parts))
|
|
241
|
+
hits: list[dict] = []
|
|
242
|
+
seen: set[int] = set()
|
|
243
|
+
for ident in dict.fromkeys(idents):
|
|
244
|
+
# PascalCase 类名同时按实例字段命名约定查一次
|
|
245
|
+
# (Java/Spring: OrderValidator -> orderValidator,edges 记录的是接收者名)
|
|
246
|
+
names = [ident]
|
|
247
|
+
if ident[:1].isupper():
|
|
248
|
+
names.append(ident[:1].lower() + ident[1:])
|
|
249
|
+
for name in names:
|
|
250
|
+
for ch in self.store.callers_of(name):
|
|
251
|
+
if ch["id"] in seen:
|
|
252
|
+
continue
|
|
253
|
+
seen.add(ch["id"])
|
|
254
|
+
ch = dict(ch)
|
|
255
|
+
ch["score"] = 9.99 # 结构化命中,排在所有语义结果之前
|
|
256
|
+
hits.append(ch)
|
|
257
|
+
return hits
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _ident_parts(name: str) -> list[str]:
|
|
261
|
+
"""把标识符拆成小写分词(camelCase / snake_case 兼容)。"""
|
|
262
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name or "")
|
|
263
|
+
return [p for p in re.findall(r"[a-z0-9]+", s.lower()) if len(p) >= 2]
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def _query_tokens(cls, query: str) -> set[str]:
|
|
267
|
+
"""提取查询中的标识符 token(含整词与分词)。"""
|
|
268
|
+
tokens: set[str] = set()
|
|
269
|
+
for w in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", query):
|
|
270
|
+
tokens.add(w.lower())
|
|
271
|
+
tokens.update(cls._ident_parts(w))
|
|
272
|
+
return tokens
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _is_declaration_chunk(chunk: dict) -> bool:
|
|
276
|
+
"""纯声明块:Java interface / @FeignClient 契约(TS interface 同理)。"""
|
|
277
|
+
lang = chunk.get("language") or ""
|
|
278
|
+
code = chunk.get("code") or ""
|
|
279
|
+
if lang == "java":
|
|
280
|
+
return "@FeignClient" in code or bool(_JAVA_INTERFACE_RE.search(code))
|
|
281
|
+
if lang in ("typescript", "tsx"):
|
|
282
|
+
return bool(re.match(r"\s*(?:export\s+)?interface\s", code))
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _is_entity_chunk(chunk: dict) -> bool:
|
|
287
|
+
"""实体/DTO 块:注解驱动的纯数据载体,无控制流。"""
|
|
288
|
+
lang = chunk.get("language") or ""
|
|
289
|
+
code = chunk.get("code") or ""
|
|
290
|
+
if lang != "java":
|
|
291
|
+
return False
|
|
292
|
+
if "@TableName" in code or ("@Data" in code and "class " in code):
|
|
293
|
+
return not _CONTROL_FLOW_RE.search(code)
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def _declared_names(cls, code: str, language: str) -> list[str]:
|
|
298
|
+
"""提取块内声明的方法名(类/接口级 chunk 的 symbol 只有类名,
|
|
299
|
+
caller 方向图扩展需要方法名才能匹配 edges 的调用边)。"""
|
|
300
|
+
regs = _DECL_NAME_RES.get("typescript" if language == "tsx" else language)
|
|
301
|
+
if not regs:
|
|
302
|
+
return []
|
|
303
|
+
names: list[str] = []
|
|
304
|
+
seen: set[str] = set()
|
|
305
|
+
for rg in regs:
|
|
306
|
+
for m in rg.finditer(code):
|
|
307
|
+
n = m.group(1)
|
|
308
|
+
if len(n) < 4 or n in seen or n in _DECL_NAME_STOP:
|
|
309
|
+
continue
|
|
310
|
+
# Java/TS 方法名小写开头,滤掉构造器/类名误匹配
|
|
311
|
+
if language in ("java", "typescript", "tsx") and n[:1].isupper():
|
|
312
|
+
continue
|
|
313
|
+
# accessor/样板方法不参与 caller 反查("谁调用了 setRemark"是纯噪音)
|
|
314
|
+
if _is_boilerplate_symbol(n):
|
|
315
|
+
continue
|
|
316
|
+
seen.add(n)
|
|
317
|
+
names.append(n)
|
|
318
|
+
if len(names) >= _MAX_DECL_NAMES_PER_CHUNK:
|
|
319
|
+
return names
|
|
320
|
+
return names
|
|
321
|
+
|
|
322
|
+
def _name_boost(self, qtokens: set[str], chunk: dict) -> float:
|
|
323
|
+
"""名称信号 boost:符号完整命中强提升;符号/文件名分词命中按个数提升。"""
|
|
324
|
+
if not qtokens:
|
|
325
|
+
return 1.0
|
|
326
|
+
symbol = chunk.get("symbol") or ""
|
|
327
|
+
sym_parts = self._ident_parts(symbol)
|
|
328
|
+
flat = "".join(sym_parts)
|
|
329
|
+
if flat and flat in qtokens:
|
|
330
|
+
return _SYMBOL_EXACT_BOOST
|
|
331
|
+
fp = chunk.get("file_path") or ""
|
|
332
|
+
stem = fp.rsplit("/", 1)[-1].rsplit("\\", 1)[-1].split(".")[0]
|
|
333
|
+
matched = len((set(sym_parts) | set(self._ident_parts(stem))) & qtokens)
|
|
334
|
+
return 1.0 + _NAME_TOKEN_BOOST * min(matched, 3)
|
|
335
|
+
|
|
336
|
+
def search(
|
|
337
|
+
self,
|
|
338
|
+
query: str,
|
|
339
|
+
top_k: int = 100,
|
|
340
|
+
top_n: int = 10,
|
|
341
|
+
expand_graph: bool = True,
|
|
342
|
+
graph_limit: int = 8,
|
|
343
|
+
max_per_file: int = _MAX_PER_FILE,
|
|
344
|
+
path_filter: str | None = None,
|
|
345
|
+
) -> list[dict]:
|
|
346
|
+
"""检索并返回 top_n 个代码块(dict,含 score)。
|
|
347
|
+
|
|
348
|
+
排序管线:向量+BM25(+trigram/子查询) 召回 → RRF → rerank + 子查询分数混合
|
|
349
|
+
(可选)→ 调用链意图置顶 → 意图感知降权/boost + 名称 boost → 接口-实现
|
|
350
|
+
配对 → 文件多样性截断(每文件最多 max_per_file 块)→ 子查询保底。
|
|
351
|
+
|
|
352
|
+
path_filter:可选文件路径过滤,含通配符按 glob 匹配,否则按子串匹配。
|
|
353
|
+
|
|
354
|
+
expand_graph=True 时,在主结果基础上沿 call graph 扩展 1 跳,
|
|
355
|
+
把调用者/被调用者作为关联结果附加(带 relation 字段)。
|
|
356
|
+
"""
|
|
357
|
+
# 1. 向量召回 + 2. BM25 召回(原查询)
|
|
358
|
+
q_emb = self.embedder.embed_query(query)
|
|
359
|
+
rank_lists = [
|
|
360
|
+
[cid for cid, _ in self.store.search_vector(q_emb, top_k)],
|
|
361
|
+
[cid for cid, _ in self.store.search_fts(query, top_k)],
|
|
362
|
+
]
|
|
363
|
+
weights = [1.0, 1.0]
|
|
364
|
+
# 2.5 查询扩展(可选):变体走 query 模式,HyDE 假想代码走 document 模式;
|
|
365
|
+
# 原查询两路权重加倍,防扩展噪声稀释原始信号
|
|
366
|
+
expansion = self.expander.expand(query) if self.expander else None
|
|
367
|
+
if expansion:
|
|
368
|
+
weights = [2.0, 2.0]
|
|
369
|
+
for v in expansion.get("variants", []):
|
|
370
|
+
v_emb = self.embedder.embed_query(v)
|
|
371
|
+
rank_lists.append([cid for cid, _ in self.store.search_vector(v_emb, top_k)])
|
|
372
|
+
weights.append(1.0)
|
|
373
|
+
rank_lists.append([cid for cid, _ in self.store.search_fts(v, top_k)])
|
|
374
|
+
weights.append(1.0)
|
|
375
|
+
hyde = expansion.get("hyde")
|
|
376
|
+
if hyde:
|
|
377
|
+
h_emb = self.embedder.embed_documents([hyde])[0]
|
|
378
|
+
rank_lists.append([cid for cid, _ in self.store.search_vector(h_emb, top_k)])
|
|
379
|
+
weights.append(1.0)
|
|
380
|
+
# 2.8 CJK 查询追加 trigram 召回路(unicode61 把连续汉字并成单 token,主 FTS 路对中文失效;
|
|
381
|
+
# 纯英文查询不走 trigram,避免子串匹配稀释标识符精确信号)
|
|
382
|
+
if _CJK_RE.search(query):
|
|
383
|
+
tri_list = [cid for cid, _ in self.store.search_fts_trigram(query, top_k)]
|
|
384
|
+
if tri_list:
|
|
385
|
+
rank_lists.append(tri_list)
|
|
386
|
+
weights.append(weights[0])
|
|
387
|
+
# 2.9 复合意图拆分("生成+清理"):单次向量召回对双动作查询天然偏科,
|
|
388
|
+
# 各子查询独立召回后低权重并入 RRF,两个意图都能拿到席位
|
|
389
|
+
subs = self._compound_subqueries(query)
|
|
390
|
+
sub_probes: list[list[int]] = [] # 每个子查询自己的前 N 名(保底探针/分数混合用)
|
|
391
|
+
for sub in subs:
|
|
392
|
+
s_emb = self.embedder.embed_query(sub)
|
|
393
|
+
sub_lists = [[cid for cid, _ in self.store.search_vector(s_emb, top_k)]]
|
|
394
|
+
sub_fts = (
|
|
395
|
+
self.store.search_fts_trigram(sub, top_k)
|
|
396
|
+
if _CJK_RE.search(sub) else self.store.search_fts(sub, top_k)
|
|
397
|
+
)
|
|
398
|
+
if sub_fts:
|
|
399
|
+
sub_lists.append([cid for cid, _ in sub_fts])
|
|
400
|
+
for sl in sub_lists:
|
|
401
|
+
rank_lists.append(sl)
|
|
402
|
+
weights.append(_COMPOUND_SUB_WEIGHT)
|
|
403
|
+
mini = self._rrf(sub_lists)
|
|
404
|
+
# 召回为空也 append 空探针,保持与 subs 一一对应(4.2 按下标 zip 配对)
|
|
405
|
+
sub_probes.append([cid for cid, _ in mini[:_COMPOUND_PROBE_TOP]])
|
|
406
|
+
# 3. RRF 融合(仅用排名)
|
|
407
|
+
fused = self._rrf(rank_lists, weights=weights)
|
|
408
|
+
if not fused:
|
|
409
|
+
return []
|
|
410
|
+
cand_ids = [cid for cid, _ in fused[:top_k]]
|
|
411
|
+
chunk_map = self.store.get_chunks(cand_ids)
|
|
412
|
+
candidates: list[dict] = []
|
|
413
|
+
for cid, rrf_score in fused[:top_k]:
|
|
414
|
+
ch = chunk_map.get(cid)
|
|
415
|
+
if ch:
|
|
416
|
+
ch = dict(ch)
|
|
417
|
+
ch["score"] = rrf_score
|
|
418
|
+
candidates.append(ch)
|
|
419
|
+
# 4. rerank(可选,失败降级为 RRF 分数);多取候选给后续多样性截断留余量
|
|
420
|
+
if self.rerank_client and candidates:
|
|
421
|
+
pre_rerank = {c.get("id"): c for c in candidates}
|
|
422
|
+
try:
|
|
423
|
+
rerank_n = min(len(candidates), max(top_n * 3, 20))
|
|
424
|
+
candidates = self._rerank(query, candidates, rerank_n)
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
# 4.1 整句 rerank 截断会把整句分低的探针块丢掉,补回(0 分起步,
|
|
428
|
+
# 等 4.2 子查询分数拉起),否则分数混合和保底都看不见它
|
|
429
|
+
if sub_probes:
|
|
430
|
+
out_ids = {c.get("id") for c in candidates}
|
|
431
|
+
for cid in dict.fromkeys(cid for p in sub_probes for cid in p):
|
|
432
|
+
if cid in pre_rerank and cid not in out_ids:
|
|
433
|
+
ch = pre_rerank[cid]
|
|
434
|
+
ch["score"] = 0.0
|
|
435
|
+
candidates.append(ch)
|
|
436
|
+
# 4.2 子查询分数混合:探针块单独用子查询文本 rerank,取 max 混入。
|
|
437
|
+
# 整句 rerank 把子意图文档压到 0.2x(语义重心在主意图),
|
|
438
|
+
# 子查询文本打分才能反映它对那半意图的真实相关性
|
|
439
|
+
if subs and sub_probes:
|
|
440
|
+
by_id = {c.get("id"): c for c in candidates}
|
|
441
|
+
for sub, probe in zip(subs, sub_probes):
|
|
442
|
+
sub_cands = [by_id[cid] for cid in probe if cid in by_id]
|
|
443
|
+
if not sub_cands:
|
|
444
|
+
continue
|
|
445
|
+
try:
|
|
446
|
+
scored = self._rerank(sub, sub_cands, len(sub_cands))
|
|
447
|
+
except Exception:
|
|
448
|
+
continue
|
|
449
|
+
for s in scored:
|
|
450
|
+
c = by_id.get(s.get("id"))
|
|
451
|
+
if c is None:
|
|
452
|
+
continue
|
|
453
|
+
c["_sub_score"] = max(c.get("_sub_score", 0.0), s["score"])
|
|
454
|
+
if s["score"] >= _COMPOUND_SUB_MIN_SCORE:
|
|
455
|
+
c["score"] = max(
|
|
456
|
+
c["score"], s["score"] * _COMPOUND_SUB_RERANK_MIX
|
|
457
|
+
)
|
|
458
|
+
# 4.5 调用链意图:结构化命中注入高分(并入统一打分管线,测试调用方会被后续降权拆开)
|
|
459
|
+
intent_hits = self._caller_intent_hits(query)
|
|
460
|
+
if intent_hits:
|
|
461
|
+
by_id = {c.get("id"): c for c in candidates}
|
|
462
|
+
for h in intent_hits:
|
|
463
|
+
if h["id"] in by_id:
|
|
464
|
+
by_id[h["id"]]["score"] = h["score"]
|
|
465
|
+
else:
|
|
466
|
+
candidates.append(h)
|
|
467
|
+
# 5. 统一后处理:意图感知的降权/boost + 符号/文件名 boost
|
|
468
|
+
qtokens = self._query_tokens(query)
|
|
469
|
+
test_intent = bool(_TEST_INTENT_RE.search(query))
|
|
470
|
+
doc_intent = bool(_DOC_INTENT_RE.search(query))
|
|
471
|
+
config_intent = bool(_CONFIG_INTENT_RE.search(query))
|
|
472
|
+
decl_intent = bool(_DECL_INTENT_RE.search(query))
|
|
473
|
+
impl_intent = not decl_intent and bool(_IMPL_INTENT_RE.search(query))
|
|
474
|
+
for c in candidates:
|
|
475
|
+
mult = 1.0
|
|
476
|
+
fp = c.get("file_path", "")
|
|
477
|
+
if self._is_test_file(fp):
|
|
478
|
+
mult *= _TEST_INTENT_BOOST if test_intent else _TEST_FILE_PENALTY
|
|
479
|
+
if self._is_barrel_file(fp):
|
|
480
|
+
mult *= _BARREL_PENALTY
|
|
481
|
+
mult *= self._noncode_mult(c.get("language", ""), doc_intent, config_intent)
|
|
482
|
+
is_decl = self._is_declaration_chunk(c)
|
|
483
|
+
is_entity = not is_decl and self._is_entity_chunk(c)
|
|
484
|
+
if is_entity:
|
|
485
|
+
c["entity"] = True # 展示层据此收紧行预算(DTO 字段列表不值得全量吐)
|
|
486
|
+
if not decl_intent:
|
|
487
|
+
if is_decl:
|
|
488
|
+
# 记住降权系数:接口-实现配对要用降权前的分数做 base,
|
|
489
|
+
# 否则声明降权把 base 一起拉低,配对提拔失效
|
|
490
|
+
c["_decl_mult"] = _DECL_PENALTY * (_IMPL_INTENT_EXTRA if impl_intent else 1.0)
|
|
491
|
+
mult *= c["_decl_mult"]
|
|
492
|
+
elif is_entity:
|
|
493
|
+
mult *= _ENTITY_PENALTY * (_IMPL_INTENT_EXTRA if impl_intent else 1.0)
|
|
494
|
+
if c.get("end_line", 0) - c.get("start_line", 0) + 1 <= _TINY_CHUNK_LINES:
|
|
495
|
+
mult *= _TINY_CHUNK_PENALTY
|
|
496
|
+
mult *= self._name_boost(qtokens, c)
|
|
497
|
+
c["score"] *= mult
|
|
498
|
+
# 5.5 接口/实现配对(文件级):接口块 symbol 是类名、实现块 symbol 是方法名,
|
|
499
|
+
# 按 symbol 配不上;改按文件 stem 配对:XImpl.java 的最高分块继承 X.java
|
|
500
|
+
# (接口文件)的更高分,实现方法排到接口声明前面
|
|
501
|
+
best_by_stem: dict[str, float] = {}
|
|
502
|
+
for c in candidates:
|
|
503
|
+
undecl = c["score"] / c.get("_decl_mult", 1.0)
|
|
504
|
+
best_by_stem[self._file_stem(c)] = max(
|
|
505
|
+
best_by_stem.get(self._file_stem(c), 0.0), undecl
|
|
506
|
+
)
|
|
507
|
+
impl_top: dict[str, dict] = {} # stem -> 该实现文件最高分块
|
|
508
|
+
for c in candidates:
|
|
509
|
+
stem = self._file_stem(c)
|
|
510
|
+
if stem.endswith("Impl") and len(stem) > 4:
|
|
511
|
+
if stem not in impl_top or c["score"] > impl_top[stem]["score"]:
|
|
512
|
+
impl_top[stem] = c
|
|
513
|
+
for stem, c in impl_top.items():
|
|
514
|
+
base = best_by_stem.get(stem[:-4])
|
|
515
|
+
if base and base > c["score"]:
|
|
516
|
+
c["score"] = base * _IMPL_PAIR_FACTOR
|
|
517
|
+
candidates.sort(key=lambda x: x["score"], reverse=True)
|
|
518
|
+
# 6. 文件多样性:同一文件最多 max_per_file 块,避免 top_n 被单文件刷屏
|
|
519
|
+
results: list[dict] = []
|
|
520
|
+
per_file: dict[str, int] = {}
|
|
521
|
+
for c in candidates:
|
|
522
|
+
fp = c.get("file_path", "")
|
|
523
|
+
if path_filter and not self._path_match(fp, path_filter):
|
|
524
|
+
continue
|
|
525
|
+
if max_per_file > 0 and per_file.get(fp, 0) >= max_per_file:
|
|
526
|
+
continue
|
|
527
|
+
per_file[fp] = per_file.get(fp, 0) + 1
|
|
528
|
+
results.append(c)
|
|
529
|
+
if len(results) >= top_n:
|
|
530
|
+
break
|
|
531
|
+
# 6.5 子查询保底:某子意图召回的前 N 名文件与结果零交集(完全无代表)时,
|
|
532
|
+
# 从 candidates 里挑子查询 rerank 分最高且达门槛的块替换最低分名额;
|
|
533
|
+
# 无达标块则放弃("清理"这种宽泛词的 clearCache 字面命中不配强插);
|
|
534
|
+
# 已有代表则不动(避免顶掉更相关结果)
|
|
535
|
+
if sub_probes and results:
|
|
536
|
+
by_id = {c.get("id"): c for c in candidates}
|
|
537
|
+
probe_ids = [cid for p in sub_probes for cid in p]
|
|
538
|
+
probe_chunks = self.store.get_chunks(probe_ids)
|
|
539
|
+
rescued_ids: set[int] = set()
|
|
540
|
+
for probe in sub_probes:
|
|
541
|
+
result_files = {r.get("file_path", "") for r in results}
|
|
542
|
+
probe_files = {
|
|
543
|
+
probe_chunks[cid]["file_path"] for cid in probe if cid in probe_chunks
|
|
544
|
+
}
|
|
545
|
+
if result_files & probe_files:
|
|
546
|
+
continue # 该子意图已有代表
|
|
547
|
+
best = max(
|
|
548
|
+
(
|
|
549
|
+
by_id[cid] for cid in probe
|
|
550
|
+
if cid in by_id and cid not in rescued_ids
|
|
551
|
+
and by_id[cid].get("_sub_score", 0.0) >= _COMPOUND_SUB_MIN_SCORE
|
|
552
|
+
and not (path_filter and not self._path_match(
|
|
553
|
+
by_id[cid].get("file_path", ""), path_filter))
|
|
554
|
+
),
|
|
555
|
+
key=lambda c: c.get("_sub_score", 0.0),
|
|
556
|
+
default=None,
|
|
557
|
+
)
|
|
558
|
+
if best is None:
|
|
559
|
+
continue
|
|
560
|
+
if len(results) >= top_n:
|
|
561
|
+
low = min(
|
|
562
|
+
(i for i, r in enumerate(results) if r.get("id") not in rescued_ids),
|
|
563
|
+
key=lambda i: results[i].get("score", 0.0),
|
|
564
|
+
default=None,
|
|
565
|
+
)
|
|
566
|
+
if low is None:
|
|
567
|
+
continue
|
|
568
|
+
results[low] = best
|
|
569
|
+
else:
|
|
570
|
+
results.append(best)
|
|
571
|
+
rescued_ids.add(best["id"])
|
|
572
|
+
if rescued_ids:
|
|
573
|
+
# 保底替换后按分数重排,避免混合分高于中间名次时展示乱序
|
|
574
|
+
results.sort(key=lambda x: x.get("score", 0.0), reverse=True)
|
|
575
|
+
# 清理打分管线内部字段,不泄漏到返回值(entity 是展示层公开约定,保留)
|
|
576
|
+
for r in results:
|
|
577
|
+
r.pop("_decl_mult", None)
|
|
578
|
+
r.pop("_sub_score", None)
|
|
579
|
+
# 7. call graph 扩展(在主结果基础上连带召回调用关系)
|
|
580
|
+
if expand_graph and results:
|
|
581
|
+
fused_scores = dict(fused)
|
|
582
|
+
results = self._with_graph(results, graph_limit, fused_scores)
|
|
583
|
+
return results
|
|
584
|
+
|
|
585
|
+
def _with_graph(
|
|
586
|
+
self, results: list[dict], limit: int, fused_scores: dict[int, float] | None = None
|
|
587
|
+
) -> list[dict]:
|
|
588
|
+
"""在主结果后附加 call graph 关联块(去重,带 relation 标记)。
|
|
589
|
+
|
|
590
|
+
关联块按主召回融合分排序(在召回列表里出现过 = 与查询相关),
|
|
591
|
+
同分 caller 优先于 callee(调用点对溯源/影响面分析更有价值)。
|
|
592
|
+
类/接口级块额外提取内部声明的方法名参与 caller 反查。"""
|
|
593
|
+
origin_ids = [r["id"] for r in results if "id" in r]
|
|
594
|
+
symbols = [r.get("symbol", "") for r in results]
|
|
595
|
+
extra_names: list[str] = []
|
|
596
|
+
for r in results:
|
|
597
|
+
extra_names.extend(self._declared_names(r.get("code") or "", r.get("language") or ""))
|
|
598
|
+
related = self.store.expand_graph(
|
|
599
|
+
origin_ids, symbols, limit=limit * 3, extra_callee_names=extra_names
|
|
600
|
+
)
|
|
601
|
+
fused_scores = fused_scores or {}
|
|
602
|
+
related.sort(
|
|
603
|
+
key=lambda r: (
|
|
604
|
+
fused_scores.get(r.get("id"), 0.0),
|
|
605
|
+
1 if r.get("relation") == "caller" else 0,
|
|
606
|
+
),
|
|
607
|
+
reverse=True,
|
|
608
|
+
)
|
|
609
|
+
existing = set(origin_ids)
|
|
610
|
+
added = 0
|
|
611
|
+
for r in related:
|
|
612
|
+
if r["id"] in existing:
|
|
613
|
+
continue
|
|
614
|
+
r["score"] = 0.0 # 图扩展块无 relevance 分,靠 relation 标记
|
|
615
|
+
if self._is_entity_chunk(r):
|
|
616
|
+
r["entity"] = True
|
|
617
|
+
results.append(r)
|
|
618
|
+
existing.add(r["id"])
|
|
619
|
+
added += 1
|
|
620
|
+
if added >= limit:
|
|
621
|
+
break
|
|
622
|
+
return results
|
|
623
|
+
|
|
624
|
+
@staticmethod
|
|
625
|
+
def _compound_subqueries(query: str) -> list[str]:
|
|
626
|
+
"""复合意图查询拆分:恰好拆成两段且每段足够长才生效(保守策略,防误拆)。"""
|
|
627
|
+
parts = [p.strip() for p in _COMPOUND_SPLIT_RE.split(query)]
|
|
628
|
+
parts = [p for p in parts if len(p) >= _COMPOUND_MIN_LEN]
|
|
629
|
+
return parts if len(parts) == 2 else []
|
|
630
|
+
|
|
631
|
+
@staticmethod
|
|
632
|
+
def _file_stem(chunk: dict) -> str:
|
|
633
|
+
"""文件名去扩展名(OrderServiceImpl.java -> OrderServiceImpl)。"""
|
|
634
|
+
fp = chunk.get("file_path") or ""
|
|
635
|
+
return fp.rsplit("/", 1)[-1].rsplit("\\", 1)[-1].split(".")[0]
|
|
636
|
+
|
|
637
|
+
@staticmethod
|
|
638
|
+
def _path_match(file_path: str, pattern: str) -> bool:
|
|
639
|
+
"""路径过滤:含通配符按 glob(对全路径,自动补 **/ 前缀),否则子串包含。"""
|
|
640
|
+
fp = file_path.replace("\\", "/")
|
|
641
|
+
pat = pattern.replace("\\", "/")
|
|
642
|
+
if any(ch in pat for ch in "*?["):
|
|
643
|
+
return fnmatch.fnmatch(fp, pat) or fnmatch.fnmatch(fp, f"*{pat}" if not pat.startswith("*") else pat)
|
|
644
|
+
return pat.lower() in fp.lower()
|
|
645
|
+
|
|
646
|
+
@staticmethod
|
|
647
|
+
def _rrf(
|
|
648
|
+
rank_lists: list[list[int]],
|
|
649
|
+
k: int = _RRF_K,
|
|
650
|
+
weights: list[float] | None = None,
|
|
651
|
+
) -> list[tuple[int, float]]:
|
|
652
|
+
"""Reciprocal Rank Fusion:融合多个召回列表的排名(可选每路权重)。"""
|
|
653
|
+
scores: dict[int, float] = {}
|
|
654
|
+
for i, ranks in enumerate(rank_lists):
|
|
655
|
+
w = weights[i] if weights else 1.0
|
|
656
|
+
for rank, doc_id in enumerate(ranks):
|
|
657
|
+
scores[doc_id] = scores.get(doc_id, 0.0) + w / (k + rank + 1)
|
|
658
|
+
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
|
659
|
+
|
|
660
|
+
def _rerank(self, query: str, candidates: list[dict], top_n: int) -> list[dict]:
|
|
661
|
+
docs = [self._doc_text(c) for c in candidates]
|
|
662
|
+
min_interval = _rerank_min_interval()
|
|
663
|
+
resp = None
|
|
664
|
+
for attempt in range(_RERANK_MAX_RETRIES + 1):
|
|
665
|
+
if min_interval > 0:
|
|
666
|
+
wait = self._last_rerank_ts + min_interval - time.time()
|
|
667
|
+
if wait > 0:
|
|
668
|
+
time.sleep(wait)
|
|
669
|
+
try:
|
|
670
|
+
self._last_rerank_ts = time.time()
|
|
671
|
+
if self.rerank_backend == "voyage":
|
|
672
|
+
resp = self.rerank_client.rerank(
|
|
673
|
+
query=query,
|
|
674
|
+
documents=docs,
|
|
675
|
+
model=self.rerank_model,
|
|
676
|
+
top_k=min(top_n, len(docs)),
|
|
677
|
+
)
|
|
678
|
+
else:
|
|
679
|
+
resp = self.rerank_client.rerank(
|
|
680
|
+
model=self.rerank_model,
|
|
681
|
+
query=query,
|
|
682
|
+
documents=docs,
|
|
683
|
+
top_n=min(top_n, len(docs)),
|
|
684
|
+
)
|
|
685
|
+
break
|
|
686
|
+
except Exception as e:
|
|
687
|
+
# 仅对限速重试(退避后速率回到限额内);其它异常继续抛给外层降级 RRF
|
|
688
|
+
# Cohere 抛 TooManyRequestsError,Voyage 抛 RateLimitError
|
|
689
|
+
name = type(e).__name__
|
|
690
|
+
retryable = "TooManyRequests" in name or "RateLimit" in name
|
|
691
|
+
if not retryable or attempt >= _RERANK_MAX_RETRIES:
|
|
692
|
+
raise
|
|
693
|
+
time.sleep(_RERANK_BACKOFF_S * (attempt + 1))
|
|
694
|
+
out: list[dict] = []
|
|
695
|
+
for r in resp.results:
|
|
696
|
+
ch = dict(candidates[r.index])
|
|
697
|
+
ch["score"] = float(r.relevance_score)
|
|
698
|
+
out.append(ch)
|
|
699
|
+
return out
|
|
700
|
+
|
|
701
|
+
@staticmethod
|
|
702
|
+
def _doc_text(chunk: dict) -> str:
|
|
703
|
+
"""构造送入 rerank 的文档文本(路径 + 符号 + 代码)。"""
|
|
704
|
+
head = f"{chunk.get('file_path', '')} :: {chunk.get('symbol', '')}"
|
|
705
|
+
return f"{head}\n{chunk.get('code', '')}"[:_RERANK_DOC_MAX_CHARS]
|