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,399 @@
|
|
|
1
|
+
"""Tree-sitter AST 切分模块。
|
|
2
|
+
|
|
3
|
+
把源码文件按函数/类/方法等语义单元切成代码块,保持语义完整。
|
|
4
|
+
不支持的语言或解析失败时,自动回退到按行切分。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import importlib
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from tree_sitter import Language, Parser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# 文件扩展名 → tree-sitter 语言名
|
|
17
|
+
EXT_TO_LANG = {
|
|
18
|
+
".py": "python",
|
|
19
|
+
".js": "javascript",
|
|
20
|
+
".jsx": "javascript",
|
|
21
|
+
".mjs": "javascript",
|
|
22
|
+
".ts": "typescript",
|
|
23
|
+
".tsx": "tsx",
|
|
24
|
+
".java": "java",
|
|
25
|
+
".go": "go",
|
|
26
|
+
".rs": "rust",
|
|
27
|
+
".c": "c",
|
|
28
|
+
".h": "c",
|
|
29
|
+
".cpp": "cpp",
|
|
30
|
+
".cc": "cpp",
|
|
31
|
+
".cxx": "cpp",
|
|
32
|
+
".hpp": "cpp",
|
|
33
|
+
".cs": "csharp",
|
|
34
|
+
".rb": "ruby",
|
|
35
|
+
".php": "php",
|
|
36
|
+
".kt": "kotlin",
|
|
37
|
+
".scala": "scala",
|
|
38
|
+
".swift": "swift",
|
|
39
|
+
".sql": "sql",
|
|
40
|
+
".sh": "bash",
|
|
41
|
+
".lua": "lua",
|
|
42
|
+
# 非代码但承载架构/配置语义的文件(无 parser,走按行切分)
|
|
43
|
+
".md": "markdown",
|
|
44
|
+
".markdown": "markdown",
|
|
45
|
+
".yml": "yaml",
|
|
46
|
+
".yaml": "yaml",
|
|
47
|
+
".json": "json",
|
|
48
|
+
".toml": "toml",
|
|
49
|
+
".properties": "properties",
|
|
50
|
+
".proto": "proto",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# 各语言中代表"函数/类/方法"的 AST 节点类型
|
|
54
|
+
SPLIT_NODE_TYPES = {
|
|
55
|
+
"python": {"function_definition", "class_definition", "decorated_definition"},
|
|
56
|
+
"javascript": {"function_declaration", "class_declaration", "method_definition"},
|
|
57
|
+
"typescript": {"function_declaration", "class_declaration", "method_definition", "interface_declaration", "enum_declaration"},
|
|
58
|
+
"tsx": {"function_declaration", "class_declaration", "method_definition", "interface_declaration", "enum_declaration"},
|
|
59
|
+
"java": {"method_declaration", "class_declaration", "interface_declaration", "constructor_declaration", "enum_declaration"},
|
|
60
|
+
"go": {"function_declaration", "method_declaration", "type_declaration"},
|
|
61
|
+
"rust": {"function_item", "impl_item", "struct_item", "trait_item", "enum_item"},
|
|
62
|
+
"c": {"function_definition", "struct_specifier"},
|
|
63
|
+
"cpp": {"function_definition", "class_specifier", "struct_specifier"},
|
|
64
|
+
"csharp": {"method_declaration", "class_declaration", "interface_declaration", "struct_declaration"},
|
|
65
|
+
"ruby": {"method", "class", "module", "singleton_method"},
|
|
66
|
+
"php": {"function_definition", "method_declaration", "class_declaration", "interface_declaration"},
|
|
67
|
+
"kotlin": {"function_declaration", "class_declaration", "object_declaration"},
|
|
68
|
+
"scala": {"function_definition", "class_definition", "object_definition", "trait_definition"},
|
|
69
|
+
"swift": {"function_declaration", "class_declaration", "protocol_declaration"},
|
|
70
|
+
"lua": {"function_declaration", "function_definition"},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# 各语言中代表“函数调用”的 AST 节点类型(用于构建 call graph)
|
|
74
|
+
CALL_NODE_TYPES = {
|
|
75
|
+
"python": {"call"},
|
|
76
|
+
"javascript": {"call_expression", "new_expression"},
|
|
77
|
+
"typescript": {"call_expression", "new_expression"},
|
|
78
|
+
"tsx": {"call_expression", "new_expression"},
|
|
79
|
+
"java": {"method_invocation", "object_creation_expression"},
|
|
80
|
+
"go": {"call_expression"},
|
|
81
|
+
"rust": {"call_expression", "macro_invocation"},
|
|
82
|
+
"c": {"call_expression"},
|
|
83
|
+
"cpp": {"call_expression"},
|
|
84
|
+
"csharp": {"invocation_expression", "object_creation_expression"},
|
|
85
|
+
"ruby": {"call", "method_call"},
|
|
86
|
+
"php": {"function_call_expression", "member_call_expression", "scoped_call_expression"},
|
|
87
|
+
"kotlin": {"call_expression"},
|
|
88
|
+
"scala": {"call_expression"},
|
|
89
|
+
"swift": {"call_expression"},
|
|
90
|
+
"lua": {"function_call"},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# call 节点中“被调用名”可能所在的字段(type 覆盖 Java/C# 的 object_creation_expression)
|
|
94
|
+
_CALL_NAME_FIELDS = ("function", "name", "method", "macro", "constructor", "type")
|
|
95
|
+
# 成员调用的接收者字段(如 Java method_invocation 的 object):
|
|
96
|
+
# 记录接收者标识符,支持“谁调用了 PriceRuleEvaluator”类意图查询
|
|
97
|
+
# (Java 写法 priceRuleEvaluator.evaluate() 里类名不出现在方法名中)
|
|
98
|
+
_CALL_RECEIVER_FIELDS = ("object", "receiver")
|
|
99
|
+
# 标识符类叶子节点(被调用名的末端)
|
|
100
|
+
_NAME_LEAF_TYPES = {
|
|
101
|
+
"identifier", "field_identifier", "type_identifier", "name",
|
|
102
|
+
"property_identifier", "constant", "simple_identifier",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# 常见内置/容器方法,作为调用名无区分度,构图时过滤
|
|
106
|
+
_CALL_STOPLIST = {
|
|
107
|
+
"print", "len", "range", "enumerate", "zip", "map", "filter", "sorted",
|
|
108
|
+
"list", "dict", "set", "tuple", "str", "int", "float", "bool", "bytes",
|
|
109
|
+
"open", "isinstance", "type", "super", "getattr", "setattr", "hasattr",
|
|
110
|
+
"min", "max", "sum", "any", "all", "abs", "repr", "format", "iter", "next",
|
|
111
|
+
"append", "extend", "insert", "remove", "pop", "get", "items", "keys",
|
|
112
|
+
"values", "update", "add", "join", "split", "strip", "replace", "decode",
|
|
113
|
+
"encode", "startswith", "endswith", "lower", "upper", "find", "index",
|
|
114
|
+
"getenv", "setdefault", "copy", "clear", "sort", "reverse", "count",
|
|
115
|
+
# 接收者噪声(日志/自引用等无区分度标识符)
|
|
116
|
+
"this", "self", "log", "logger", "console", "System",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# 超过此行数的容器节点(如大类)会递归切内部定义
|
|
120
|
+
MAX_CHUNK_LINES = 200
|
|
121
|
+
# 按行切分的窗口与重叠
|
|
122
|
+
LINE_WINDOW = 60
|
|
123
|
+
LINE_OVERLAP = 10
|
|
124
|
+
# 过小的块(行数与字符数都不足)会被丢弃
|
|
125
|
+
MIN_CHUNK_LINES = 3
|
|
126
|
+
MIN_CHUNK_CHARS = 40
|
|
127
|
+
|
|
128
|
+
_PARSER_CACHE: dict = {}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class CodeChunk:
|
|
133
|
+
"""一个语义代码块。"""
|
|
134
|
+
|
|
135
|
+
file_path: str
|
|
136
|
+
language: str
|
|
137
|
+
symbol: str # 函数/类名,行切块为 lines_<起始行>
|
|
138
|
+
start_line: int # 1-indexed
|
|
139
|
+
end_line: int # 1-indexed
|
|
140
|
+
code: str
|
|
141
|
+
blob_hash: str = "" # SHA256(file_path + code),用于内容寻址增量
|
|
142
|
+
calls: list[str] = field(default_factory=list) # 本块内调用的函数名(call graph 边)
|
|
143
|
+
|
|
144
|
+
def __post_init__(self) -> None:
|
|
145
|
+
if not self.blob_hash:
|
|
146
|
+
h = hashlib.sha256()
|
|
147
|
+
h.update(self.file_path.encode("utf-8", "ignore"))
|
|
148
|
+
h.update(b"\x00")
|
|
149
|
+
h.update(self.code.encode("utf-8", "ignore"))
|
|
150
|
+
self.blob_hash = h.hexdigest()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# 语言名 -> (pip 模块名, 取 Language 的函数名)
|
|
154
|
+
# 使用独立语言包(含预编译 wheel),避免运行时联网下载 grammar
|
|
155
|
+
_LANG_MODULES = {
|
|
156
|
+
"python": ("tree_sitter_python", "language"),
|
|
157
|
+
"javascript": ("tree_sitter_javascript", "language"),
|
|
158
|
+
"typescript": ("tree_sitter_typescript", "language_typescript"),
|
|
159
|
+
"tsx": ("tree_sitter_typescript", "language_tsx"),
|
|
160
|
+
"java": ("tree_sitter_java", "language"),
|
|
161
|
+
"go": ("tree_sitter_go", "language"),
|
|
162
|
+
"rust": ("tree_sitter_rust", "language"),
|
|
163
|
+
"c": ("tree_sitter_c", "language"),
|
|
164
|
+
"cpp": ("tree_sitter_cpp", "language"),
|
|
165
|
+
"csharp": ("tree_sitter_c_sharp", "language"),
|
|
166
|
+
"ruby": ("tree_sitter_ruby", "language"),
|
|
167
|
+
"php": ("tree_sitter_php", "language_php"),
|
|
168
|
+
"bash": ("tree_sitter_bash", "language"),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _get_parser(lang: str):
|
|
173
|
+
"""获取并缓存 parser,失败返回 None(该语言回退按行切)。"""
|
|
174
|
+
if lang in _PARSER_CACHE:
|
|
175
|
+
return _PARSER_CACHE[lang]
|
|
176
|
+
parser = None
|
|
177
|
+
spec = _LANG_MODULES.get(lang)
|
|
178
|
+
if spec is not None:
|
|
179
|
+
mod_name, func_name = spec
|
|
180
|
+
try:
|
|
181
|
+
mod = importlib.import_module(mod_name)
|
|
182
|
+
language = Language(getattr(mod, func_name)())
|
|
183
|
+
parser = Parser(language)
|
|
184
|
+
except Exception:
|
|
185
|
+
parser = None
|
|
186
|
+
_PARSER_CACHE[lang] = parser
|
|
187
|
+
return parser
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _get_symbol(node) -> str:
|
|
191
|
+
"""提取节点的符号名(函数名/类名)。"""
|
|
192
|
+
# 装饰器节点(如 Python @dataclass class):递归取内部真正定义的名字
|
|
193
|
+
if node.type == "decorated_definition":
|
|
194
|
+
for child in node.children:
|
|
195
|
+
if child.type.endswith(("definition", "declaration")):
|
|
196
|
+
return _get_symbol(child)
|
|
197
|
+
name_node = node.child_by_field_name("name")
|
|
198
|
+
if name_node is not None:
|
|
199
|
+
return name_node.text.decode("utf-8", "ignore")
|
|
200
|
+
# 回退:找第一个标识符类子节点
|
|
201
|
+
for child in node.children:
|
|
202
|
+
if child.type in ("identifier", "type_identifier", "field_identifier", "name"):
|
|
203
|
+
return child.text.decode("utf-8", "ignore")
|
|
204
|
+
return node.type
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _has_split_descendant(node, split_types: set) -> bool:
|
|
208
|
+
"""node 内部(不含自身)是否还有可切分节点。"""
|
|
209
|
+
for child in node.children:
|
|
210
|
+
if child.type in split_types:
|
|
211
|
+
return True
|
|
212
|
+
if _has_split_descendant(child, split_types):
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _last_identifier(node) -> str | None:
|
|
218
|
+
"""取节点末端的标识符文本(如 a.b.c() 取 c)。"""
|
|
219
|
+
if node.type in _NAME_LEAF_TYPES and node.child_count == 0:
|
|
220
|
+
return node.text.decode("utf-8", "ignore")
|
|
221
|
+
last = None
|
|
222
|
+
for child in node.children:
|
|
223
|
+
r = _last_identifier(child)
|
|
224
|
+
if r:
|
|
225
|
+
last = r
|
|
226
|
+
return last
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# 泛型实参子树(Java type_arguments / C# type_argument_list),取类型名时跳过
|
|
230
|
+
_TYPE_ARG_NODE_TYPES = {"type_arguments", "type_argument_list"}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _type_name(node) -> str | None:
|
|
234
|
+
"""取类型节点的主类型名:跳过泛型实参后取末端标识符。
|
|
235
|
+
|
|
236
|
+
new ArrayList<String>() -> ArrayList(而非 String);
|
|
237
|
+
new com.foo.Bar() -> Bar。
|
|
238
|
+
"""
|
|
239
|
+
if node.type in _NAME_LEAF_TYPES and node.child_count == 0:
|
|
240
|
+
return node.text.decode("utf-8", "ignore")
|
|
241
|
+
last = None
|
|
242
|
+
for child in node.children:
|
|
243
|
+
if child.type in _TYPE_ARG_NODE_TYPES:
|
|
244
|
+
continue
|
|
245
|
+
r = _type_name(child)
|
|
246
|
+
if r:
|
|
247
|
+
last = r
|
|
248
|
+
return last
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _callee_name(call_node) -> str | None:
|
|
252
|
+
"""从 call 节点提取被调用函数名(取末端标识符;type 字段走类型名提取)。"""
|
|
253
|
+
target = None
|
|
254
|
+
matched_field = None
|
|
255
|
+
for f in _CALL_NAME_FIELDS:
|
|
256
|
+
target = call_node.child_by_field_name(f)
|
|
257
|
+
if target is not None:
|
|
258
|
+
matched_field = f
|
|
259
|
+
break
|
|
260
|
+
if target is None and call_node.child_count:
|
|
261
|
+
target = call_node.children[0]
|
|
262
|
+
if target is None:
|
|
263
|
+
return None
|
|
264
|
+
if matched_field == "type":
|
|
265
|
+
return _type_name(target)
|
|
266
|
+
return _last_identifier(target)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extract_calls(node, lang: str) -> list[str]:
|
|
270
|
+
"""提取节点子树内所有函数调用的被调用名(去重,保序)。"""
|
|
271
|
+
call_types = CALL_NODE_TYPES.get(lang)
|
|
272
|
+
if not call_types:
|
|
273
|
+
return []
|
|
274
|
+
names: list[str] = []
|
|
275
|
+
seen: set[str] = set()
|
|
276
|
+
|
|
277
|
+
def _visit(n):
|
|
278
|
+
if n.type in call_types:
|
|
279
|
+
name = _callee_name(n)
|
|
280
|
+
if name and name not in seen and name.isidentifier() and name not in _CALL_STOPLIST:
|
|
281
|
+
seen.add(name)
|
|
282
|
+
names.append(name)
|
|
283
|
+
# 成员调用的接收者(obj.method() 的 obj)也记一条边,
|
|
284
|
+
# 使“谁调用了某类”能通过实例字段名命中调用方
|
|
285
|
+
for f in _CALL_RECEIVER_FIELDS:
|
|
286
|
+
r = n.child_by_field_name(f)
|
|
287
|
+
if r is None:
|
|
288
|
+
continue
|
|
289
|
+
recv = _last_identifier(r)
|
|
290
|
+
if recv and recv not in seen and recv.isidentifier() and recv not in _CALL_STOPLIST:
|
|
291
|
+
seen.add(recv)
|
|
292
|
+
names.append(recv)
|
|
293
|
+
break
|
|
294
|
+
for c in n.children:
|
|
295
|
+
_visit(c)
|
|
296
|
+
|
|
297
|
+
_visit(node)
|
|
298
|
+
return names
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _make_chunk(node, lang: str, file_path: str) -> CodeChunk:
|
|
302
|
+
code = node.text.decode("utf-8", "ignore")
|
|
303
|
+
return CodeChunk(
|
|
304
|
+
file_path=file_path,
|
|
305
|
+
language=lang,
|
|
306
|
+
symbol=_get_symbol(node),
|
|
307
|
+
start_line=node.start_point[0] + 1,
|
|
308
|
+
end_line=node.end_point[0] + 1,
|
|
309
|
+
code=code,
|
|
310
|
+
calls=_extract_calls(node, lang),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _walk(node, chunks: list, lang: str, file_path: str) -> None:
|
|
315
|
+
"""递归遍历 AST,收集可切分节点。
|
|
316
|
+
|
|
317
|
+
小定义整体成块;大容器(如大类)递归切内部,避免单块过大。
|
|
318
|
+
"""
|
|
319
|
+
split_types = SPLIT_NODE_TYPES.get(lang, set())
|
|
320
|
+
if not split_types:
|
|
321
|
+
return
|
|
322
|
+
for child in node.children:
|
|
323
|
+
if child.type in split_types:
|
|
324
|
+
n_lines = child.end_point[0] - child.start_point[0] + 1
|
|
325
|
+
has_sub = _has_split_descendant(child, split_types)
|
|
326
|
+
if n_lines <= MAX_CHUNK_LINES or not has_sub:
|
|
327
|
+
chunks.append(_make_chunk(child, lang, file_path))
|
|
328
|
+
else:
|
|
329
|
+
_walk(child, chunks, lang, file_path)
|
|
330
|
+
else:
|
|
331
|
+
_walk(child, chunks, lang, file_path)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _chunk_by_lines(file_path: str, code: str, lang: str) -> list[CodeChunk]:
|
|
335
|
+
"""回退策略:按固定行窗口 + 重叠切分。"""
|
|
336
|
+
lines = code.split("\n")
|
|
337
|
+
chunks: list[CodeChunk] = []
|
|
338
|
+
step = max(1, LINE_WINDOW - LINE_OVERLAP)
|
|
339
|
+
for i in range(0, len(lines), step):
|
|
340
|
+
block = lines[i : i + LINE_WINDOW]
|
|
341
|
+
text = "\n".join(block)
|
|
342
|
+
if not text.strip():
|
|
343
|
+
continue
|
|
344
|
+
chunks.append(
|
|
345
|
+
CodeChunk(
|
|
346
|
+
file_path=file_path,
|
|
347
|
+
language=lang,
|
|
348
|
+
symbol=f"lines_{i + 1}",
|
|
349
|
+
start_line=i + 1,
|
|
350
|
+
end_line=i + len(block),
|
|
351
|
+
code=text,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
if i + LINE_WINDOW >= len(lines):
|
|
355
|
+
break
|
|
356
|
+
return chunks
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _is_meaningful(chunk: CodeChunk) -> bool:
|
|
360
|
+
n_lines = chunk.end_line - chunk.start_line + 1
|
|
361
|
+
return n_lines >= MIN_CHUNK_LINES or len(chunk.code.strip()) >= MIN_CHUNK_CHARS
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def chunk_file(file_path: str, code: str | None = None) -> list[CodeChunk]:
|
|
365
|
+
"""把一个文件切成代码块。
|
|
366
|
+
|
|
367
|
+
参数:
|
|
368
|
+
file_path: 文件路径(用于推断语言与生成 blob_hash)
|
|
369
|
+
code: 文件内容,None 时自动读取
|
|
370
|
+
返回:
|
|
371
|
+
CodeChunk 列表
|
|
372
|
+
"""
|
|
373
|
+
path = Path(file_path)
|
|
374
|
+
if code is None:
|
|
375
|
+
try:
|
|
376
|
+
code = path.read_text(encoding="utf-8", errors="ignore")
|
|
377
|
+
except Exception:
|
|
378
|
+
return []
|
|
379
|
+
if not code.strip():
|
|
380
|
+
return []
|
|
381
|
+
|
|
382
|
+
lang = EXT_TO_LANG.get(path.suffix.lower())
|
|
383
|
+
if not lang:
|
|
384
|
+
return _chunk_by_lines(file_path, code, "text")
|
|
385
|
+
|
|
386
|
+
parser = _get_parser(lang)
|
|
387
|
+
if parser is None:
|
|
388
|
+
return _chunk_by_lines(file_path, code, lang)
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
tree = parser.parse(bytes(code, "utf-8"))
|
|
392
|
+
chunks: list[CodeChunk] = []
|
|
393
|
+
_walk(tree.root_node, chunks, lang, file_path)
|
|
394
|
+
chunks = [c for c in chunks if _is_meaningful(c)]
|
|
395
|
+
if not chunks:
|
|
396
|
+
return _chunk_by_lines(file_path, code, lang)
|
|
397
|
+
return chunks
|
|
398
|
+
except Exception:
|
|
399
|
+
return _chunk_by_lines(file_path, code, lang)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Embedding 层:默认 Voyage AI voyage-code-3,可选本地 sentence-transformers 后端。
|
|
2
|
+
|
|
3
|
+
后端选择:SCM_EMBED_BACKEND=voyage(默认)| local
|
|
4
|
+
本地后端:SCM_LOCAL_EMBED_MODEL(默认 Qwen/Qwen3-Embedding-0.6B),需 pip install sentence-transformers
|
|
5
|
+
区分 document(索引时)与 query(检索时)两种输入模式,
|
|
6
|
+
并按字符预算动态分批,避免超过单次请求 token 上限。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
|
|
15
|
+
import voyageai
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# 常见模型的输出维度,避免额外探测调用
|
|
19
|
+
_MODEL_DIM = {
|
|
20
|
+
"voyage-code-3": 1024,
|
|
21
|
+
"voyage-3.5": 1024,
|
|
22
|
+
"voyage-3.5-lite": 1024,
|
|
23
|
+
"voyage-3": 1024,
|
|
24
|
+
"voyage-3-lite": 512,
|
|
25
|
+
"voyage-large-2": 1536,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# 单条文本最大字符数(超长截断,约对应 token 上限)
|
|
29
|
+
_MAX_CHARS_PER_TEXT = 16000
|
|
30
|
+
# 单批最大文本条数
|
|
31
|
+
_MAX_BATCH = 128
|
|
32
|
+
# 单批最大累计字符(默认约 50K tokens;未绑卡受 10K TPM 限制时可调小)
|
|
33
|
+
_CHAR_BUDGET = int(os.getenv("SCM_EMBED_CHAR_BUDGET", "200000"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Embedder:
|
|
37
|
+
"""Voyage embedding 客户端封装(embed_documents 可多线程并发调用)。"""
|
|
38
|
+
|
|
39
|
+
# 索引时建议的并发请求数(纯网络 IO,并发收益显著)
|
|
40
|
+
default_concurrency = 3
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str | None = None,
|
|
45
|
+
model: str = "voyage-code-3",
|
|
46
|
+
output_dimension: int | None = None,
|
|
47
|
+
max_retries: int | None = None,
|
|
48
|
+
output_dtype: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.model = model
|
|
51
|
+
self.output_dimension = output_dimension
|
|
52
|
+
# 量化输出类型:float(默认/最高精度)/ int8(4x 省存储,精度近乎无损)
|
|
53
|
+
self.output_dtype = output_dtype or os.getenv("SCM_EMBED_DTYPE", "float")
|
|
54
|
+
# 遇到限流/临时错误时 SDK 自动 wait-and-retry
|
|
55
|
+
retries = max_retries if max_retries is not None else int(os.getenv("SCM_EMBED_MAX_RETRIES", "4"))
|
|
56
|
+
self.client = voyageai.Client(
|
|
57
|
+
api_key=api_key or os.getenv("VOYAGE_API_KEY"),
|
|
58
|
+
max_retries=retries,
|
|
59
|
+
)
|
|
60
|
+
# 请求最小间隔(秒):未绑卡受 3 RPM 限制时设为 21 可避免限流
|
|
61
|
+
self._min_interval = float(os.getenv("SCM_EMBED_MIN_INTERVAL", "0"))
|
|
62
|
+
self._last_req = 0.0
|
|
63
|
+
# 节流状态锁:并发索引时保证全局 RPM 语义
|
|
64
|
+
self._throttle_lock = threading.Lock()
|
|
65
|
+
self._dim: int | None = None
|
|
66
|
+
self._query_cache: OrderedDict[str, list[float]] = OrderedDict()
|
|
67
|
+
self._query_cache_max = 32
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def dim(self) -> int:
|
|
71
|
+
"""输出向量维度。优先查表,否则探测一次。"""
|
|
72
|
+
if self._dim is None:
|
|
73
|
+
self._dim = self.output_dimension or _MODEL_DIM.get(self.model)
|
|
74
|
+
if self._dim is None:
|
|
75
|
+
self._dim = len(self.embed_query("x"))
|
|
76
|
+
return self._dim
|
|
77
|
+
|
|
78
|
+
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
79
|
+
"""索引用:批量 embedding 代码块。"""
|
|
80
|
+
return self._embed(texts, "document")
|
|
81
|
+
|
|
82
|
+
def embed_query(self, text: str) -> list[float]:
|
|
83
|
+
"""检索用:embedding 单条查询(带 LRU 缓存)。"""
|
|
84
|
+
if text in self._query_cache:
|
|
85
|
+
self._query_cache.move_to_end(text)
|
|
86
|
+
return self._query_cache[text]
|
|
87
|
+
result = self._embed([text], "query")[0]
|
|
88
|
+
self._query_cache[text] = result
|
|
89
|
+
if len(self._query_cache) > self._query_cache_max:
|
|
90
|
+
self._query_cache.popitem(last=False)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
def _embed(self, texts: list[str], input_type: str) -> list[list[float]]:
|
|
94
|
+
results: list[list[float]] = []
|
|
95
|
+
batch: list[str] = []
|
|
96
|
+
batch_chars = 0
|
|
97
|
+
for raw in texts:
|
|
98
|
+
t = (raw or "")[:_MAX_CHARS_PER_TEXT]
|
|
99
|
+
if not t:
|
|
100
|
+
t = " " # 占位,保证与输入一一对应
|
|
101
|
+
if batch and (len(batch) >= _MAX_BATCH or batch_chars + len(t) > _CHAR_BUDGET):
|
|
102
|
+
results.extend(self._embed_batch(batch, input_type))
|
|
103
|
+
batch, batch_chars = [], 0
|
|
104
|
+
batch.append(t)
|
|
105
|
+
batch_chars += len(t)
|
|
106
|
+
if batch:
|
|
107
|
+
results.extend(self._embed_batch(batch, input_type))
|
|
108
|
+
return results
|
|
109
|
+
|
|
110
|
+
def _embed_batch(self, batch: list[str], input_type: str) -> list[list[float]]:
|
|
111
|
+
if self._min_interval > 0:
|
|
112
|
+
# 锁内等待+登记,并发调用下仍严格保持全局请求间隔;HTTP 调用在锁外
|
|
113
|
+
with self._throttle_lock:
|
|
114
|
+
wait = self._min_interval - (time.time() - self._last_req)
|
|
115
|
+
if wait > 0:
|
|
116
|
+
time.sleep(wait)
|
|
117
|
+
self._last_req = time.time()
|
|
118
|
+
kwargs = {"model": self.model, "input_type": input_type}
|
|
119
|
+
if self.output_dimension:
|
|
120
|
+
kwargs["output_dimension"] = self.output_dimension
|
|
121
|
+
if self.output_dtype and self.output_dtype != "float":
|
|
122
|
+
kwargs["output_dtype"] = self.output_dtype
|
|
123
|
+
resp = self.client.embed(batch, **kwargs)
|
|
124
|
+
return resp.embeddings
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class LocalEmbedder:
|
|
128
|
+
"""本地 sentence-transformers 后端(如 Qwen3-Embedding-0.6B)。
|
|
129
|
+
|
|
130
|
+
与 Embedder 同接口(dim / embed_documents / embed_query / output_dtype),
|
|
131
|
+
可直接替换。需额外安装 sentence-transformers(惰性导入,不影响 voyage 路径)。
|
|
132
|
+
Qwen3-Embedding 系列自带 query prompt,检索时自动启用;其它模型静默跳过。
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
# 本地推理是算力瓶颈,并发调用只会互相抢 CPU,保持串行
|
|
136
|
+
default_concurrency = 1
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
model: str | None = None,
|
|
141
|
+
device: str | None = None,
|
|
142
|
+
batch_size: int | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
from sentence_transformers import SentenceTransformer # 惰性导入
|
|
145
|
+
|
|
146
|
+
self.model = model or os.getenv("SCM_LOCAL_EMBED_MODEL", "Qwen/Qwen3-Embedding-0.6B")
|
|
147
|
+
self.output_dtype = "float"
|
|
148
|
+
self.output_dimension = None
|
|
149
|
+
dev = device or os.getenv("SCM_LOCAL_EMBED_DEVICE") or None
|
|
150
|
+
self.st = SentenceTransformer(self.model, device=dev)
|
|
151
|
+
# CPU 推理控制序列长度保速度;代码块均值远小于 1024 token
|
|
152
|
+
self.st.max_seq_length = int(os.getenv("SCM_LOCAL_EMBED_MAX_SEQ", "1024"))
|
|
153
|
+
self.batch_size = batch_size or int(os.getenv("SCM_LOCAL_EMBED_BATCH", "16"))
|
|
154
|
+
self._query_prompt = "query" if "query" in (getattr(self.st, "prompts", None) or {}) else None
|
|
155
|
+
self._dim: int | None = None
|
|
156
|
+
self._query_cache: OrderedDict[str, list[float]] = OrderedDict()
|
|
157
|
+
self._query_cache_max = 32
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def dim(self) -> int:
|
|
161
|
+
if self._dim is None:
|
|
162
|
+
self._dim = int(self.st.get_sentence_embedding_dimension())
|
|
163
|
+
return self._dim
|
|
164
|
+
|
|
165
|
+
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
166
|
+
clipped = [(t or " ")[:_MAX_CHARS_PER_TEXT] for t in texts]
|
|
167
|
+
embs = self.st.encode(
|
|
168
|
+
clipped, batch_size=self.batch_size,
|
|
169
|
+
normalize_embeddings=True, show_progress_bar=False,
|
|
170
|
+
)
|
|
171
|
+
return [e.tolist() for e in embs]
|
|
172
|
+
|
|
173
|
+
def embed_query(self, text: str) -> list[float]:
|
|
174
|
+
if text in self._query_cache:
|
|
175
|
+
self._query_cache.move_to_end(text)
|
|
176
|
+
return self._query_cache[text]
|
|
177
|
+
kwargs = {"normalize_embeddings": True, "show_progress_bar": False}
|
|
178
|
+
if self._query_prompt:
|
|
179
|
+
kwargs["prompt_name"] = self._query_prompt
|
|
180
|
+
result = self.st.encode([text[:_MAX_CHARS_PER_TEXT]], **kwargs)[0].tolist()
|
|
181
|
+
self._query_cache[text] = result
|
|
182
|
+
if len(self._query_cache) > self._query_cache_max:
|
|
183
|
+
self._query_cache.popitem(last=False)
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def create_embedder(**kwargs):
|
|
188
|
+
"""按 SCM_EMBED_BACKEND 选择后端:voyage(默认)/ local。"""
|
|
189
|
+
backend = os.getenv("SCM_EMBED_BACKEND", "voyage").lower()
|
|
190
|
+
if backend == "local":
|
|
191
|
+
return LocalEmbedder()
|
|
192
|
+
return Embedder(**kwargs)
|