semcode-mcp 0.1.0__tar.gz → 0.1.1__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.1}/PKG-INFO +1 -1
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/pyproject.toml +1 -1
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/embedder.py +23 -8
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/retriever.py +54 -21
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/.env.example +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/.gitignore +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/LICENSE +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/README.md +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/npm/README.md +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/__init__.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/chunker.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/expander.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/indexer.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/server.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/store.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/watcher.py +0 -0
- {semcode_mcp-0.1.0 → semcode_mcp-0.1.1}/src/semantic_code_mcp/workspace.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.1
|
|
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
|
|
@@ -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.1"
|
|
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"
|
|
@@ -81,14 +81,25 @@ class Embedder:
|
|
|
81
81
|
|
|
82
82
|
def embed_query(self, text: str) -> list[float]:
|
|
83
83
|
"""检索用:embedding 单条查询(带 LRU 缓存)。"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
return self.embed_queries([text])[0]
|
|
85
|
+
|
|
86
|
+
def embed_queries(self, texts: list[str]) -> list[list[float]]:
|
|
87
|
+
"""检索用:批量 embedding 多条查询,与输入一一对应。
|
|
88
|
+
|
|
89
|
+
复合查询主查询 + 子查询合并成一次请求(省 N-1 次网络往返);
|
|
90
|
+
命中缓存的不重发,只有未命中子集上行。
|
|
91
|
+
"""
|
|
92
|
+
missing = [t for t in dict.fromkeys(texts) if t not in self._query_cache]
|
|
93
|
+
if missing:
|
|
94
|
+
for t, vec in zip(missing, self._embed(missing, "query")):
|
|
95
|
+
self._query_cache[t] = vec
|
|
96
|
+
if len(self._query_cache) > self._query_cache_max:
|
|
97
|
+
self._query_cache.popitem(last=False)
|
|
98
|
+
out: list[list[float]] = []
|
|
99
|
+
for t in texts:
|
|
100
|
+
self._query_cache.move_to_end(t)
|
|
101
|
+
out.append(self._query_cache[t])
|
|
102
|
+
return out
|
|
92
103
|
|
|
93
104
|
def _embed(self, texts: list[str], input_type: str) -> list[list[float]]:
|
|
94
105
|
results: list[list[float]] = []
|
|
@@ -183,6 +194,10 @@ class LocalEmbedder:
|
|
|
183
194
|
self._query_cache.popitem(last=False)
|
|
184
195
|
return result
|
|
185
196
|
|
|
197
|
+
def embed_queries(self, texts: list[str]) -> list[list[float]]:
|
|
198
|
+
"""与 Embedder.embed_queries 同接口;本地推理无网络往返,逐条等价。"""
|
|
199
|
+
return [self.embed_query(t) for t in texts]
|
|
200
|
+
|
|
186
201
|
|
|
187
202
|
def create_embedder(**kwargs):
|
|
188
203
|
"""按 SCM_EMBED_BACKEND 选择后端:voyage(默认)/ local。"""
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|