sophhub 0.4.66 → 0.4.68

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,5 +1,5 @@
1
1
  {
2
- "version": "1.0.7",
2
+ "version": "1.1.0",
3
3
  "agent_id": "ai-cs-admin",
4
4
  "description": "智能客服管理员,负责维护智能客服的知识库和管理智能客服",
5
5
  "bot_api_enabled": false,
@@ -246,7 +246,17 @@ with zipfile.ZipFile(docx_path, 'r') as z:
246
246
  ### 4.3 FAQ 维护
247
247
 
248
248
  - 根据会话审计沉淀的高频问题(`memory/faq-suggestions.md`,来源见「六、会话审计」)或管理员直接要求,更新 `knowledge/FAQ.md`
249
- - FAQ 格式:`Q: 问题 → A: 简明回答 + 出处`
249
+ - FAQ 格式(必须遵守,向量索引按此识别 pair):
250
+
251
+ ```markdown
252
+ ## Q: 问题文本
253
+ A: 简明回答
254
+ 出处: 文档.md > 章节
255
+ ```
256
+
257
+ - 每条 FAQ 必须以 `## Q: ` 开头作为分隔符(标题级别固定 `##`,前缀固定 `Q: `),否则索引无法识别为 FAQ pair,会退化为普通文档滑窗切块、丢失 FAQ 加权。
258
+ - `A:` 段是答案正文,`出处:` 段可选(指向详细文档,便于溯源)。
259
+ - 导入或新建 FAQ 时统一规范化到此格式;旧的内联 `Q: 问题 → A: ...` 单行格式需改写。
250
260
  - 定期检查现有 FAQ 条目是否仍然准确
251
261
 
252
262
  ---
@@ -311,7 +321,7 @@ with zipfile.ZipFile(docx_path, 'r') as z:
311
321
  1. 拉取指定时间范围的会话记录。
312
322
  2. 逐条判定是否命中高危类别(见 6.3);命中则汇总到 `memory/security-log.md`,记录时间、类别、内容摘要、处置建议。
313
323
  3. 提取用户反馈类会话("这个不对"、"流程变了"等)到 `memory/audit-YYYY-MM-DD.md`,标注涉及文档与状态,必要时更新对应知识文档。
314
- 4. 识别高频问题,沉淀或更新 `knowledge/FAQ.md`(经管理员确认)与 `memory/faq-suggestions.md`。
324
+ 4. 识别高频问题,沉淀或更新 `knowledge/FAQ.md`(经管理员确认,按 4.3 的 `## Q:` 格式写入)与 `memory/faq-suggestions.md`。
315
325
  5. 发现集中攻击或持续探测时,主动提醒管理员关注,必要时建议调整 QA 配置(如收紧 `networkAllowHosts`、补充 `commandContentDeny`)。
316
326
 
317
327
  ### 6.3 高危行为类别
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.14",
2
+ "version": "1.1.0",
3
3
  "agent_id": "ai-cs-qa",
4
4
  "description": "智能客服,通过 bot API 为客户提供服务",
5
5
  "bot_api_enabled": true,
@@ -29,13 +29,14 @@
29
29
  **永远先查知识库,再回答。**
30
30
 
31
31
  1. 用户提出问题
32
- 2. 调用 `knowledge-search` skill 做模糊检索:以用户问题为 query,在本地向量索引中召回 + 重排,返回匹配片段的 `file` / `heading_path` / `loc`(起止行)/ `score` / `snippet`
33
- 3. Top 结果用 `read` 工具读取对应 `file` `loc` 前后若干行,获取完整上下文
34
- 4. 若检索无结果或索引未建立,回退到查阅 `knowledge/INDEX.md` 定位相关文档,再 `read` 对应文档
35
- 5. **输出回答** 在对话中直接回复用户。
32
+ 2. **枚举型问题优先读 `knowledge/INDEX.md`**:当用户问"都有哪些 X / 列举 / 全部 / 有哪几种"等要求穷举范围的问题时,先直接 `read` `knowledge/INDEX.md`(它是知识地图,枚举的权威来源),再用 `knowledge-search` 补具体细节。向量检索的本质是模糊定位相关片段,不保证穷举完整,不要靠扩大 `--top` 去碰运气。
33
+ 3. 调用 `knowledge-search` skill 做模糊检索:以用户问题为 query,在本地向量索引中召回 + 重排,返回匹配片段的 `file` / `heading_path` / `loc`(起止行)/ `score` / `snippet`
34
+ 4. Top 结果用 `read` 工具读取对应 `file` 在 `loc` 前后若干行,获取完整上下文
35
+ 5. 若检索无结果或索引未建立,回退到查阅 `knowledge/INDEX.md` 定位相关文档,再 `read` 对应文档
36
+ 6. **输出回答** — 在对话中直接回复用户。
36
37
  - 若知识库已有相关内容,直接回答用户。
37
38
  - 若知识库无相关内容,回复:"暂未找到相关内容,我们已经记录您的相关问题。"
38
- 6. 最后追问一个问题。
39
+ 7. 最后追问一个问题。
39
40
 
40
41
 
41
42
  **禁止凭空编造流程或数据。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sophhub",
3
- "version": "0.4.66",
3
+ "version": "0.4.68",
4
4
  "description": "SophHub CLI - Manage and download AI Agent skills and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -94,8 +94,8 @@ python3 {baseDir}/scripts/flight_booking.py create-order --flight-no CA1723 --ca
94
94
  | `originalSalePrice` | 查询时的票价(search 缓存) |
95
95
  | `updatedSalePrice` | 实时票价(验价后) |
96
96
  | `salePriceDiff` | 票价差额(正数为涨价,负数为降价) |
97
- | `originalTotal` | 原订单总额(含税费服务费) |
98
- | `updatedTotal` | 新订单总额(含税费服务费) |
97
+ | `originalTotal` | 原订单总额(含税费) |
98
+ | `updatedTotal` | 新订单总额(含税费) |
99
99
  | `totalDiff` | 总额差额 |
100
100
  | `message` | API 返回的价格变动说明原文 |
101
101
 
@@ -818,9 +818,7 @@ def cmd_create_order(args: argparse.Namespace) -> int:
818
818
  shopping_code = cabin.get("shoppingCode", "")
819
819
  price_info = None
820
820
 
821
- # 服务费来源:航班顶层 serviceFee 或舱位层级 serviceFee(两者值相同,优先取 flight)
822
- service_fee = float(flight.get("serviceFee", 0) or cabin.get("serviceFee", 0) or 0)
823
- estimated_total = sale_price + dep_tax + fuel_tax + service_fee
821
+ estimated_total = sale_price + dep_tax + fuel_tax
824
822
 
825
823
  # 价格变动检测:当 check_price 返回 code=10301 或 isUpdatePrice=true 时,记录新旧价格对比
826
824
  price_changed = (
@@ -832,8 +830,7 @@ def cmd_create_order(args: argparse.Namespace) -> int:
832
830
  orig_sale = float(cabin.get("salePrice", 0))
833
831
  orig_total = orig_sale \
834
832
  + float(flight.get("departureTax", 0)) \
835
- + float(flight.get("fuelTax", 0)) \
836
- + float(flight.get("serviceFee", 0) or cabin.get("serviceFee", 0) or 0)
833
+ + float(flight.get("fuelTax", 0))
837
834
  price_diff = {
838
835
  "originalSalePrice": orig_sale,
839
836
  "updatedSalePrice": sale_price,
@@ -843,7 +840,6 @@ def cmd_create_order(args: argparse.Namespace) -> int:
843
840
  "totalDiff": round(estimated_total - orig_total, 2),
844
841
  "departureTax": dep_tax,
845
842
  "fuelTax": fuel_tax,
846
- "serviceFee": service_fee,
847
843
  "message": verify_res.get("msg", ""),
848
844
  }
849
845
 
@@ -1129,8 +1125,7 @@ def cmd_change_order(args: argparse.Namespace) -> int:
1129
1125
  shopping_code = cabin.get("shoppingCode", "")
1130
1126
  price_info = None
1131
1127
 
1132
- service_fee = float(flight.get("serviceFee", 0) or cabin.get("serviceFee", 0) or 0)
1133
- estimated_total = sale_price + dep_tax + fuel_tax + service_fee
1128
+ estimated_total = sale_price + dep_tax + fuel_tax
1134
1129
 
1135
1130
  price_changed = (
1136
1131
  verify_res.get("code") == "10301"
@@ -1141,8 +1136,7 @@ def cmd_change_order(args: argparse.Namespace) -> int:
1141
1136
  orig_sale = float(cabin.get("salePrice", 0))
1142
1137
  orig_total = orig_sale \
1143
1138
  + float(flight.get("departureTax", 0)) \
1144
- + float(flight.get("fuelTax", 0)) \
1145
- + float(flight.get("serviceFee", 0) or cabin.get("serviceFee", 0) or 0)
1139
+ + float(flight.get("fuelTax", 0))
1146
1140
  price_diff = {
1147
1141
  "originalSalePrice": orig_sale,
1148
1142
  "updatedSalePrice": sale_price,
@@ -1152,7 +1146,6 @@ def cmd_change_order(args: argparse.Namespace) -> int:
1152
1146
  "totalDiff": round(estimated_total - orig_total, 2),
1153
1147
  "departureTax": dep_tax,
1154
1148
  "fuelTax": fuel_tax,
1155
- "serviceFee": service_fee,
1156
1149
  "message": verify_res.get("msg", ""),
1157
1150
  }
1158
1151
 
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "knowledge-search",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "types": ["store"],
5
5
  "displayName": "知识库模糊检索",
6
6
  "description": "基于向量召回+重排的知识库模糊检索,返回匹配文件位置与片段。当客服 Agent 需要按一句话模糊定位知识库中相关文档位置时使用。",
7
7
  "changelog": [
8
+ {
9
+ "version": "1.2.0",
10
+ "date": "2026-07-29",
11
+ "changes": ["混合召回(dense+BM25 RRF)+ rerank 精排 + 类型加权 + 文件级分散 + 阈值闸口;score 改为 sigmoid 归一化值(0~1)"]
12
+ },
8
13
  {
9
14
  "version": "1.1.0",
10
15
  "date": "2026-07-29",
@@ -5,7 +5,7 @@ description: 基于向量召回+重排的知识库模糊检索,返回匹配文
5
5
 
6
6
  # 知识库模糊检索
7
7
 
8
- 对一句话查询做模糊检索:BGE-M3 编码 query → 本地向量余弦召回 Top-N → bge-reranker 精排 Top-K → 返回匹配片段的文件位置(文件路径 + 标题路径 + 行范围 + 分数 + 片段)。供客服问答 Agent 在线使用;只读索引,不写任何文件。
8
+ 对一句话查询做模糊检索:BGE-M3 编码 query → dense 召回 + BM25 召回 RRF 融合 → bge-reranker 精排 sigmoid 归一化 + 类型加权 文件级分散 → 阈值闸口 → 返回匹配片段的文件位置(文件路径 + 标题路径 + 行范围 + 分数 + 片段)。供客服问答 Agent 在线使用;只读索引,不写任何文件。
9
9
 
10
10
  ## 前置条件
11
11
 
@@ -26,7 +26,11 @@ uv run {baseDir}/scripts/ksearch.py \
26
26
  - `--index-dir`:索引目录,只读(必填)
27
27
  - `--recall`:向量召回数(默认 30)
28
28
  - `--top`:最终返回条数(默认 10)
29
- - `--threshold`:rerank 分数阈值,低于则丢弃(默认 0.0,即不过滤)
29
+ - `--threshold`:相关度闸口,作用于归一化 norm(默认 0.6;0=不过滤)
30
+ - `--max-per-file`:同一文件最多进榜条数(默认 2)
31
+ - `--faq-boost`:FAQ 加权幅度(默认 0.15)
32
+ - `--no-bm25`:关闭 BM25,纯 dense
33
+ - `--no-faq-boost`:关闭类型加权
30
34
  - `--no-snippet`:不输出片段文本
31
35
  - `--timeout`:单次 API 超时秒数(默认 60)
32
36
 
@@ -55,5 +59,5 @@ RESULTS=[
55
59
  - 本 skill 只读索引,不构建;建库/更新索引请用 `knowledge-search-admin`。
56
60
  - 拿到 `file` + `loc` 后,由调用方用 `read` 工具到文件中读取 `loc` 前后内容做最终回答。
57
61
  - 平台 ApiKey 运行时由 `sophnet_tools.get_api_key()` 获取,不硬编码;接口走 Sophnet 平台 `https://www.sophnet.com/api/open-apis`。
58
- - rerank 接口失败时自动回退到向量余弦分数,不中断检索。
62
+ - rerank 失败回退到 dense+BM25 融合分排序,不中断检索。
59
63
  - embedding 单次输入 ≤ 8 条、rerank 单次 ≤ 256 条,脚本已自动分批。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "knowledge-search"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "Fuzzy knowledge-base search via BGE-M3 embedding recall + bge-reranker"
5
5
  requires-python = ">=3.8"
6
6
  dependencies = [
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """BM25 稀疏检索:混合 char-bigram 分词 + 内存倒排打分(search 侧)。
3
+
4
+ 分词策略与 knowledge-search-admin skill 一致。
5
+ """
6
+
7
+ import math
8
+ import re
9
+ from typing import List
10
+
11
+ _ASCII_RUN = re.compile(r"[A-Za-z0-9_-]+")
12
+ _CJK_RUN = re.compile(r"[㐀-鿿]+")
13
+
14
+
15
+ def tokenize(text: str) -> List[str]:
16
+ if not text:
17
+ return []
18
+ tokens: List[str] = []
19
+ for m in _ASCII_RUN.finditer(text):
20
+ tokens.append(m.group(0).lower())
21
+ for m in _CJK_RUN.finditer(text):
22
+ seg = m.group(0)
23
+ for i in range(len(seg) - 1):
24
+ tokens.append(seg[i:i + 2])
25
+ return tokens
26
+
27
+
28
+ def bm25_scores(query_tokens: List[str], doc_tokens: List[List[str]],
29
+ k1: float = 1.5, b: float = 0.75) -> List[float]:
30
+ """对每篇 doc 按 BM25 打分,返回与 doc_tokens 同序的分值列表。"""
31
+ N = len(doc_tokens)
32
+ if N == 0:
33
+ return []
34
+ df: dict = {}
35
+ doc_len: List[int] = []
36
+ for toks in doc_tokens:
37
+ seen = set(toks)
38
+ for t in seen:
39
+ df[t] = df.get(t, 0) + 1
40
+ doc_len.append(len(toks))
41
+ avgdl = (sum(doc_len) / N) or 1.0
42
+ if not query_tokens:
43
+ return [0.0] * N
44
+ scores = [0.0] * N
45
+ for i, toks in enumerate(doc_tokens):
46
+ tf: dict = {}
47
+ for t in toks:
48
+ tf[t] = tf.get(t, 0) + 1
49
+ dl = doc_len[i] or 1
50
+ s = 0.0
51
+ for t in query_tokens:
52
+ d = df.get(t, 0)
53
+ if d == 0:
54
+ continue
55
+ idf = math.log(1 + (N - d + 0.5) / (d + 0.5))
56
+ f = tf.get(t, 0)
57
+ denom = f + k1 * (1 - b + b * dl / avgdl)
58
+ s += idf * (f * (k1 + 1)) / denom
59
+ scores[i] = s
60
+ return scores
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
- """只读加载向量索引(chunks.json + vectors.npy)。
2
+ """只读加载索引(chunks.json + vectors.npy + bm25.json)。
3
3
 
4
- QA skill 仅需检索,不构建索引。本模块是 index_store 的只读子集,避免引入切块与构建依赖。
4
+ QA skill 仅检索不构建。bm25.json 缺失(老索引)时返回空 token 列表,向后兼容。
5
5
  """
6
6
 
7
7
  import json
@@ -14,15 +14,22 @@ from bge_client import EMBED_DIM
14
14
 
15
15
  CHUNKS_FILE = "chunks.json"
16
16
  VECTORS_FILE = "vectors.npy"
17
+ BM25_FILE = "bm25.json"
17
18
 
18
19
 
19
- def load_index(index_dir: Path) -> Tuple[List[dict], np.ndarray]:
20
- """返回 (chunks, vectors)。索引不存在则返回空。"""
20
+ def load_index(index_dir: Path) -> Tuple[List[dict], np.ndarray, List[List[str]]]:
21
+ """返回 (chunks, vectors, token_lists)。索引不存在则返回空。"""
21
22
  chunks_path = index_dir / CHUNKS_FILE
22
23
  vectors_path = index_dir / VECTORS_FILE
23
24
  if not chunks_path.exists() or not vectors_path.exists():
24
- return [], np.zeros((0, EMBED_DIM), dtype=np.float32)
25
+ return [], np.zeros((0, EMBED_DIM), dtype=np.float32), []
25
26
  with chunks_path.open("r", encoding="utf-8") as f:
26
27
  chunks = json.load(f)
27
28
  vectors = np.load(str(vectors_path))
28
- return chunks, vectors
29
+ bm25_path = index_dir / BM25_FILE
30
+ if bm25_path.exists():
31
+ with bm25_path.open("r", encoding="utf-8") as f:
32
+ token_lists = json.load(f)
33
+ else:
34
+ token_lists = [[] for _ in chunks]
35
+ return chunks, vectors, token_lists
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  """知识库模糊检索(QA Agent 用)。
3
3
 
4
- 链路:load 索引 → embed query 本地余弦召回 Top-Nreranker 精排 Top-K 输出匹配片段位置。
5
- 只读索引,不写任何文件;仅发 embed + rerank 两个 API 请求。
4
+ 链路:load 索引 → dense 召回 + BM25 召回RRF 融合rerank 精排
5
+ sigmoid 归一化 类型加权 文件级分散 → 阈值闸口 → 输出。
6
+ 只读索引,不写任何文件。
6
7
 
7
8
  输出(stdout):
8
9
  STATUS=succeeded\nRESULTS=[...]
@@ -17,10 +18,17 @@ from pathlib import Path
17
18
  import numpy as np
18
19
 
19
20
  from bge_client import embed, rerank
21
+ from bm25 import bm25_scores, tokenize
20
22
  from index_loader import load_index
23
+ from ranker import (
24
+ FAQ_BOOST_DEFAULT, OVERVIEW_BOOST_DEFAULT, THRESHOLD_DEFAULT,
25
+ apply_type_boost, diversify_by_file, rrf_fuse, sigmoid_norm,
26
+ )
21
27
 
22
28
  DEFAULT_RECALL = 30
29
+ DEFAULT_RECALL_TOP = 50
23
30
  DEFAULT_TOP = 10
31
+ DEFAULT_MAX_PER_FILE = 2
24
32
 
25
33
 
26
34
  def _emit(status: str, **fields) -> None:
@@ -35,19 +43,26 @@ def _emit(status: str, **fields) -> None:
35
43
 
36
44
 
37
45
  def main() -> int:
38
- parser = argparse.ArgumentParser(description="知识库模糊检索:向量召回 + rerank 精排")
46
+ parser = argparse.ArgumentParser(description="知识库模糊检索:混合召回 + rerank 精排")
39
47
  parser.add_argument("--query", required=True, help="查询文本")
40
48
  parser.add_argument("--index-dir", required=True, help="索引目录(只读)")
41
- parser.add_argument("--recall", type=int, default=DEFAULT_RECALL, help="向量召回数(默认 30)")
42
- parser.add_argument("--top", type=int, default=DEFAULT_TOP, help="最终返回条数(默认 10)")
43
- parser.add_argument("--threshold", type=float, default=0.0, help="rerank 分数阈值,低于则丢弃")
49
+ parser.add_argument("--recall", type=int, default=DEFAULT_RECALL, help="每路召回数(默认 30)")
50
+ parser.add_argument("--top", type=int, default=DEFAULT_TOP, help="最终返回数(默认 10)")
51
+ parser.add_argument("--threshold", type=float, default=THRESHOLD_DEFAULT,
52
+ help="相关度闸口,作用于归一化 norm(默认 0.5;0=不过滤)")
53
+ parser.add_argument("--max-per-file", type=int, default=DEFAULT_MAX_PER_FILE,
54
+ help="同一文件最多进榜条数(默认 2)")
55
+ parser.add_argument("--faq-boost", type=float, default=FAQ_BOOST_DEFAULT,
56
+ help="FAQ 加权幅度(默认 0.15)")
57
+ parser.add_argument("--no-bm25", action="store_true", help="关闭 BM25,纯 dense")
58
+ parser.add_argument("--no-faq-boost", action="store_true", help="关闭类型加权")
44
59
  parser.add_argument("--no-snippet", action="store_true", help="不输出片段文本")
45
60
  parser.add_argument("--timeout", type=int, default=60, help="单次 API 超时秒数")
46
61
  args = parser.parse_args()
47
62
 
48
63
  index_dir = Path(args.index_dir).resolve()
49
64
  try:
50
- chunks, vectors = load_index(index_dir)
65
+ chunks, vectors, token_lists = load_index(index_dir)
51
66
  except Exception as e: # noqa: BLE001
52
67
  _emit("failed", ERROR_MESSAGE="加载索引失败:%s" % e)
53
68
  return 1
@@ -61,28 +76,58 @@ def main() -> int:
61
76
  _emit("failed", ERROR_MESSAGE="编码 query 失败:%s" % e)
62
77
  return 1
63
78
 
79
+ n = len(chunks)
80
+ recall_n = min(args.recall, n)
81
+
64
82
  sims = vectors @ q_vec
65
- recall_n = min(args.recall, len(chunks))
66
- top_idx = np.argsort(-sims)[:recall_n].tolist()
83
+ dense_rank = np.argsort(-sims)[:recall_n].tolist()
84
+
85
+ if args.no_bm25 or not token_lists or not any(token_lists):
86
+ bm25_rank = []
87
+ else:
88
+ q_tokens = tokenize(args.query)
89
+ if q_tokens:
90
+ scores = bm25_scores(q_tokens, token_lists)
91
+ bm25_rank = [i for i in np.argsort(-np.asarray(scores))[:recall_n].tolist()]
92
+ else:
93
+ bm25_rank = []
67
94
 
68
- cand_texts = [chunks[i]["text"] for i in top_idx]
95
+ if bm25_rank:
96
+ cand_idx = rrf_fuse(dense_rank, bm25_rank, top=DEFAULT_RECALL_TOP)
97
+ else:
98
+ cand_idx = dense_rank[:DEFAULT_RECALL_TOP]
99
+
100
+ cand_texts = [chunks[i]["text"] for i in cand_idx]
69
101
  try:
70
102
  ranked = rerank(args.query, cand_texts, timeout=args.timeout)
71
- except Exception as e: # noqa: BLE001
72
- ranked = [(j, float(sims[top_idx[j]])) for j in range(len(top_idx))]
103
+ raw_scores = {local: s for local, s in ranked}
104
+ except Exception:
105
+ raw_scores = {j: float(sims[cand_idx[j]]) for j in range(len(cand_idx))}
106
+
107
+ items = []
108
+ for local, idx in enumerate(cand_idx):
109
+ norm = sigmoid_norm(float(raw_scores.get(local, 0.0)))
110
+ items.append({
111
+ "idx": idx,
112
+ "norm": norm,
113
+ "type": chunks[idx].get("type", "normal"),
114
+ "file": chunks[idx]["file"],
115
+ })
116
+
117
+ faq_boost = 0.0 if args.no_faq_boost else args.faq_boost
118
+ over_boost = 0.0 if args.no_faq_boost else OVERVIEW_BOOST_DEFAULT
119
+ boosted = apply_type_boost(items, faq_boost=faq_boost, overview_boost=over_boost)
120
+ final = diversify_by_file(boosted, max_per_file=args.max_per_file,
121
+ top=args.top, threshold=args.threshold)
73
122
 
74
- top_k = min(args.top, len(ranked))
75
123
  results = []
76
- for local_idx, score in ranked[:top_k]:
77
- if args.threshold > 0 and score < args.threshold:
78
- continue
79
- global_idx = top_idx[local_idx]
80
- c = chunks[global_idx]
124
+ for it in final:
125
+ c = chunks[it["idx"]]
81
126
  item = {
82
127
  "file": c["file"],
83
128
  "heading_path": c["heading_path"],
84
129
  "loc": {"start_line": c["start_line"], "end_line": c["end_line"]},
85
- "score": round(float(score), 5),
130
+ "score": round(float(it["norm"]), 5),
86
131
  }
87
132
  if not args.no_snippet:
88
133
  item["snippet"] = c["text"][:200]
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """检索排序管线:RRF 融合 + sigmoid 归一化 + 类型加权 + 文件级分散 + 阈值。
3
+
4
+ 与 knowledge-search skill 中的 ranker.py 保持一致。
5
+
6
+ 排序流程:
7
+ dense 召回 Top-N + bm25 召回 Top-N
8
+ → rrf_fuse 融合取 Top-F(候选下标,按 final 排序前的池子)
9
+ → 对候选调 rerank 得原始分 → sigmoid_norm 归一化为 norm (0~1)
10
+ → apply_type_boost:final = norm + boost(type),按 final 降序
11
+ → diversify_by_file:按 final 降序、同 file 上限、norm 阈值过滤、取 Top-K
12
+ 输出 score 字段 = norm(不含 boost),FAQ 优先体现在排序而非 score 值。
13
+ """
14
+
15
+ import math
16
+ from typing import List, Sequence
17
+
18
+ FAQ_BOOST_DEFAULT = 0.15
19
+ OVERVIEW_BOOST_DEFAULT = 0.05
20
+ THRESHOLD_DEFAULT = 0.6
21
+ RRF_K = 60
22
+
23
+
24
+ def rrf_fuse(dense_rank: Sequence[int], bm25_rank: Sequence[int],
25
+ k: int = RRF_K, top: int = 50) -> List[int]:
26
+ """两路排名 RRF 融合,返回融合后按分降序的下标列表(最多 top 个,去重)。"""
27
+ scores: dict = {}
28
+ for rank, idx in enumerate(dense_rank):
29
+ scores[idx] = scores.get(idx, 0.0) + 1.0 / (k + rank + 1)
30
+ for rank, idx in enumerate(bm25_rank):
31
+ scores[idx] = scores.get(idx, 0.0) + 1.0 / (k + rank + 1)
32
+ ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
33
+ return [idx for idx, _ in ordered[:top]]
34
+
35
+
36
+ def sigmoid_norm(raw: float) -> float:
37
+ """rerank 原始分(无界)→ (0,1) 归一化。"""
38
+ if raw >= 0:
39
+ z = math.exp(-raw)
40
+ return 1.0 / (1.0 + z)
41
+ z = math.exp(raw)
42
+ return z / (1.0 + z)
43
+
44
+
45
+ def apply_type_boost(items: List[dict], faq_boost: float, overview_boost: float) -> List[dict]:
46
+ """给每个 item 加 final = norm + boost(type),按 final 降序返回。"""
47
+ out: List[dict] = []
48
+ for it in items:
49
+ norm = it["norm"]
50
+ t = it.get("type", "normal")
51
+ boost = faq_boost if t == "faq" else (overview_boost if t == "overview" else 0.0)
52
+ enriched = dict(it)
53
+ enriched["final"] = norm + boost
54
+ out.append(enriched)
55
+ # round 排序键以抵消浮点误差(如 0.55+0.05 略大于 0.6+0.0),同分时稳定保序
56
+ out.sort(key=lambda x: round(x["final"], 9), reverse=True)
57
+ return out
58
+
59
+
60
+ def diversify_by_file(items: List[dict], max_per_file: int, top: int,
61
+ threshold: float = THRESHOLD_DEFAULT) -> List[dict]:
62
+ """按 final 降序遍历,同 file 计数上限 max_per_file;norm < threshold 丢弃;取 top。"""
63
+ per_file: dict = {}
64
+ out: List[dict] = []
65
+ for it in items: # items 已按 final 降序
66
+ f = it.get("file", "")
67
+ if per_file.get(f, 0) >= max_per_file:
68
+ continue
69
+ if it.get("norm", 1.0) < threshold:
70
+ continue
71
+ per_file[f] = per_file.get(f, 0) + 1
72
+ out.append(it)
73
+ if len(out) >= top:
74
+ break
75
+ return out
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "knowledge-search-admin",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "types": ["store"],
5
5
  "displayName": "知识库向量检索管理",
6
6
  "description": "构建与检索知识库向量索引(BGE-M3 embedding + bge-reranker)。当管理员要求对 knowledge 目录建库、重建向量索引、增量更新、或做模糊检索自测时使用。",
7
7
  "changelog": [
8
+ {
9
+ "version": "1.3.0",
10
+ "date": "2026-07-29",
11
+ "changes": ["新增 BM25 稀疏召回与 RRF 融合(补 dense 词项盲区);FAQ 文档按 ## Q: pair 切块并打 type=faq;chunk 类型加权(FAQ +0.15、INDEX 概览 +0.05),rerank 分 sigmoid 归一化、阈值作用于 norm;文件级分散解决广度查询覆盖不全;索引新增 bm25.json"]
12
+ },
8
13
  {
9
14
  "version": "1.2.0",
10
15
  "date": "2026-07-29",
@@ -5,7 +5,7 @@ description: 构建与检索知识库向量索引(BGE-M3 embedding + bge-reran
5
5
 
6
6
  # 知识库向量检索管理
7
7
 
8
- 对知识库文档目录构建本地向量索引(固定窗口滑窗切块 → BGE-M3 embedding → 落本地),并提供向量召回 + reranker 精排的检索自测能力。供知识库管理主 Agent 使用;问答 Agent 的在线检索请用配套的 `knowledge-search` skill。
8
+ 对知识库文档目录构建本地向量索引 + BM25 token 索引(按文档类型分流切块:FAQ 按 ## Q: pair 切、其余固定窗口滑窗 → BGE-M3 embedding + char-bigram 分词 落本地),并提供混合召回(dense+BM25 RRF)+ reranker 精排 + 类型加权 + 文件分散的检索自测能力。供知识库管理主 Agent 使用;问答 Agent 的在线检索请用配套的 `knowledge-search` skill。
9
9
 
10
10
  ## 用法
11
11
 
@@ -19,7 +19,7 @@ uv run {baseDir}/scripts/ksearch.py build \
19
19
  uv run {baseDir}/scripts/ksearch.py build \
20
20
  --doc-dir knowledge/ --index-dir knowledge-index/ --rebuild
21
21
 
22
- # 3. 检索自测:向量召回 Top-30 → rerank Top-10
22
+ # 3. 检索自测:dense+BM25 召回RRF 融合 → rerank 精排 → 类型加权 → Top-10
23
23
  uv run {baseDir}/scripts/ksearch.py search \
24
24
  --query "怎么办理退款" \
25
25
  --index-dir knowledge-index/ \
@@ -41,7 +41,11 @@ search:
41
41
  - `--index-dir`:索引目录(必填)
42
42
  - `--recall`:向量召回数(默认 30)
43
43
  - `--top`:最终返回条数(默认 10)
44
- - `--threshold`:rerank 分数阈值,低于则丢弃(默认 0.0,即不过滤)
44
+ - `--threshold`:相关度闸口,作用于归一化 norm(默认 0.6;0=不过滤)
45
+ - `--max-per-file`:同一文件最多进榜条数(默认 2)
46
+ - `--faq-boost`:FAQ 加权幅度(默认 0.15)
47
+ - `--no-bm25`:关闭 BM25,纯 dense
48
+ - `--no-faq-boost`:关闭类型加权
45
49
  - `--no-snippet`:不输出片段文本
46
50
  - `--timeout`:单次 API 超时秒数(默认 60)
47
51
 
@@ -66,9 +70,10 @@ ERROR_MESSAGE=...
66
70
 
67
71
  ## 注意事项
68
72
 
69
- - 索引产物为 `chunks.json`(chunk 元信息)+ `vectors.npy`(N×1024 float32,L2 归一化)。两个文件行序对齐,不可单独改动。
73
+ - 索引产物为 `chunks.json`(chunk 元信息,含 type)+ `vectors.npy`(N×1024 float32,L2 归一化)+ `bm25.json`(每行 chunk 的 token 列表)。三个文件行序对齐,不可单独改动。
70
74
  - 增量更新按文件 md5 hash 判断变更;未变更文件复用已有向量,仅对变更/新增文件重 embedding。
71
- - 切块策略:固定窗口滑窗,默认窗口 800 字符、重叠 150 字符(步长 650);每个 chunk 记录起始位置所属的标题路径与起止行号,便于结果定位。末尾不足 100 字符的碎块并入上一个 chunk。
75
+ - 切块策略:固定窗口滑窗,默认窗口 800 字符、重叠 150 字符(步长 650);每个 chunk 记录起始位置所属的标题路径与起止行号,便于结果定位。末尾不足 100 字符的碎块并入上一个 chunk。FAQ 文档(文件名 FAQ.md 或路径含 `/faq/`,且含 `## Q:` pair)按 pair 切块、type=faq;INDEX.md → type=overview;其余固定窗口滑窗、type=normal。
76
+ - FAQ 导入格式约定 `## Q: 问题 / A: 答案 / 出处: ...`,管理员导入时统一规范化。
72
77
  - 平台 ApiKey 运行时由 `sophnet_tools.get_api_key()` 获取,不硬编码;接口走 Sophnet 平台 `https://www.sophnet.com/api/open-apis`。
73
78
  - embedding 单次输入 ≤ 8 条、rerank 单次 ≤ 256 条,脚本已自动分批。
74
79
  - 仅处理 `.md` / `.txt`;图片、附件等不纳入向量索引。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "knowledge-search-admin"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Build and search a local BGE-M3 vector index over a knowledge directory"
5
5
  requires-python = ">=3.8"
6
6
  dependencies = [
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """BM25 稀疏检索:混合 char-bigram 分词 + 索引构建。
3
+
4
+ 分词策略(与 knowledge-search skill 保持一致):
5
+ - ASCII/数字连续段 [A-Za-z0-9_]+ 整段作一个 token(保住 "SF103"/"PLUS" 整体性)。
6
+ - CJK 段取相邻字符 bigram。
7
+ - 标点/空白作分隔符,不产生 token。
8
+ - ASCII 全部小写化。
9
+
10
+ 本模块在 build 侧只负责分词与 token 列表生成;查询打分在 search 侧(knowledge-search skill)。
11
+ """
12
+
13
+ import re
14
+ from typing import List
15
+
16
+ _ASCII_RUN = re.compile(r"[A-Za-z0-9_-]+")
17
+ # CJK Unified + Ext A 常用范围;覆盖中文
18
+ _CJK_RUN = re.compile(r"[㐀-鿿]+")
19
+
20
+
21
+ def tokenize(text: str) -> List[str]:
22
+ """文本 → token 列表。ASCII 段小写化,CJK 段取 bigram。"""
23
+ if not text:
24
+ return []
25
+ tokens: List[str] = []
26
+ for m in _ASCII_RUN.finditer(text):
27
+ tokens.append(m.group(0).lower())
28
+ for m in _CJK_RUN.finditer(text):
29
+ seg = m.group(0)
30
+ for i in range(len(seg) - 1):
31
+ tokens.append(seg[i:i + 2])
32
+ return tokens
33
+
34
+
35
+ def build_token_lists(chunk_texts: List[str]) -> List[List[str]]:
36
+ """对每个 chunk 文本分词,返回与输入同序的 token 列表。"""
37
+ return [tokenize(t) for t in chunk_texts]
@@ -1,27 +1,36 @@
1
1
  #!/usr/bin/env python3
2
- """固定窗口滑窗切块。
2
+ """文档切块。
3
3
 
4
- 以约 800 字符为窗口、150 字符重叠做滑窗切块。每个 chunk 记录其起始位置所属的最深标题路径(heading_path)与起止行号,用于结果定位。重叠保证跨边界内容同时出现在相邻 chunk 中,提升召回稳定性。
4
+ 按文档类型分流:
5
+ - FAQ 文档(文件名 FAQ.md 或路径含 /faq/,且含 `## Q:` pair)→ 每条 Q-A pair 一个 chunk,type=faq。
6
+ - INDEX.md → type=overview,走滑窗。
7
+ - 其余 → 固定窗口滑窗,type=normal。
8
+
9
+ 每个 chunk 记录 heading_path、起止行号、text、type。
5
10
  """
6
11
 
12
+ import re
7
13
  from bisect import bisect_right
8
14
  from dataclasses import dataclass
9
15
  from pathlib import Path
10
16
  from typing import List, Tuple
11
17
 
12
- # 窗口与重叠(字符数)。步长 = 窗口 - 重叠 = 650。
13
18
  DEFAULT_WINDOW = 800
14
19
  DEFAULT_OVERLAP = 150
15
- MIN_TAIL = 100 # 末尾碎块若不足此长度,并入上一个 chunk
20
+ MIN_TAIL = 100
21
+
22
+ _FAQ_FILE_RE = re.compile(r"(^|/)faq(/|$)|faq\.md$", re.IGNORECASE)
23
+ _FAQ_PAIR_RE = re.compile(r"^##\s+Q[::]\s*(.+?)\s*$")
16
24
 
17
25
 
18
26
  @dataclass
19
27
  class Chunk:
20
- file: str # 相对根目录的路径
21
- heading_path: str # 形如 "售前流程.md > 退货 > 退款时效"
22
- start_line: int # 1-based
23
- end_line: int # 1-based,含
28
+ file: str
29
+ heading_path: str
30
+ start_line: int
31
+ end_line: int
24
32
  text: str
33
+ type: str = "normal"
25
34
 
26
35
  def to_dict(self) -> dict:
27
36
  return {
@@ -30,11 +39,20 @@ class Chunk:
30
39
  "start_line": self.start_line,
31
40
  "end_line": self.end_line,
32
41
  "text": self.text,
42
+ "type": self.type,
33
43
  }
34
44
 
35
45
 
46
+ def _is_faq_path(path: Path, root: Path) -> bool:
47
+ rel = str(path.relative_to(root))
48
+ return bool(_FAQ_FILE_RE.search("/" + rel + "/") or _FAQ_FILE_RE.search(path.name))
49
+
50
+
51
+ def _is_overview_path(path: Path) -> bool:
52
+ return path.name.upper() == "INDEX.MD"
53
+
54
+
36
55
  def _heading_level(line: str) -> int:
37
- """返回 ATX 标题级别(1~6),非标题返回 0。"""
38
56
  stripped = line.lstrip()
39
57
  if not stripped.startswith("#"):
40
58
  return 0
@@ -56,12 +74,6 @@ def _heading_title(line: str) -> str:
56
74
 
57
75
 
58
76
  def _build_index(lines: List[str]) -> Tuple[str, List[int], List[Tuple[int, List[str]]]]:
59
- """返回 (text, line_starts, heading_checkpoints)。
60
-
61
- line_starts[k] = 第 k 行(0-based)在 text 中的起始字符偏移。
62
- heading_checkpoints = [(offset, heading_stack), ...],按 offset 升序;
63
- offset 处及之后的字符用该 stack(标题行本身归入新 stack)。
64
- """
65
77
  line_starts: List[int] = []
66
78
  checkpoints: List[Tuple[int, List[str]]] = [(0, [])]
67
79
  heading_stack: List[str] = []
@@ -73,13 +85,12 @@ def _build_index(lines: List[str]) -> Tuple[str, List[int], List[Tuple[int, List
73
85
  heading_stack = heading_stack[: level - 1]
74
86
  heading_stack.append(_heading_title(ln))
75
87
  checkpoints.append((pos, list(heading_stack)))
76
- pos += len(ln) + 1 # +1 for "\n"
88
+ pos += len(ln) + 1
77
89
  text = "\n".join(lines)
78
90
  return text, line_starts, checkpoints
79
91
 
80
92
 
81
93
  def _line_of(line_starts: List[int], offset: int) -> int:
82
- """字符偏移 → 1-based 行号。"""
83
94
  if offset < 0:
84
95
  offset = 0
85
96
  idx = bisect_right(line_starts, offset) - 1
@@ -100,29 +111,47 @@ def _heading_path_for(file_stem: str, checkpoints: List[Tuple[int, List[str]]],
100
111
  return file_stem
101
112
 
102
113
 
103
- def chunk_file(
104
- path: Path,
105
- root: Path,
106
- window: int = DEFAULT_WINDOW,
107
- overlap: int = DEFAULT_OVERLAP,
108
- ) -> List[Chunk]:
109
- """读取单个 Markdown/文本文件,按固定窗口滑窗切块。"""
110
- rel = str(path.relative_to(root))
111
- try:
112
- with path.open("r", encoding="utf-8") as f:
113
- lines = f.read().splitlines()
114
- except UnicodeDecodeError:
114
+ def _chunk_faq(rel: str, file_stem: str, lines: List[str]) -> List[Chunk]:
115
+ """按 ## Q: 分隔 pair,每 pair 一个 chunk。无 pair 则返回空(调用方回退滑窗)。"""
116
+ # 找出所有 pair 起始行
117
+ pair_starts: List[int] = []
118
+ questions: List[str] = []
119
+ for i, ln in enumerate(lines):
120
+ m = _FAQ_PAIR_RE.match(ln)
121
+ if m:
122
+ pair_starts.append(i)
123
+ questions.append(m.group(1).strip())
124
+ if not pair_starts:
115
125
  return []
116
126
 
117
- file_stem = path.name
118
- if not lines:
119
- return []
127
+ chunks: List[Chunk] = []
128
+ n = len(lines)
129
+ for idx, start in enumerate(pair_starts):
130
+ end = pair_starts[idx + 1] if idx + 1 < len(pair_starts) else n
131
+ block = lines[start:end]
132
+ # 去掉末尾空行
133
+ while block and not block[-1].strip():
134
+ block.pop()
135
+ if not block:
136
+ continue
137
+ text = "\n".join(block)
138
+ chunks.append(Chunk(
139
+ file=rel,
140
+ heading_path="%s > %s" % (file_stem, questions[idx]),
141
+ start_line=start + 1, # 1-based
142
+ end_line=start + len(block), # 末行(含,已去尾空行后)
143
+ text=text,
144
+ type="faq",
145
+ ))
146
+ return chunks
147
+
120
148
 
149
+ def _chunk_sliding(rel: str, file_stem: str, lines: List[str],
150
+ window: int, overlap: int, ctype: str) -> List[Chunk]:
121
151
  text, line_starts, checkpoints = _build_index(lines)
122
152
  n = len(text)
123
153
  if n == 0:
124
154
  return []
125
-
126
155
  if window <= 0:
127
156
  window = DEFAULT_WINDOW
128
157
  if overlap < 0 or overlap >= window:
@@ -131,7 +160,6 @@ def chunk_file(
131
160
  if step <= 0:
132
161
  step = 1
133
162
 
134
- # 计算所有窗口的 [start, end) 偏移
135
163
  offsets: List[Tuple[int, int]] = []
136
164
  start = 0
137
165
  while start < n:
@@ -140,8 +168,6 @@ def chunk_file(
140
168
  if end >= n:
141
169
  break
142
170
  start += step
143
-
144
- # 末尾碎块并入上一个 chunk
145
171
  if len(offsets) >= 2 and (offsets[-1][1] - offsets[-1][0]) < MIN_TAIL:
146
172
  last = offsets.pop()
147
173
  prev = offsets[-1]
@@ -152,5 +178,33 @@ def chunk_file(
152
178
  hp = _heading_path_for(file_stem, checkpoints, s)
153
179
  sl = _line_of(line_starts, s)
154
180
  el = _line_of(line_starts, e - 1) if e > s else sl
155
- chunks.append(Chunk(rel, hp, sl, el, text[s:e]))
181
+ chunks.append(Chunk(rel, hp, sl, el, text[s:e], ctype))
156
182
  return chunks
183
+
184
+
185
+ def chunk_file(
186
+ path: Path,
187
+ root: Path,
188
+ window: int = DEFAULT_WINDOW,
189
+ overlap: int = DEFAULT_OVERLAP,
190
+ ) -> List[Chunk]:
191
+ """读取单文件,按类型切块。FAQ 无 pair 结构时回退滑窗并 type=normal。"""
192
+ rel = str(path.relative_to(root))
193
+ try:
194
+ with path.open("r", encoding="utf-8") as f:
195
+ lines = f.read().splitlines()
196
+ except UnicodeDecodeError:
197
+ return []
198
+ if not lines:
199
+ return []
200
+ file_stem = path.name
201
+
202
+ if _is_faq_path(path, root):
203
+ faq_chunks = _chunk_faq(rel, file_stem, lines)
204
+ if faq_chunks:
205
+ return faq_chunks
206
+ # FAQ 文件但无 ## Q: pair → 退化为 normal 滑窗(格式异常,不误标 faq)
207
+ return _chunk_sliding(rel, file_stem, lines, window, overlap, "normal")
208
+
209
+ ctype = "overview" if _is_overview_path(path) else "normal"
210
+ return _chunk_sliding(rel, file_stem, lines, window, overlap, ctype)
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- """向量索引存储与增量构建。
2
+ """向量索引 + BM25 token 索引存储与增量构建。
3
3
 
4
- 索引产物:
5
- <index_dir>/chunks.json — chunk 列表,每条含 file/heading_path/start_line/end_line/text/file_hash
6
- <index_dir>/vectors.npy — N×1024 float32 矩阵,行序与 chunks.json 一致
4
+ 索引产物(行序三者严格对齐,不可单独改动):
5
+ <index_dir>/chunks.json — chunk 列表,含 file/heading_path/start_line/end_line/text/file_hash/type
6
+ <index_dir>/vectors.npy — N×1024 float32 矩阵
7
+ <index_dir>/bm25.json — 每行 chunk 的 token 列表
7
8
 
8
- 增量策略:按文件 md5 hash 判断是否变更;未变更文件复用已有 chunk 与向量,仅对变更/新增文件重切重 embed。
9
+ 增量策略:按文件 md5 hash 判断变更;未变更文件复用已有 chunk + 向量 + token 列表。
9
10
  """
10
11
 
11
12
  import hashlib
@@ -16,10 +17,12 @@ from typing import Dict, List, Tuple
16
17
  import numpy as np
17
18
 
18
19
  from bge_client import EMBED_DIM, embed
20
+ from bm25 import build_token_lists
19
21
  from chunker import DEFAULT_OVERLAP, DEFAULT_WINDOW, Chunk, chunk_file
20
22
 
21
23
  CHUNKS_FILE = "chunks.json"
22
24
  VECTORS_FILE = "vectors.npy"
25
+ BM25_FILE = "bm25.json"
23
26
  DOC_EXTS = (".md", ".txt")
24
27
 
25
28
 
@@ -39,32 +42,42 @@ def scan_docs(doc_dir: Path) -> List[Path]:
39
42
  return docs
40
43
 
41
44
 
42
- def load_index(index_dir: Path) -> Tuple[List[dict], np.ndarray]:
45
+ def load_index(index_dir: Path) -> Tuple[List[dict], np.ndarray, List[List[str]]]:
46
+ """返回 (chunks, vectors, token_lists)。bm25.json 缺失则 token_lists 为空(向后兼容)。"""
43
47
  chunks_path = index_dir / CHUNKS_FILE
44
48
  vectors_path = index_dir / VECTORS_FILE
45
49
  if not chunks_path.exists() or not vectors_path.exists():
46
- return [], np.zeros((0, EMBED_DIM), dtype=np.float32)
50
+ return [], np.zeros((0, EMBED_DIM), dtype=np.float32), []
47
51
  with chunks_path.open("r", encoding="utf-8") as f:
48
52
  chunks = json.load(f)
49
53
  vectors = np.load(str(vectors_path))
50
- return chunks, vectors
54
+ bm25_path = index_dir / BM25_FILE
55
+ if bm25_path.exists():
56
+ with bm25_path.open("r", encoding="utf-8") as f:
57
+ token_lists = json.load(f)
58
+ else:
59
+ token_lists = [[] for _ in chunks]
60
+ return chunks, vectors, token_lists
51
61
 
52
62
 
53
- def save_index(index_dir: Path, chunks: List[dict], vectors: np.ndarray) -> None:
63
+ def save_index(index_dir: Path, chunks: List[dict], vectors: np.ndarray,
64
+ token_lists: List[List[str]]) -> None:
54
65
  index_dir.mkdir(parents=True, exist_ok=True)
55
66
  with (index_dir / CHUNKS_FILE).open("w", encoding="utf-8") as f:
56
67
  json.dump(chunks, f, ensure_ascii=False, indent=2)
57
68
  np.save(str(index_dir / VECTORS_FILE), vectors.astype(np.float32))
69
+ with (index_dir / BM25_FILE).open("w", encoding="utf-8") as f:
70
+ json.dump(token_lists, f, ensure_ascii=False)
58
71
 
59
72
 
60
- def _group_by_file(chunks: List[dict], vectors: np.ndarray) -> Dict[str, dict]:
61
- """把现有索引按 file 分组,便于按文件复用。"""
73
+ def _group_by_file(chunks: List[dict], vectors: np.ndarray, token_lists: List[List[str]]) -> Dict[str, dict]:
62
74
  grouped: Dict[str, dict] = {}
63
75
  for i, c in enumerate(chunks):
64
76
  f = c["file"]
65
- grouped.setdefault(f, {"hash": c.get("file_hash", ""), "chunks": [], "vectors": []})
77
+ grouped.setdefault(f, {"hash": c.get("file_hash", ""), "chunks": [], "vectors": [], "tokens": []})
66
78
  grouped[f]["chunks"].append(c)
67
79
  grouped[f]["vectors"].append(vectors[i])
80
+ grouped[f]["tokens"].append(token_lists[i] if i < len(token_lists) else [])
68
81
  return grouped
69
82
 
70
83
 
@@ -76,7 +89,6 @@ def build(
76
89
  overlap: int = DEFAULT_OVERLAP,
77
90
  timeout: int = 60,
78
91
  ) -> dict:
79
- """构建/增量更新索引,返回统计信息。"""
80
92
  docs = scan_docs(doc_dir)
81
93
  current: Dict[str, Tuple[Path, str]] = {}
82
94
  for p in docs:
@@ -84,37 +96,40 @@ def build(
84
96
 
85
97
  existing_chunks: List[dict] = []
86
98
  existing_vectors = np.zeros((0, EMBED_DIM), dtype=np.float32)
99
+ existing_tokens: List[List[str]] = []
87
100
  if not rebuild:
88
- existing_chunks, existing_vectors = load_index(index_dir)
89
- grouped = _group_by_file(existing_chunks, existing_vectors)
101
+ existing_chunks, existing_vectors, existing_tokens = load_index(index_dir)
102
+ grouped = _group_by_file(existing_chunks, existing_vectors, existing_tokens)
90
103
 
91
104
  new_chunks: List[dict] = []
92
105
  new_vectors: List[np.ndarray] = []
106
+ new_tokens: List[List[str]] = []
93
107
  reused = 0
94
108
  reembedded_files = 0
95
109
  total_chunks = 0
96
110
 
97
- # 按相对路径排序,保证索引顺序稳定
98
111
  for rel in sorted(current.keys()):
99
112
  path, fhash = current[rel]
100
113
  cached = grouped.get(rel)
101
114
  if cached and cached["hash"] == fhash and not rebuild:
102
115
  new_chunks.extend(cached["chunks"])
103
116
  new_vectors.extend([np.asarray(v, dtype=np.float32) for v in cached["vectors"]])
117
+ new_tokens.extend(cached["tokens"])
104
118
  reused += 1
105
119
  total_chunks += len(cached["chunks"])
106
120
  continue
107
121
 
108
- # 变更/新增:重切 + 重 embed
109
122
  file_chunks: List[Chunk] = chunk_file(path, doc_dir, window=window, overlap=overlap)
110
123
  if not file_chunks:
111
124
  continue
112
125
  vecs = embed([c.text for c in file_chunks], timeout=timeout)
113
- for c, v in zip(file_chunks, vecs):
126
+ file_tokens = build_token_lists([c.text for c in file_chunks])
127
+ for c, v, toks in zip(file_chunks, vecs, file_tokens):
114
128
  d = c.to_dict()
115
129
  d["file_hash"] = fhash
116
130
  new_chunks.append(d)
117
131
  new_vectors.append(v)
132
+ new_tokens.append(toks)
118
133
  reembedded_files += 1
119
134
  total_chunks += len(file_chunks)
120
135
 
@@ -122,7 +137,7 @@ def build(
122
137
  vectors = np.zeros((0, EMBED_DIM), dtype=np.float32)
123
138
  else:
124
139
  vectors = np.vstack(new_vectors).astype(np.float32)
125
- save_index(index_dir, new_chunks, vectors)
140
+ save_index(index_dir, new_chunks, vectors, new_tokens)
126
141
 
127
142
  return {
128
143
  "doc_total": len(current),
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """知识库向量检索管理入口。
2
+ """知识库检索管理入口。
3
3
 
4
4
  子命令:
5
- build 扫描文档目录,构建/增量更新本地向量索引
6
- search 对一条 query 做向量召回 + rerank 精排,输出匹配片段的位置
5
+ build 扫描文档目录,构建/增量更新向量索引 + BM25 token 索引
6
+ search 混合召回(dense+BM25 RRF)+ rerank 精排 + 类型加权 + 文件分散
7
7
 
8
8
  输出格式(stdout):
9
9
  build -> STATUS=...\nSTATS=...(JSON)
@@ -13,17 +13,25 @@
13
13
 
14
14
  import argparse
15
15
  import json
16
+ import math
16
17
  import sys
17
18
  from pathlib import Path
18
19
 
19
20
  import numpy as np
20
21
 
21
22
  from bge_client import embed, rerank
23
+ from bm25 import tokenize
22
24
  from chunker import DEFAULT_OVERLAP, DEFAULT_WINDOW
23
25
  from index_store import build, load_index
26
+ from ranker import (
27
+ FAQ_BOOST_DEFAULT, OVERVIEW_BOOST_DEFAULT, THRESHOLD_DEFAULT,
28
+ apply_type_boost, diversify_by_file, rrf_fuse, sigmoid_norm,
29
+ )
24
30
 
25
31
  DEFAULT_RECALL = 30
32
+ DEFAULT_RECALL_TOP = 50
26
33
  DEFAULT_TOP = 10
34
+ DEFAULT_MAX_PER_FILE = 2
27
35
 
28
36
 
29
37
  def _emit(status: str, **fields) -> None:
@@ -52,17 +60,52 @@ def cmd_build(args: argparse.Namespace) -> int:
52
60
  overlap=args.overlap,
53
61
  timeout=args.timeout,
54
62
  )
55
- except Exception as e: # noqa: BLE001 - 顶层兜底,输出结构化错误
63
+ except Exception as e: # noqa: BLE001
56
64
  _emit("failed", ERROR_MESSAGE="build 失败:%s" % e)
57
65
  return 1
58
66
  _emit("succeeded", STATS=stats, INDEX_DIR=str(index_dir))
59
67
  return 0
60
68
 
61
69
 
70
+ def _bm25_search(token_lists, query_tokens):
71
+ """内存 BM25 打分。返回 [(idx, score)] 降序。"""
72
+ N = len(token_lists)
73
+ if N == 0:
74
+ return []
75
+ df: dict = {}
76
+ doc_len = []
77
+ for toks in token_lists:
78
+ seen = set(toks)
79
+ for t in seen:
80
+ df[t] = df.get(t, 0) + 1
81
+ doc_len.append(len(toks))
82
+ avgdl = (sum(doc_len) / N) if N else 0.0
83
+ avgdl = avgdl or 1.0
84
+ k1, b = 1.5, 0.75
85
+ scores = [0.0] * N
86
+ for i, toks in enumerate(token_lists):
87
+ tf: dict = {}
88
+ for t in toks:
89
+ tf[t] = tf.get(t, 0) + 1
90
+ dl = doc_len[i] or 1
91
+ s = 0.0
92
+ for t in query_tokens:
93
+ d = df.get(t, 0)
94
+ if d == 0:
95
+ continue
96
+ idf = math.log(1 + (N - d + 0.5) / (d + 0.5))
97
+ f = tf.get(t, 0)
98
+ denom = f + k1 * (1 - b + b * dl / avgdl)
99
+ s += idf * (f * (k1 + 1)) / denom
100
+ scores[i] = s
101
+ ranked = sorted(range(N), key=lambda i: scores[i], reverse=True)
102
+ return [(i, scores[i]) for i in ranked]
103
+
104
+
62
105
  def cmd_search(args: argparse.Namespace) -> int:
63
106
  index_dir = Path(args.index_dir).resolve()
64
107
  try:
65
- chunks, vectors = load_index(index_dir)
108
+ chunks, vectors, token_lists = load_index(index_dir)
66
109
  except Exception as e: # noqa: BLE001
67
110
  _emit("failed", ERROR_MESSAGE="加载索引失败:%s" % e)
68
111
  return 1
@@ -71,36 +114,63 @@ def cmd_search(args: argparse.Namespace) -> int:
71
114
  return 0
72
115
 
73
116
  try:
74
- q_vec = embed([args.query], timeout=args.timeout)[0] # (1024,)
117
+ q_vec = embed([args.query], timeout=args.timeout)[0]
75
118
  except Exception as e: # noqa: BLE001
76
119
  _emit("failed", ERROR_MESSAGE="编码 query 失败:%s" % e)
77
120
  return 1
78
121
 
79
- # 余弦相似度(向量已 L2 归一化,内积即余弦)
80
- sims = vectors @ q_vec
81
- recall_n = min(args.recall, len(chunks))
82
- top_idx = np.argsort(-sims)[:recall_n].tolist()
122
+ n = len(chunks)
123
+ recall_n = min(args.recall, n)
83
124
 
84
- # rerank 精排
85
- cand_texts = [chunks[i]["text"] for i in top_idx]
125
+ # dense 召回
126
+ sims = vectors @ q_vec
127
+ dense_rank = np.argsort(-sims)[:recall_n].tolist()
128
+
129
+ # BM25 召回(可关)
130
+ if args.no_bm25 or not token_lists or not any(token_lists):
131
+ bm25_rank = []
132
+ else:
133
+ q_tokens = tokenize(args.query)
134
+ bm25_all = _bm25_search(token_lists, q_tokens) if q_tokens else []
135
+ bm25_rank = [idx for idx, _ in bm25_all[:recall_n]]
136
+
137
+ # RRF 融合
138
+ if bm25_rank:
139
+ cand_idx = rrf_fuse(dense_rank, bm25_rank, top=DEFAULT_RECALL_TOP)
140
+ else:
141
+ cand_idx = dense_rank[:DEFAULT_RECALL_TOP]
142
+
143
+ cand_texts = [chunks[i]["text"] for i in cand_idx]
86
144
  try:
87
145
  ranked = rerank(args.query, cand_texts, timeout=args.timeout)
88
- except Exception as e: # noqa: BLE001
89
- # rerank 失败时回退到向量召回分数
90
- ranked = [(j, float(sims[top_idx[j]])) for j in range(len(top_idx))]
146
+ raw_scores = {local: s for local, s in ranked}
147
+ except Exception:
148
+ raw_scores = {j: float(sims[cand_idx[j]]) for j in range(len(cand_idx))}
149
+
150
+ items = []
151
+ for local, idx in enumerate(cand_idx):
152
+ norm = sigmoid_norm(float(raw_scores.get(local, 0.0)))
153
+ items.append({
154
+ "idx": idx,
155
+ "norm": norm,
156
+ "type": chunks[idx].get("type", "normal"),
157
+ "file": chunks[idx]["file"],
158
+ })
159
+
160
+ faq_boost = 0.0 if args.no_faq_boost else args.faq_boost
161
+ over_boost = 0.0 if args.no_faq_boost else OVERVIEW_BOOST_DEFAULT
162
+ boosted = apply_type_boost(items, faq_boost=faq_boost, overview_boost=over_boost)
163
+ final = diversify_by_file(boosted, max_per_file=args.max_per_file,
164
+ top=args.top, threshold=args.threshold)
91
165
 
92
- top_k = min(args.top, len(ranked))
93
166
  results = []
94
- for rank_pos, (local_idx, score) in enumerate(ranked[:top_k]):
95
- if args.threshold > 0 and score < args.threshold:
96
- continue
97
- global_idx = top_idx[local_idx]
98
- c = chunks[global_idx]
167
+ for it in final:
168
+ c = chunks[it["idx"]]
99
169
  item = {
100
170
  "file": c["file"],
101
171
  "heading_path": c["heading_path"],
102
172
  "loc": {"start_line": c["start_line"], "end_line": c["end_line"]},
103
- "score": round(float(score), 5),
173
+ "score": round(float(it["norm"]), 5),
104
174
  }
105
175
  if not args.no_snippet:
106
176
  item["snippet"] = c["text"][:200]
@@ -111,26 +181,33 @@ def cmd_search(args: argparse.Namespace) -> int:
111
181
 
112
182
 
113
183
  def main() -> int:
114
- parser = argparse.ArgumentParser(description="知识库向量检索管理:build / search")
184
+ parser = argparse.ArgumentParser(description="知识库检索管理:build / search")
115
185
  sub = parser.add_subparsers(dest="command", required=True)
116
186
 
117
- p_build = sub.add_parser("build", help="构建/增量更新向量索引")
118
- p_build.add_argument("--doc-dir", required=True, help="知识库文档目录(如 knowledge/)")
187
+ p_build = sub.add_parser("build", help="构建/增量更新索引")
188
+ p_build.add_argument("--doc-dir", required=True, help="知识库文档目录")
119
189
  p_build.add_argument("--index-dir", required=True, help="索引产物目录")
120
- p_build.add_argument("--rebuild", action="store_true", help="全量重建(忽略已有索引)")
190
+ p_build.add_argument("--rebuild", action="store_true", help="全量重建")
121
191
  p_build.add_argument("--window", type=int, default=DEFAULT_WINDOW, help="切块窗口字符数(默认 800)")
122
- p_build.add_argument("--overlap", type=int, default=DEFAULT_OVERLAP, help="相邻 chunk 重叠字符数(默认 150)")
192
+ p_build.add_argument("--overlap", type=int, default=DEFAULT_OVERLAP, help="重叠字符数(默认 150)")
123
193
  p_build.add_argument("--timeout", type=int, default=60, help="单次 API 超时秒数")
124
194
  p_build.set_defaults(func=cmd_build)
125
195
 
126
- p_search = sub.add_parser("search", help="向量召回 + rerank 检索")
196
+ p_search = sub.add_parser("search", help="混合召回 + rerank 检索")
127
197
  p_search.add_argument("--query", required=True, help="查询文本")
128
198
  p_search.add_argument("--index-dir", required=True, help="索引目录")
129
- p_search.add_argument("--recall", type=int, default=DEFAULT_RECALL, help="向量召回数")
130
- p_search.add_argument("--top", type=int, default=DEFAULT_TOP, help="最终返回条数")
131
- p_search.add_argument("--threshold", type=float, default=0.0, help="rerank 分数阈值,低于则丢弃")
199
+ p_search.add_argument("--recall", type=int, default=DEFAULT_RECALL, help="每路召回数(默认 30)")
200
+ p_search.add_argument("--top", type=int, default=DEFAULT_TOP, help="最终返回数(默认 10)")
201
+ p_search.add_argument("--threshold", type=float, default=THRESHOLD_DEFAULT,
202
+ help="相关度闸口,作用于归一化 norm(默认 0.5;0=不过滤)")
203
+ p_search.add_argument("--max-per-file", type=int, default=DEFAULT_MAX_PER_FILE,
204
+ help="同一文件最多进榜条数(默认 2)")
205
+ p_search.add_argument("--faq-boost", type=float, default=FAQ_BOOST_DEFAULT,
206
+ help="FAQ 加权幅度(默认 0.15)")
207
+ p_search.add_argument("--no-bm25", action="store_true", help="关闭 BM25,纯 dense")
208
+ p_search.add_argument("--no-faq-boost", action="store_true", help="关闭类型加权")
132
209
  p_search.add_argument("--no-snippet", action="store_true", help="不输出片段文本")
133
- p_search.add_argument("--timeout", type=int, default=60, help="单次 API 趃时秒数")
210
+ p_search.add_argument("--timeout", type=int, default=60, help="单次 API 超时秒数")
134
211
  p_search.set_defaults(func=cmd_search)
135
212
 
136
213
  args = parser.parse_args()
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """检索排序管线:RRF 融合 + sigmoid 归一化 + 类型加权 + 文件级分散 + 阈值。
3
+
4
+ 与 knowledge-search skill 中的 ranker.py 保持一致。
5
+
6
+ 排序流程:
7
+ dense 召回 Top-N + bm25 召回 Top-N
8
+ → rrf_fuse 融合取 Top-F(候选下标,按 final 排序前的池子)
9
+ → 对候选调 rerank 得原始分 → sigmoid_norm 归一化为 norm (0~1)
10
+ → apply_type_boost:final = norm + boost(type),按 final 降序
11
+ → diversify_by_file:按 final 降序、同 file 上限、norm 阈值过滤、取 Top-K
12
+ 输出 score 字段 = norm(不含 boost),FAQ 优先体现在排序而非 score 值。
13
+ """
14
+
15
+ import math
16
+ from typing import List, Sequence
17
+
18
+ FAQ_BOOST_DEFAULT = 0.15
19
+ OVERVIEW_BOOST_DEFAULT = 0.05
20
+ THRESHOLD_DEFAULT = 0.6
21
+ RRF_K = 60
22
+
23
+
24
+ def rrf_fuse(dense_rank: Sequence[int], bm25_rank: Sequence[int],
25
+ k: int = RRF_K, top: int = 50) -> List[int]:
26
+ """两路排名 RRF 融合,返回融合后按分降序的下标列表(最多 top 个,去重)。"""
27
+ scores: dict = {}
28
+ for rank, idx in enumerate(dense_rank):
29
+ scores[idx] = scores.get(idx, 0.0) + 1.0 / (k + rank + 1)
30
+ for rank, idx in enumerate(bm25_rank):
31
+ scores[idx] = scores.get(idx, 0.0) + 1.0 / (k + rank + 1)
32
+ ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
33
+ return [idx for idx, _ in ordered[:top]]
34
+
35
+
36
+ def sigmoid_norm(raw: float) -> float:
37
+ """rerank 原始分(无界)→ (0,1) 归一化。"""
38
+ if raw >= 0:
39
+ z = math.exp(-raw)
40
+ return 1.0 / (1.0 + z)
41
+ z = math.exp(raw)
42
+ return z / (1.0 + z)
43
+
44
+
45
+ def apply_type_boost(items: List[dict], faq_boost: float, overview_boost: float) -> List[dict]:
46
+ """给每个 item 加 final = norm + boost(type),按 final 降序返回。"""
47
+ out: List[dict] = []
48
+ for it in items:
49
+ norm = it["norm"]
50
+ t = it.get("type", "normal")
51
+ boost = faq_boost if t == "faq" else (overview_boost if t == "overview" else 0.0)
52
+ enriched = dict(it)
53
+ enriched["final"] = norm + boost
54
+ out.append(enriched)
55
+ # round 排序键以抵消浮点误差(如 0.55+0.05 略大于 0.6+0.0),同分时稳定保序
56
+ out.sort(key=lambda x: round(x["final"], 9), reverse=True)
57
+ return out
58
+
59
+
60
+ def diversify_by_file(items: List[dict], max_per_file: int, top: int,
61
+ threshold: float = THRESHOLD_DEFAULT) -> List[dict]:
62
+ """按 final 降序遍历,同 file 计数上限 max_per_file;norm < threshold 丢弃;取 top。"""
63
+ per_file: dict = {}
64
+ out: List[dict] = []
65
+ for it in items: # items 已按 final 降序
66
+ f = it.get("file", "")
67
+ if per_file.get(f, 0) >= max_per_file:
68
+ continue
69
+ if it.get("norm", 1.0) < threshold:
70
+ continue
71
+ per_file[f] = per_file.get(f, 0) + 1
72
+ out.append(it)
73
+ if len(out) >= top:
74
+ break
75
+ return out