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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: semcode-mcp
3
- Version: 0.1.0
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.0"
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
- 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
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
- # 最终分取 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
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
- q_emb = self.embedder.embed_query(query)
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
- subs = self._compound_subqueries(query)
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
- 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
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"] >= _COMPOUND_SUB_MIN_SCORE:
469
+ if s["score"] >= min_sub and sub_top > 0:
455
470
  c["score"] = max(
456
- c["score"], s["score"] * _COMPOUND_SUB_RERANK_MIX
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) >= _COMPOUND_SUB_MIN_SCORE
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