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.
@@ -0,0 +1,3 @@
1
+ """semantic-code-mcp:代码语义检索 MCP Server。"""
2
+
3
+ __version__ = "0.1.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)