semcode-mcp 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/PKG-INFO +13 -4
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/README.md +12 -3
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/pyproject.toml +1 -1
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/chunker.py +11 -5
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/embedder.py +58 -19
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/indexer.py +14 -9
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/retriever.py +54 -21
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/server.py +1 -1
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/store.py +101 -3
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/workspace.py +2 -1
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/.env.example +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/.gitignore +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/LICENSE +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/npm/README.md +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/__init__.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/expander.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.2}/src/semantic_code_mcp/watcher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: semcode-mcp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Semantic code search MCP server: Tree-sitter AST chunking + voyage-code-3 embeddings + hybrid retrieval (sqlite-vec + FTS5 + RRF) + Voyage rerank-2.5 + call graph expansion
|
|
5
5
|
Project-URL: Homepage, https://github.com/huawang1258/semantic-code-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/huawang1258/semantic-code-mcp
|
|
@@ -51,7 +51,7 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
|
|
52
52
|
「效果优先」技术栈:**Tree-sitter AST 切分 + voyage-code-3 embedding(int8 量化)+ sqlite-vec 向量库 + BM25 全文 + RRF 融合 + Voyage rerank-2.5 重排 + Call Graph 图扩展**。一个 `VOYAGE_API_KEY` 跑通 embedding + rerank 全链路。
|
|
53
53
|
|
|
54
|
-
工程特性:**watchdog 文件实时监控 + 多工作区 LRU 管理 + MCP progress notification +
|
|
54
|
+
工程特性:**watchdog 文件实时监控 + 多工作区 LRU 管理 + MCP progress notification + 协程级取消**(取消后立即返回;已在飞的索引批次在后台跑完并持久化,不丢数据也不重复计费重建)。
|
|
55
55
|
|
|
56
56
|
## 架构
|
|
57
57
|
|
|
@@ -69,7 +69,7 @@ codebase_search(query, dir)
|
|
|
69
69
|
Retriever ── 向量召回 + BM25召回 ── RRF融合 ── rerank(Voyage/Cohere)── Call Graph扩展 ── top_n 代码片段
|
|
70
70
|
│
|
|
71
71
|
▼
|
|
72
|
-
MCP Server ── async tools + progress notification +
|
|
72
|
+
MCP Server ── async tools + progress notification + 协程级取消
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
| 模块 | 职责 |
|
|
@@ -186,7 +186,16 @@ npx 用法把 `command/args` 换成 `"npx", ["-y", "semcode-mcp"]`;
|
|
|
186
186
|
| Cohere(可选后端) | 查询文本 + top-50 候选代码块全文 | 仅 `SCM_RERANK_BACKEND=cohere` 时;默认零发送 |
|
|
187
187
|
| 自配 LLM 端点(可选,默认关) | 仅查询文本(不含代码) | `SCM_QUERY_EXPANSION=on` 时 |
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
### ⚠️ Voyage 数据条款(重要,索引闭源代码前必读)
|
|
190
|
+
|
|
191
|
+
按 Voyage [TOS](https://www.voyageai.com/tos) Section 3(iii)(2026-07 复核):
|
|
192
|
+
|
|
193
|
+
- **默认(opt-out 前)提交内容可被 Voyage 用于训练和改进模型**
|
|
194
|
+
- Opt-out 需要:账户绑定支付方式 + 组织 Admin 身份,dashboard → Terms of Service → 底部 toggle 切到 Opted Out
|
|
195
|
+
- Opt-out 后为 zero-day retention(处理完即删),但**不追溯**之前提交的数据,且免费额度可能被作废
|
|
196
|
+
- Batch API 上传的文件会保留 30 天(本工具不使用 Batch API)
|
|
197
|
+
|
|
198
|
+
**用于公司/闭源代码前,先完成 opt-out 或使用全本地模式。** Cohere(可选 rerank 后端)条款请自行评估。
|
|
190
199
|
|
|
191
200
|
### 始终留在本地的
|
|
192
201
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
「效果优先」技术栈:**Tree-sitter AST 切分 + voyage-code-3 embedding(int8 量化)+ sqlite-vec 向量库 + BM25 全文 + RRF 融合 + Voyage rerank-2.5 重排 + Call Graph 图扩展**。一个 `VOYAGE_API_KEY` 跑通 embedding + rerank 全链路。
|
|
6
6
|
|
|
7
|
-
工程特性:**watchdog 文件实时监控 + 多工作区 LRU 管理 + MCP progress notification +
|
|
7
|
+
工程特性:**watchdog 文件实时监控 + 多工作区 LRU 管理 + MCP progress notification + 协程级取消**(取消后立即返回;已在飞的索引批次在后台跑完并持久化,不丢数据也不重复计费重建)。
|
|
8
8
|
|
|
9
9
|
## 架构
|
|
10
10
|
|
|
@@ -22,7 +22,7 @@ codebase_search(query, dir)
|
|
|
22
22
|
Retriever ── 向量召回 + BM25召回 ── RRF融合 ── rerank(Voyage/Cohere)── Call Graph扩展 ── top_n 代码片段
|
|
23
23
|
│
|
|
24
24
|
▼
|
|
25
|
-
MCP Server ── async tools + progress notification +
|
|
25
|
+
MCP Server ── async tools + progress notification + 协程级取消
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
| 模块 | 职责 |
|
|
@@ -139,7 +139,16 @@ npx 用法把 `command/args` 换成 `"npx", ["-y", "semcode-mcp"]`;
|
|
|
139
139
|
| Cohere(可选后端) | 查询文本 + top-50 候选代码块全文 | 仅 `SCM_RERANK_BACKEND=cohere` 时;默认零发送 |
|
|
140
140
|
| 自配 LLM 端点(可选,默认关) | 仅查询文本(不含代码) | `SCM_QUERY_EXPANSION=on` 时 |
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
### ⚠️ Voyage 数据条款(重要,索引闭源代码前必读)
|
|
143
|
+
|
|
144
|
+
按 Voyage [TOS](https://www.voyageai.com/tos) Section 3(iii)(2026-07 复核):
|
|
145
|
+
|
|
146
|
+
- **默认(opt-out 前)提交内容可被 Voyage 用于训练和改进模型**
|
|
147
|
+
- Opt-out 需要:账户绑定支付方式 + 组织 Admin 身份,dashboard → Terms of Service → 底部 toggle 切到 Opted Out
|
|
148
|
+
- Opt-out 后为 zero-day retention(处理完即删),但**不追溯**之前提交的数据,且免费额度可能被作废
|
|
149
|
+
- Batch API 上传的文件会保留 30 天(本工具不使用 Batch API)
|
|
150
|
+
|
|
151
|
+
**用于公司/闭源代码前,先完成 opt-out 或使用全本地模式。** Cohere(可选 rerank 后端)条款请自行评估。
|
|
143
152
|
|
|
144
153
|
### 始终留在本地的
|
|
145
154
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "semcode-mcp"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Semantic code search MCP server: Tree-sitter AST chunking + voyage-code-3 embeddings + hybrid retrieval (sqlite-vec + FTS5 + RRF) + Voyage rerank-2.5 + call graph expansion"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import hashlib
|
|
9
9
|
import importlib
|
|
10
|
+
import threading
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
@@ -125,7 +126,9 @@ LINE_OVERLAP = 10
|
|
|
125
126
|
MIN_CHUNK_LINES = 3
|
|
126
127
|
MIN_CHUNK_CHARS = 40
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
# per-thread parser 缓存:tree-sitter Parser 对象非线程安全,多 workspace
|
|
130
|
+
# 并发 sync 时共享同一 parser 会产生数据竞争(错误解析/崩溃)
|
|
131
|
+
_PARSER_TLS = threading.local()
|
|
129
132
|
|
|
130
133
|
|
|
131
134
|
@dataclass
|
|
@@ -170,9 +173,12 @@ _LANG_MODULES = {
|
|
|
170
173
|
|
|
171
174
|
|
|
172
175
|
def _get_parser(lang: str):
|
|
173
|
-
"""获取并缓存 parser
|
|
174
|
-
|
|
175
|
-
|
|
176
|
+
"""获取并缓存 parser(每线程独立),失败返回 None(该语言回退按行切)。"""
|
|
177
|
+
cache = getattr(_PARSER_TLS, "cache", None)
|
|
178
|
+
if cache is None:
|
|
179
|
+
cache = _PARSER_TLS.cache = {}
|
|
180
|
+
if lang in cache:
|
|
181
|
+
return cache[lang]
|
|
176
182
|
parser = None
|
|
177
183
|
spec = _LANG_MODULES.get(lang)
|
|
178
184
|
if spec is not None:
|
|
@@ -183,7 +189,7 @@ def _get_parser(lang: str):
|
|
|
183
189
|
parser = Parser(language)
|
|
184
190
|
except Exception:
|
|
185
191
|
parser = None
|
|
186
|
-
|
|
192
|
+
cache[lang] = parser
|
|
187
193
|
return parser
|
|
188
194
|
|
|
189
195
|
|
|
@@ -65,6 +65,9 @@ class Embedder:
|
|
|
65
65
|
self._dim: int | None = None
|
|
66
66
|
self._query_cache: OrderedDict[str, list[float]] = OrderedDict()
|
|
67
67
|
self._query_cache_max = 32
|
|
68
|
+
# 缓存锁:多 workspace 并发检索共享同一 Embedder,OrderedDict 读改
|
|
69
|
+
# 无锁会在淘汰竞态下 KeyError(重复 miss 多发一次请求可接受,无害)
|
|
70
|
+
self._cache_lock = threading.Lock()
|
|
68
71
|
|
|
69
72
|
@property
|
|
70
73
|
def dim(self) -> int:
|
|
@@ -81,14 +84,32 @@ class Embedder:
|
|
|
81
84
|
|
|
82
85
|
def embed_query(self, text: str) -> list[float]:
|
|
83
86
|
"""检索用:embedding 单条查询(带 LRU 缓存)。"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
return self.embed_queries([text])[0]
|
|
88
|
+
|
|
89
|
+
def embed_queries(self, texts: list[str]) -> list[list[float]]:
|
|
90
|
+
"""检索用:批量 embedding 多条查询,与输入一一对应。
|
|
91
|
+
|
|
92
|
+
复合查询主查询 + 子查询合并成一次请求(省 N-1 次网络往返);
|
|
93
|
+
命中缓存的不重发,只有未命中子集上行。
|
|
94
|
+
"""
|
|
95
|
+
with self._cache_lock:
|
|
96
|
+
cached = {t: self._query_cache[t] for t in texts if t in self._query_cache}
|
|
97
|
+
for t in cached:
|
|
98
|
+
self._query_cache.move_to_end(t)
|
|
99
|
+
missing = [t for t in dict.fromkeys(texts) if t not in cached]
|
|
100
|
+
fresh: dict[str, list[float]] = {}
|
|
101
|
+
if missing:
|
|
102
|
+
# 网络调用在锁外:持锁发请求会把并发查询串行化
|
|
103
|
+
for t, vec in zip(missing, self._embed(missing, "query")):
|
|
104
|
+
fresh[t] = vec
|
|
105
|
+
with self._cache_lock:
|
|
106
|
+
for t, vec in fresh.items():
|
|
107
|
+
self._query_cache[t] = vec
|
|
108
|
+
self._query_cache.move_to_end(t)
|
|
109
|
+
while len(self._query_cache) > self._query_cache_max:
|
|
110
|
+
self._query_cache.popitem(last=False)
|
|
111
|
+
# 输出只从本地快照组装,不依赖缓存在锁外的存活性(并发淘汰不影响正确性)
|
|
112
|
+
return [cached[t] if t in cached else fresh[t] for t in texts]
|
|
92
113
|
|
|
93
114
|
def _embed(self, texts: list[str], input_type: str) -> list[list[float]]:
|
|
94
115
|
results: list[list[float]] = []
|
|
@@ -155,6 +176,10 @@ class LocalEmbedder:
|
|
|
155
176
|
self._dim: int | None = None
|
|
156
177
|
self._query_cache: OrderedDict[str, list[float]] = OrderedDict()
|
|
157
178
|
self._query_cache_max = 32
|
|
179
|
+
# 与 Embedder 同款缓存锁;另加 encode 串行锁——SentenceTransformer 推理
|
|
180
|
+
# 非线程安全,且 CPU 推理并发也无吞吐收益
|
|
181
|
+
self._cache_lock = threading.Lock()
|
|
182
|
+
self._encode_lock = threading.Lock()
|
|
158
183
|
|
|
159
184
|
@property
|
|
160
185
|
def dim(self) -> int:
|
|
@@ -164,25 +189,39 @@ class LocalEmbedder:
|
|
|
164
189
|
|
|
165
190
|
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
166
191
|
clipped = [(t or " ")[:_MAX_CHARS_PER_TEXT] for t in texts]
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
192
|
+
with self._encode_lock:
|
|
193
|
+
embs = self.st.encode(
|
|
194
|
+
clipped, batch_size=self.batch_size,
|
|
195
|
+
normalize_embeddings=True, show_progress_bar=False,
|
|
196
|
+
)
|
|
171
197
|
return [e.tolist() for e in embs]
|
|
172
198
|
|
|
173
199
|
def embed_query(self, text: str) -> list[float]:
|
|
174
|
-
|
|
175
|
-
self._query_cache
|
|
176
|
-
|
|
200
|
+
with self._cache_lock:
|
|
201
|
+
if text in self._query_cache:
|
|
202
|
+
self._query_cache.move_to_end(text)
|
|
203
|
+
return self._query_cache[text]
|
|
177
204
|
kwargs = {"normalize_embeddings": True, "show_progress_bar": False}
|
|
178
205
|
if self._query_prompt:
|
|
179
206
|
kwargs["prompt_name"] = self._query_prompt
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
self.
|
|
207
|
+
with self._encode_lock:
|
|
208
|
+
# double-check:排队等锁期间同 key 可能已被先行线程算完,
|
|
209
|
+
# 不查会让 N 个并发同 key 请求重复推理 N 次(本地模型延迟放大)
|
|
210
|
+
with self._cache_lock:
|
|
211
|
+
if text in self._query_cache:
|
|
212
|
+
self._query_cache.move_to_end(text)
|
|
213
|
+
return self._query_cache[text]
|
|
214
|
+
result = self.st.encode([text[:_MAX_CHARS_PER_TEXT]], **kwargs)[0].tolist()
|
|
215
|
+
with self._cache_lock:
|
|
216
|
+
self._query_cache[text] = result
|
|
217
|
+
while len(self._query_cache) > self._query_cache_max:
|
|
218
|
+
self._query_cache.popitem(last=False)
|
|
184
219
|
return result
|
|
185
220
|
|
|
221
|
+
def embed_queries(self, texts: list[str]) -> list[list[float]]:
|
|
222
|
+
"""与 Embedder.embed_queries 同接口;本地推理无网络往返,逐条等价。"""
|
|
223
|
+
return [self.embed_query(t) for t in texts]
|
|
224
|
+
|
|
186
225
|
|
|
187
226
|
def create_embedder(**kwargs):
|
|
188
227
|
"""按 SCM_EMBED_BACKEND 选择后端:voyage(默认)/ local。"""
|
|
@@ -43,6 +43,9 @@ SKIP_FILENAMES = {
|
|
|
43
43
|
}
|
|
44
44
|
# agent 指导文件:架构/规范信息密度最高,很多仓库把它们 gitignore 了,索引时豁免
|
|
45
45
|
FORCE_INCLUDE_FILENAMES = {"AGENTS.md", "CLAUDE.md", "GEMINI.md", "AGENT.md", ".windsurfrules", ".cursorrules"}
|
|
46
|
+
# 点目录白名单:隐藏目录默认全排除,但 CI workflow / agent 配置的检索价值高
|
|
47
|
+
# ("发布流程在哪定义"类查询必需);仍受 gitignore 约束
|
|
48
|
+
DOT_DIR_ALLOWLIST = {".github", ".gitlab", ".windsurf", ".devin", ".claude", ".cursor", ".codex", ".agent"}
|
|
46
49
|
# 单批 embedding 的最大累计块数(跨文件合并,减少 API 往返)
|
|
47
50
|
BATCH_CHUNKS = 256
|
|
48
51
|
|
|
@@ -116,10 +119,10 @@ class _EmbedPipeline:
|
|
|
116
119
|
offset = 0
|
|
117
120
|
for fp, fh, mt, sz, cs in batch:
|
|
118
121
|
n = len(cs)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
# 单事务原子替换:删旧 + 插新 + 指纹,中途崩溃整体回滚,
|
|
123
|
+
# 不会出现旧数据已删但新数据未写的中间态
|
|
124
|
+
store.replace_file(fp, cs, embeddings[offset:offset + n], fh, mtime=mt, size=sz)
|
|
125
|
+
offset += n
|
|
123
126
|
if self.on_write:
|
|
124
127
|
self.on_write(len(batch), len(all_chunks))
|
|
125
128
|
|
|
@@ -189,7 +192,8 @@ class Indexer:
|
|
|
189
192
|
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
190
193
|
dirnames[:] = [
|
|
191
194
|
d for d in dirnames
|
|
192
|
-
if d not in DEFAULT_IGNORE_DIRS
|
|
195
|
+
if d not in DEFAULT_IGNORE_DIRS
|
|
196
|
+
and (not d.startswith(".") or d in DOT_DIR_ALLOWLIST)
|
|
193
197
|
]
|
|
194
198
|
if ".gitignore" in filenames:
|
|
195
199
|
gi = Path(dirpath) / ".gitignore"
|
|
@@ -242,10 +246,11 @@ class Indexer:
|
|
|
242
246
|
|
|
243
247
|
def _iter_source_files(self):
|
|
244
248
|
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
245
|
-
#
|
|
249
|
+
# 原地过滤忽略目录与隐藏目录(白名单内的点目录放行)
|
|
246
250
|
dirnames[:] = [
|
|
247
251
|
d for d in dirnames
|
|
248
|
-
if d not in DEFAULT_IGNORE_DIRS
|
|
252
|
+
if d not in DEFAULT_IGNORE_DIRS
|
|
253
|
+
and (not d.startswith(".") or d in DOT_DIR_ALLOWLIST)
|
|
249
254
|
]
|
|
250
255
|
for fn in filenames:
|
|
251
256
|
p = Path(dirpath) / fn
|
|
@@ -308,7 +313,7 @@ class Indexer:
|
|
|
308
313
|
buf_chunks = 0
|
|
309
314
|
try:
|
|
310
315
|
for done, (fp, fh, mt, sz) in enumerate(to_index, 1):
|
|
311
|
-
|
|
316
|
+
# 不提前删旧数据:由 replace_file 在写回时单事务完成删+插
|
|
312
317
|
chunks = chunk_file(fp)
|
|
313
318
|
chunks_total += len(chunks)
|
|
314
319
|
buf.append((fp, fh, mt, sz, chunks))
|
|
@@ -397,7 +402,7 @@ class Indexer:
|
|
|
397
402
|
buf_chunks = 0
|
|
398
403
|
try:
|
|
399
404
|
for done, (fp, fh, mt, sz) in enumerate(to_index, 1):
|
|
400
|
-
|
|
405
|
+
# 不提前删旧数据:由 replace_file 在写回时单事务完成删+插
|
|
401
406
|
chunks = chunk_file(fp)
|
|
402
407
|
buf.append((fp, fh, mt, sz, chunks))
|
|
403
408
|
buf_chunks += len(chunks)
|
|
@@ -17,6 +17,7 @@ import fnmatch
|
|
|
17
17
|
import os
|
|
18
18
|
import re
|
|
19
19
|
import time
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
21
|
|
|
21
22
|
from .embedder import Embedder
|
|
22
23
|
from .expander import create_expander
|
|
@@ -141,12 +142,19 @@ _COMPOUND_SUB_WEIGHT = 0.7
|
|
|
141
142
|
_COMPOUND_PROBE_TOP = 10
|
|
142
143
|
# 子查询 rerank 分数混合:探针块用整句 rerank 分天然吃亏("清理 100 天"
|
|
143
144
|
# 在整句里只占小半权重),单独用子查询文本再 rerank 一次,
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
#
|
|
149
|
-
|
|
145
|
+
# 混合分 = 整句 top 分 × 子查询相对强度 × 此系数(子意图最佳代表
|
|
146
|
+
# ≈ 整句最佳的 95 折;Voyage 分数域窄,0.9 的提拔量不足以越过
|
|
147
|
+
# 中间名次的密集分数带,0.95 实测 K1 探针 rank5→rank4)
|
|
148
|
+
_COMPOUND_SUB_RERANK_MIX = 0.95
|
|
149
|
+
# 子查询分参与混合/保底的最低门槛,按 rerank 后端分档标定(分数分布不同,
|
|
150
|
+
# 绝对阈值跨后端不通用):
|
|
151
|
+
# - cohere(rerank-v3.5)分布宽:实测"清理"这种宽泛短词的字面命中
|
|
152
|
+
# (clearCache/clear 工具方法)全部 ≤0.14,真业务清理实现 ≥0.22,0.2 是分界线
|
|
153
|
+
# - voyage(rerank-2.5)分布窄且整体偏高:无关块 <0.5,字面噪声(0.63)与
|
|
154
|
+
# 真业务(0.645)重叠不可分——0.55 只拦"彻底无关",字面噪声由
|
|
155
|
+
# "子意图已有代表则不动"(6.5)与 must_contain 探针兜底
|
|
156
|
+
_COMPOUND_SUB_MIN_SCORE = {"voyage": 0.55, "cohere": 0.2}
|
|
157
|
+
_COMPOUND_SUB_MIN_DEFAULT = 0.2
|
|
150
158
|
|
|
151
159
|
|
|
152
160
|
class Retriever:
|
|
@@ -355,7 +363,10 @@ class Retriever:
|
|
|
355
363
|
把调用者/被调用者作为关联结果附加(带 relation 字段)。
|
|
356
364
|
"""
|
|
357
365
|
# 1. 向量召回 + 2. BM25 召回(原查询)
|
|
358
|
-
|
|
366
|
+
# 复合查询提前拆分,主查询 + 子查询合批一次 embed(省 N-1 次往返)
|
|
367
|
+
subs = self._compound_subqueries(query)
|
|
368
|
+
embs = self.embedder.embed_queries([query, *subs])
|
|
369
|
+
q_emb, sub_embs = embs[0], embs[1:]
|
|
359
370
|
rank_lists = [
|
|
360
371
|
[cid for cid, _ in self.store.search_vector(q_emb, top_k)],
|
|
361
372
|
[cid for cid, _ in self.store.search_fts(query, top_k)],
|
|
@@ -386,10 +397,9 @@ class Retriever:
|
|
|
386
397
|
weights.append(weights[0])
|
|
387
398
|
# 2.9 复合意图拆分("生成+清理"):单次向量召回对双动作查询天然偏科,
|
|
388
399
|
# 各子查询独立召回后低权重并入 RRF,两个意图都能拿到席位
|
|
389
|
-
|
|
400
|
+
min_sub = _COMPOUND_SUB_MIN_SCORE.get(self.rerank_backend, _COMPOUND_SUB_MIN_DEFAULT)
|
|
390
401
|
sub_probes: list[list[int]] = [] # 每个子查询自己的前 N 名(保底探针/分数混合用)
|
|
391
|
-
for sub in subs:
|
|
392
|
-
s_emb = self.embedder.embed_query(sub)
|
|
402
|
+
for sub, s_emb in zip(subs, sub_embs):
|
|
393
403
|
sub_lists = [[cid for cid, _ in self.store.search_vector(s_emb, top_k)]]
|
|
394
404
|
sub_fts = (
|
|
395
405
|
self.store.search_fts_trigram(sub, top_k)
|
|
@@ -438,22 +448,29 @@ class Retriever:
|
|
|
438
448
|
# 子查询文本打分才能反映它对那半意图的真实相关性
|
|
439
449
|
if subs and sub_probes:
|
|
440
450
|
by_id = {c.get("id"): c for c in candidates}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
451
|
+
# 混合量纲锚定整句 top 分:Voyage 分数域窄(整句/子查询分都挤在
|
|
452
|
+
# 0.5-0.8,裸子查询分 ×0.9 永远赢不了整句分,混合失效);
|
|
453
|
+
# 改为整句 top × 子查询相对强度,两后端语义一致:
|
|
454
|
+
# 子查询的最佳代表 ≈ 整句最佳的 _COMPOUND_SUB_RERANK_MIX 折
|
|
455
|
+
full_top = max((c.get("score", 0.0) for c in candidates), default=0.0)
|
|
456
|
+
tasks = [
|
|
457
|
+
(sub, [by_id[cid] for cid in probe if cid in by_id])
|
|
458
|
+
for sub, probe in zip(subs, sub_probes)
|
|
459
|
+
]
|
|
460
|
+
tasks = [(s, c) for s, c in tasks if c]
|
|
461
|
+
scored_lists = self._rerank_many(tasks)
|
|
462
|
+
for (sub, _), scored in zip(tasks, scored_lists):
|
|
463
|
+
sub_top = max((s["score"] for s in scored), default=0.0)
|
|
449
464
|
for s in scored:
|
|
450
465
|
c = by_id.get(s.get("id"))
|
|
451
466
|
if c is None:
|
|
452
467
|
continue
|
|
453
468
|
c["_sub_score"] = max(c.get("_sub_score", 0.0), s["score"])
|
|
454
|
-
if s["score"] >=
|
|
469
|
+
if s["score"] >= min_sub and sub_top > 0:
|
|
455
470
|
c["score"] = max(
|
|
456
|
-
c["score"],
|
|
471
|
+
c["score"],
|
|
472
|
+
full_top * (s["score"] / sub_top)
|
|
473
|
+
* _COMPOUND_SUB_RERANK_MIX,
|
|
457
474
|
)
|
|
458
475
|
# 4.5 调用链意图:结构化命中注入高分(并入统一打分管线,测试调用方会被后续降权拆开)
|
|
459
476
|
intent_hits = self._caller_intent_hits(query)
|
|
@@ -548,7 +565,7 @@ class Retriever:
|
|
|
548
565
|
(
|
|
549
566
|
by_id[cid] for cid in probe
|
|
550
567
|
if cid in by_id and cid not in rescued_ids
|
|
551
|
-
and by_id[cid].get("_sub_score", 0.0) >=
|
|
568
|
+
and by_id[cid].get("_sub_score", 0.0) >= min_sub
|
|
552
569
|
and not (path_filter and not self._path_match(
|
|
553
570
|
by_id[cid].get("file_path", ""), path_filter))
|
|
554
571
|
),
|
|
@@ -657,6 +674,22 @@ class Retriever:
|
|
|
657
674
|
scores[doc_id] = scores.get(doc_id, 0.0) + w / (k + rank + 1)
|
|
658
675
|
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
|
659
676
|
|
|
677
|
+
def _rerank_many(self, tasks: list[tuple[str, list[dict]]]) -> list[list[dict]]:
|
|
678
|
+
"""批量 rerank(子查询分数混合用),失败项降级为空列表。
|
|
679
|
+
|
|
680
|
+
多个子查询并行发送(纯网络 IO,省一次往返延迟);
|
|
681
|
+
评测限速场景(SCM_RERANK_MIN_INTERVAL>0)保持串行,不破坏全局请求间隔。"""
|
|
682
|
+
def one(sub: str, cands: list[dict]) -> list[dict]:
|
|
683
|
+
try:
|
|
684
|
+
return self._rerank(sub, cands, len(cands))
|
|
685
|
+
except Exception:
|
|
686
|
+
return []
|
|
687
|
+
|
|
688
|
+
if len(tasks) > 1 and _rerank_min_interval() <= 0:
|
|
689
|
+
with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
|
|
690
|
+
return list(ex.map(lambda t: one(*t), tasks))
|
|
691
|
+
return [one(s, c) for s, c in tasks]
|
|
692
|
+
|
|
660
693
|
def _rerank(self, query: str, candidates: list[dict], top_n: int) -> list[dict]:
|
|
661
694
|
docs = [self._doc_text(c) for c in candidates]
|
|
662
695
|
min_interval = _rerank_min_interval()
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
- 多工作区 LRU 管理(SCM_MAX_WORKSPACES,默认 8)
|
|
9
9
|
- watchdog 文件监控 + 2s debounce(SCM_WATCH=0 禁用)
|
|
10
10
|
- MCP progress notification(索引进度实时回报)
|
|
11
|
-
-
|
|
11
|
+
- 协程级取消(取消后立即返回;已在飞的索引批次后台跑完并持久化,不丢数据)
|
|
12
12
|
- 首次全量索引 / 后续 watcher 增量(O(变更数) 极速)
|
|
13
13
|
"""
|
|
14
14
|
from __future__ import annotations
|
|
@@ -38,10 +38,16 @@ def _is_boilerplate_symbol(name: str) -> bool:
|
|
|
38
38
|
return bool(_ACCESSOR_RE.match(name)) or name in _BOILERPLATE_NAMES
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
# 索引格式版本:切块算法 / embed 上下文文本格式(_embed_text)变更时必须 bump。
|
|
42
|
+
# 文件 hash 没变不代表块/向量还有效:切块边界或上下文头变了,旧块与新块
|
|
43
|
+
# 不可比,必须拒绝静默混用(由 _check_profile 拦截并提示重建)。
|
|
44
|
+
INDEX_FORMAT = "1"
|
|
45
|
+
|
|
46
|
+
|
|
41
47
|
class CodeStore:
|
|
42
48
|
"""单个工作区的索引存储。"""
|
|
43
49
|
|
|
44
|
-
def __init__(self, db_path: str, dim: int, dtype: str = "float") -> None:
|
|
50
|
+
def __init__(self, db_path: str, dim: int, dtype: str = "float", embed_model: str = "") -> None:
|
|
45
51
|
self.db_path = db_path
|
|
46
52
|
self.dim = dim
|
|
47
53
|
self.dtype = dtype if dtype in ("float", "int8") else "float"
|
|
@@ -49,7 +55,52 @@ class CodeStore:
|
|
|
49
55
|
self.db.enable_load_extension(True)
|
|
50
56
|
sqlite_vec.load(self.db)
|
|
51
57
|
self.db.enable_load_extension(False)
|
|
58
|
+
# 跨进程治理:WAL 允许多进程并发读 + 单写不互斥读;busy_timeout 让并发写
|
|
59
|
+
# 排队等待而不是立即抛 SQLITE_BUSY(本机源码版 + uvx 发布版可能同时运行)
|
|
60
|
+
try:
|
|
61
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
62
|
+
self.db.execute("PRAGMA busy_timeout=5000")
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
52
65
|
self._init_schema()
|
|
66
|
+
self._check_profile(embed_model)
|
|
67
|
+
|
|
68
|
+
def _check_profile(self, embed_model: str) -> None:
|
|
69
|
+
"""索引 profile 校验:换 embedding 模型/维度后旧向量不可比,必须拒绝混用。
|
|
70
|
+
|
|
71
|
+
meta 表记录 (embed_model, dim, dtype)。不匹配时抛错并提示删库重建,
|
|
72
|
+
而不是静默把不同模型的向量混进同一个向量空间。embed_model 为空
|
|
73
|
+
(单元测试 fake embedder)时跳过模型名校验,但仍校验 dim/dtype。
|
|
74
|
+
"""
|
|
75
|
+
cur = self.db.cursor()
|
|
76
|
+
cur.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
77
|
+
rows = dict(cur.execute("SELECT key, value FROM meta").fetchall())
|
|
78
|
+
want = {
|
|
79
|
+
"embed_model": embed_model,
|
|
80
|
+
"dim": str(self.dim),
|
|
81
|
+
"dtype": self.dtype,
|
|
82
|
+
"index_format": INDEX_FORMAT,
|
|
83
|
+
}
|
|
84
|
+
has_chunks = bool(cur.execute("SELECT 1 FROM chunks LIMIT 1").fetchone())
|
|
85
|
+
for key, val in want.items():
|
|
86
|
+
if key == "embed_model" and not val:
|
|
87
|
+
continue
|
|
88
|
+
old = rows.get(key)
|
|
89
|
+
if old is None:
|
|
90
|
+
# 缺失时认领当前值:仅对「库确实由当前算法/模型构建」成立。
|
|
91
|
+
# 历史上 chunker/默认模型从未变过,认领事实正确;今后变更
|
|
92
|
+
# 必须 bump INDEX_FORMAT/换模型名,让不匹配分支拦住旧库。
|
|
93
|
+
cur.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, val))
|
|
94
|
+
elif old != val and has_chunks:
|
|
95
|
+
self.db.commit()
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"索引 profile 不匹配:DB 的 {key}={old!r},当前配置 {key}={val!r}。"
|
|
98
|
+
f"不同 embedding 配置的向量不可混用,请删除索引文件后重建:{self.db_path}"
|
|
99
|
+
)
|
|
100
|
+
elif old != val:
|
|
101
|
+
# 库还是空的:直接更新 profile(如 fake 测试库先建后配)
|
|
102
|
+
cur.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, val))
|
|
103
|
+
self.db.commit()
|
|
53
104
|
|
|
54
105
|
@staticmethod
|
|
55
106
|
def _trigram_supported(cur) -> bool:
|
|
@@ -180,6 +231,19 @@ class CodeStore:
|
|
|
180
231
|
def add_chunks(self, chunks: list[CodeChunk], embeddings: list[list[float]]) -> int:
|
|
181
232
|
"""批量写入代码块及其向量。blob_hash 重复的自动跳过。返回新增条数。"""
|
|
182
233
|
cur = self.db.cursor()
|
|
234
|
+
added = self._insert_chunk_rows(cur, chunks, embeddings)
|
|
235
|
+
self.db.commit()
|
|
236
|
+
return added
|
|
237
|
+
|
|
238
|
+
def _insert_chunk_rows(self, cur, chunks: list[CodeChunk], embeddings: list[list[float]]) -> int:
|
|
239
|
+
"""插入 chunk 相关全部行(chunks/vec/fts/edges),不 commit(由调用方控制事务)。"""
|
|
240
|
+
if len(chunks) != len(embeddings):
|
|
241
|
+
# zip 静默截断是数据完整性事故:供应商少返回向量时部分写入
|
|
242
|
+
# + 指纹照更新 = 永久静默缺索引。抱错让 replace_file 整体回滚,
|
|
243
|
+
# 下次 sync 重试该文件。
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"chunks({len(chunks)}) 与 embeddings({len(embeddings)}) 数量不一致,拒绝部分写入"
|
|
246
|
+
)
|
|
183
247
|
added = 0
|
|
184
248
|
for chunk, emb in zip(chunks, embeddings):
|
|
185
249
|
cur.execute(
|
|
@@ -219,12 +283,16 @@ class CodeStore:
|
|
|
219
283
|
(chunk_id, callee),
|
|
220
284
|
)
|
|
221
285
|
added += 1
|
|
222
|
-
self.db.commit()
|
|
223
286
|
return added
|
|
224
287
|
|
|
225
288
|
def delete_file(self, file_path: str) -> None:
|
|
226
289
|
"""删除某文件的所有代码块(向量 + 全文 + 元数据)。"""
|
|
227
290
|
cur = self.db.cursor()
|
|
291
|
+
self._delete_file_rows(cur, file_path)
|
|
292
|
+
self.db.commit()
|
|
293
|
+
|
|
294
|
+
def _delete_file_rows(self, cur, file_path: str) -> None:
|
|
295
|
+
"""删除某文件全部行,不 commit(由调用方控制事务)。"""
|
|
228
296
|
rows = cur.execute("SELECT id FROM chunks WHERE file_path = ?", (file_path,)).fetchall()
|
|
229
297
|
ids = [r[0] for r in rows]
|
|
230
298
|
if ids:
|
|
@@ -235,7 +303,37 @@ class CodeStore:
|
|
|
235
303
|
cur.execute(f"DELETE FROM fts_chunks_tri WHERE rowid IN ({marks})", ids)
|
|
236
304
|
cur.execute(f"DELETE FROM edges WHERE caller_id IN ({marks})", ids)
|
|
237
305
|
cur.execute("DELETE FROM chunks WHERE file_path = ?", (file_path,))
|
|
238
|
-
|
|
306
|
+
|
|
307
|
+
def replace_file(
|
|
308
|
+
self,
|
|
309
|
+
file_path: str,
|
|
310
|
+
chunks: list[CodeChunk],
|
|
311
|
+
embeddings: list[list[float]],
|
|
312
|
+
file_hash: str,
|
|
313
|
+
mtime: float = 0.0,
|
|
314
|
+
size: int = 0,
|
|
315
|
+
) -> int:
|
|
316
|
+
"""单事务原子替换一个文件的索引:删旧行 + 插新行 + 更新指纹。
|
|
317
|
+
|
|
318
|
+
任何一步失败整体回滚,不会出现「旧数据已删、新数据未写、指纹还在」
|
|
319
|
+
的静默丢失状态(旧实现 delete/add/set_hash 三次独立 commit,进程在
|
|
320
|
+
中间崩溃会让该文件永远不被重新索引)。
|
|
321
|
+
"""
|
|
322
|
+
cur = self.db.cursor()
|
|
323
|
+
try:
|
|
324
|
+
self._delete_file_rows(cur, file_path)
|
|
325
|
+
added = self._insert_chunk_rows(cur, chunks, embeddings)
|
|
326
|
+
cur.execute(
|
|
327
|
+
"INSERT INTO files (file_path, file_hash, mtime, size) VALUES (?, ?, ?, ?) "
|
|
328
|
+
"ON CONFLICT(file_path) DO UPDATE SET file_hash = excluded.file_hash, "
|
|
329
|
+
"mtime = excluded.mtime, size = excluded.size",
|
|
330
|
+
(file_path, file_hash, mtime, size),
|
|
331
|
+
)
|
|
332
|
+
self.db.commit()
|
|
333
|
+
except Exception:
|
|
334
|
+
self.db.rollback()
|
|
335
|
+
raise
|
|
336
|
+
return added
|
|
239
337
|
|
|
240
338
|
# ---------- 文件指纹(增量) ----------
|
|
241
339
|
|
|
@@ -166,7 +166,8 @@ class WorkspaceManager:
|
|
|
166
166
|
def _create_workspace(self, resolved: str) -> Workspace:
|
|
167
167
|
"""创建新的 workspace 实例。"""
|
|
168
168
|
db_path = self._db_path_for(resolved)
|
|
169
|
-
store = CodeStore(db_path, self.embedder.dim, dtype=self.embedder.output_dtype
|
|
169
|
+
store = CodeStore(db_path, self.embedder.dim, dtype=self.embedder.output_dtype,
|
|
170
|
+
embed_model=getattr(self.embedder, "model", ""))
|
|
170
171
|
# rerank 后端/模型由 Retriever 按 SCM_RERANK_BACKEND / SCM_RERANK_MODEL 自行选择
|
|
171
172
|
retriever = Retriever(store, self.embedder)
|
|
172
173
|
indexer = Indexer(resolved, store, self.embedder)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|