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.
- package/agents/ai-cs-admin/.config.json +1 -1
- package/agents/ai-cs-admin/AGENTS.md +12 -2
- package/agents/ai-cs-qa/.config.json +1 -1
- package/agents/ai-cs-qa/AGENTS.md +6 -5
- package/package.json +1 -1
- package/skills/flight-booking/src/SKILL.md +2 -2
- package/skills/flight-booking/src/scripts/flight_booking.py +4 -11
- package/skills/knowledge-search/skill.json +6 -1
- package/skills/knowledge-search/src/SKILL.md +7 -3
- package/skills/knowledge-search/src/pyproject.toml +1 -1
- package/skills/knowledge-search/src/scripts/bm25.py +60 -0
- package/skills/knowledge-search/src/scripts/index_loader.py +13 -6
- package/skills/knowledge-search/src/scripts/ksearch.py +64 -19
- package/skills/knowledge-search/src/scripts/ranker.py +75 -0
- package/skills/knowledge-search-admin/skill.json +6 -1
- package/skills/knowledge-search-admin/src/SKILL.md +10 -5
- package/skills/knowledge-search-admin/src/pyproject.toml +1 -1
- package/skills/knowledge-search-admin/src/scripts/bm25.py +37 -0
- package/skills/knowledge-search-admin/src/scripts/chunker.py +91 -37
- package/skills/knowledge-search-admin/src/scripts/index_store.py +34 -19
- package/skills/knowledge-search-admin/src/scripts/ksearch.py +109 -32
- package/skills/knowledge-search-admin/src/scripts/ranker.py +75 -0
|
@@ -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
|
|
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
|
|
324
|
+
4. 识别高频问题,沉淀或更新 `knowledge/FAQ.md`(经管理员确认,按 4.3 的 `## Q:` 格式写入)与 `memory/faq-suggestions.md`。
|
|
315
325
|
5. 发现集中攻击或持续探测时,主动提醒管理员关注,必要时建议调整 QA 配置(如收紧 `networkAllowHosts`、补充 `commandContentDeny`)。
|
|
316
326
|
|
|
317
327
|
### 6.3 高危行为类别
|
|
@@ -29,13 +29,14 @@
|
|
|
29
29
|
**永远先查知识库,再回答。**
|
|
30
30
|
|
|
31
31
|
1. 用户提出问题
|
|
32
|
-
2.
|
|
33
|
-
3.
|
|
34
|
-
4.
|
|
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
|
-
|
|
39
|
+
7. 最后追问一个问题。
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
**禁止凭空编造流程或数据。**
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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 →
|
|
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
|
|
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 条,脚本已自动分批。
|
|
@@ -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
|
-
"""
|
|
2
|
+
"""只读加载索引(chunks.json + vectors.npy + bm25.json)。
|
|
3
3
|
|
|
4
|
-
QA skill
|
|
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
|
-
|
|
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 索引 →
|
|
5
|
-
|
|
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="
|
|
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="
|
|
42
|
-
parser.add_argument("--top", type=int, default=DEFAULT_TOP, help="
|
|
43
|
-
parser.add_argument("--threshold", type=float, default=
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
77
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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`;图片、附件等不纳入向量索引。
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
22
|
-
start_line: int
|
|
23
|
-
end_line: int
|
|
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
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
6
|
-
<index_dir>/vectors.npy — N×1024 float32
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
#
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
95
|
-
|
|
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(
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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=
|
|
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
|