From b53b4e0bc750aff924e83a9cf476fc05239527d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Tue, 15 Sep 2026 08:46:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E3=80=8Cr1=E5=88=B0r5=E5=88=86?= =?UTF-8?q?=E5=88=AB=E4=BB=A3=E8=A1=A8=E4=BB=80=E4=B9=88=E3=80=8D=E7=AD=94?= =?UTF-8?q?=E4=B8=8D=E5=87=BA=E6=9D=A5=EF=BC=9A=E8=A1=A5=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E5=AF=B9=E9=BD=90=E9=97=AE=E6=B3=95=E7=9A=84?= =?UTF-8?q?=E7=9F=AD=20FAQ=EF=BC=88=E4=B8=8D=E5=8A=A8=E9=97=A8=E6=A7=9B?= =?UTF-8?q?=E7=AD=96=E7=95=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 客服对「r1到r5分别代表什么」走兜底 + 转人工;「基金申购后多久能确认」用户以为也不答。 ## 排查结论(详见 docs/演示用/知识库问答诊断-2026-09-14.md) - 向量库**有**内容、检索也命中:申购确认那一问 top1 = **0.8468**(高置信,本来就答得出, 库里留着 23:59:06 那次完整问答);R1–R5 那一问 top1 = **0.6621**、次优 0.6412。 - 判定规则:高置信(≥0.75)不看 gap;中置信(0.55–0.75)必须领先次优 ≥0.07 (`customer_service.py:148-150`)。R1–R5 只领先 0.021 → 判"候选并列"。 - 根因是**同一主题多来源 + 命中块太长**:高频问答对(0.6621)、适当性指南第十一条三块 (0.6412/0.5771/0.5520)、产品手册 1.4 节(0.4983) 分数天然挤在一起; 而我先前上传的长条目(488 字切两块)被稀释到 0.6622,**改了两次才找到有效形态**: 短条目 + 标题与问法逐字对齐 → **0.7384 / gap 0.0761 → 可答**。 ## 本次改动 - 新增知识正文 `data/knowledge/faq_r1r5.md`(口径取自 `suitability_service.MATRIX_ALLOWED` / `MATRIX_NEEDS_DISCLOSURE`,避开禁用词)。 - 新增幂等工具 `tools/seed_knowledge_r1r5_faq.py`:默认 dry-run,`--apply` 上传; 已存在同源未过期知识则跳过,并自动复测检索评分。 - 真机执行:删除 3 个试验块(200/201/202)→ 上传 id 203 → 端到端验证。 ## 验证 - `tools/seed_knowledge_r1r5_faq.py`(幂等路径)→ `top1=0.7384 次优=0.6623 gap=0.0761 可答` - 端到端(登录客户 + 访客两条路径):「r1到r5分别代表什么」给出五级含义 + C1–C5 对应关系;「基金申购后多久能确认」给出 T+1/T+2 确认规则 - 回归 4 条同类问题仍高置信直接答:费率 0.8111 / 场内场外 0.7871 / 最小买多少 0.8289 / 赎回到账 0.8480 ## 顺带查清(登记为已知问题,未擅自改) 1. **向量与元数据严重不一致**:Milvus 159/397/297 个向量 vs MySQL 41/161/**0** 行; 答得最好的 `faq/高频问答对.txt`(含 FAQ-0015)与两套政策文件**只在向量里、MySQL 无行**。 ⇒ 这**推翻**了我先前"检索按 active 过滤"的建议(会把孤儿但有用的内容一起杀掉), 已在文档里明确撤回。 2. **检索层不看知识状态**:`knowledge_search_service.py` 里 `status` 出现 0 次 → 已 expired 的历史副本照样参与排序(费率这类会变的内容有被答旧值的风险)。 3. **重复与测试垃圾在抢答,且没有清理入口**:产品手册被种了 7 遍(199 行里只有 24 active)、 FAQ 集合 41 行里 38 行是上传链路测试残留;而 `DELETE /api/v1/knowledge/{id}` 对已 expired 行 一律 404「知识文档不存在」→ 清理历史副本目前无入口。 --- docs/演示用/知识库问答诊断-2026-09-14.md | 110 ++++++++++++++++ tools/seed_knowledge_r1r5_faq.py | 157 +++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 docs/演示用/知识库问答诊断-2026-09-14.md create mode 100644 tools/seed_knowledge_r1r5_faq.py diff --git a/docs/演示用/知识库问答诊断-2026-09-14.md b/docs/演示用/知识库问答诊断-2026-09-14.md new file mode 100644 index 0000000..e6956cb --- /dev/null +++ b/docs/演示用/知识库问答诊断-2026-09-14.md @@ -0,0 +1,110 @@ +# 知识库问答诊断:「基金申购后多久能确认」「r1到r5分别代表什么」为什么不回答(2026-09-14) + +> **结论先给**:**向量库有内容,两问都检索到了** —— 但一问本来就能答,另一问被"候选并列"门槛拦下。 +> 根因不是"没知识",而是**同一主题有多个来源 + 命中块太长**,导致相似度挤在一起、拉不开差距。 +> 已按内容侧修好(不动门槛策略),并顺带查出三件更值得留意的事(见 §4)。 + +--- + +## 1. 用户报的现象与实测结论 + +| 用户问的 | 检索 top1 | 门槛判定 | 实际回答 | +|---|---|---|---| +| 基金申购后多久能确认 | **0.8468**(`基金申购后多久确认?`) | ≥0.75 高置信,**不需要**领先度 | ✅ **答得出**("交易日15:00前提交的申购申请,T+1日确认份额(QDII 为 T+2)…")。库里留着 23:59:06 那次运行的完整问答 | +| r1到r5分别代表什么 | **0.6621**(`R1到R5风险等级是什么意思?`),次优 0.6412 | 中置信(0.55–0.75)**必须领先次优 ≥0.07**,实际只领先 **0.021** | ❌ 判"候选并列" → 兜底 + 转人工 | + +判定规则在 `app/service/agent/implementations/customer_service.py:148-150`: + +``` +HIGH_SCORE = 0.75 # ≥ 直接答(高置信不再要求间隙) +MID_SCORE = 0.55 # 需与 MIN_GAP 同时满足才答 +MIN_GAP = 0.07 # top1 领先次优的最小间隙;领先不足说明有候选并列,不硬答 +``` + +**这条规则本身是对的**(金融场景"答不了"可接受、"答错"不可接受),问题在**数据与条目形态**。 + +## 2. 根因:同一主题挤了多份来源,分数天然贴在一起 + +问「风险等级 R1–R5」时,库里同时有这几路内容在竞争: + +| 来源 | 命中分数 | +|---|---| +| `faq/高频问答对.txt` 的 `R1到R5风险等级是什么意思?` | 0.6621 | +| 《个人投资者适当性管理指南》第十一条(被切成 3 块) | 0.6412 / 0.5771 / 0.5520 | +| 产品手册 `### 1.4 风险等级与适当性` | 0.4983 | + +它们的分数**天然聚在 0.64–0.66**,所以 top1 永远领先不了 0.07 → 这一问怎么问都转人工。 + +**另一层是"块太长被稀释"**(实测): + +| 条目形态 | 「r1到r5分别代表什么」top1 | 结果 | +|---|---|---| +| 长条目(表格 + 详细说明,488/433 字,切 2 块) | 0.6622 | 不过门槛 | +| 短条目(272 字) | 0.7214 | gap 0.059,仍差一点 | +| **短条目 + 标题与问法逐字对齐**(本次采用) | **0.7384**,次优 0.6623 → **gap 0.0761** | ✅ **可答** | + +⇒ **命中块的标题/正文越贴近客户问法、块越短,相似度越高**。内容侧就能解决,不必动门槛。 + +## 3. 本次做了什么(可复现) + +1. 删除先前试验上传的 3 个知识块(id 200/201/202); +2. 上传**一条短 FAQ**:标题与问法逐字对齐(`# r1到r5分别代表什么`),正文含五级含义 + + C1–C5 对应关系 + 风险揭示书要求(口径取自 `app/service/suitability_service.py` 的 + `MATRIX_ALLOWED` / `MATRIX_NEEDS_DISCLOSURE`),**避免使用禁用词**; +3. 正文入库为仓库文件,便于换环境复现: + +``` +data/knowledge/faq_r1r5.md # 知识正文 +tools/seed_knowledge_r1r5_faq.py # 幂等上传(默认 dry-run,--apply 真写) +``` + +**验证证据**: + +| 检查 | 结果 | +|---|---| +| `python tools/seed_knowledge_r1r5_faq.py`(幂等) | 库内已存在 → 跳过,并自动复测:`top1=0.7384 次优=0.6623 → gap=0.0761 可答` | +| 端到端(登录客户 + 访客两条路径) | 「r1到r5分别代表什么」→ **给出五级完整答案**(含 C1–C5 对应);「基金申购后多久能确认」→ **给出 T+1 确认答案** | +| 回归(4 条同类问题) | 管理费率 0.8111 / 场内场外区别 0.7871 / 最小买多少 0.8289 / 赎回到账 0.8480 —— **均仍为高置信直接答** | + +## 4. 排查中查出的三件更值得留意的事 + +### 4.1 向量库与元数据严重不一致(大量"孤儿向量") + +| 集合 | Milvus 向量数 | MySQL `fin_knowledge_meta` 行数 | +|---|---|---| +| `fin_faq_collection` | **159** | 41 | +| `fin_product_collection` | **397** | 161 | +| `fin_policy_collection` | **297** | **0** | + +**答得最好的那套内容恰恰只在向量里**:`faq/高频问答对.txt`(`FAQ-0001`…,含 `FAQ-0015 基金申购后多久确认?`) +在 MySQL 里**一行都没有**(`content_text LIKE '%申购后多久%'` 命中 0 行),《适当性管理指南》 +《理财产品销售管理办法》同样如此。 + +⇒ 这直接推翻了我最初"检索按 `active` 过滤"的建议:**那样会把这批孤儿但有用的内容一起杀掉** +(实测一旦过滤,申购确认这一问也答不出来了)。**该修的是"向量与元数据不一致",不是在检索层按状态过滤。** + +### 4.2 检索层不看知识状态 + +`app/service/knowledge_search_service.py` 里 **`status` 出现 0 次**(只过滤 `visibility`)。 +写入侧本来是只写 `active` 的(`knowledge_vector_worker.KNOWLEDGE_ACTIVE_STATUS`), +但**存量向量不会因为后来状态变化而失效** → 已 `expired` 的历史副本照样参与排序, +极端情况下会被当作答案吐给客户(费率、产品清单这类会变的内容尤其危险)。 + +### 4.3 重复与测试垃圾都在抢答,而且没有清理入口 + +- 《场内基金产品手册》被**重复种了 7 遍**:MySQL 199 行里只有 24 行 `active`(最新一套 176–199), + 其余 175 行是历史副本; +- `fin_faq_collection` 的 41 行里有 **38 行是上传链路测试留下的**(`e2e-*.md` / `check-*.md` / + `verify-*.md` / `diag-test.md`); +- ⚠️ **删除接口删不掉已 `expired` 的行**:`DELETE /api/v1/knowledge/{id}` 对 id=1/6/137 + 一律返回 **404「知识文档不存在」**(该接口只在"未过期"范围内查找)→ **目前没有清理历史副本的入口**。 + +## 5. 遗留建议(未做,等决策) + +1. **向量与元数据对账**:以 MySQL 为权威,补一个对账工具(列出"有向量无元数据""有元数据无向量"), + 并把对账结果作为"知识库健康度"的一项输出。**这是上述所有问题的公共根因。** +2. **导入侧幂等**:知识入库按 `(source_file, content_hash)` upsert,避免同一份文档反复灌出 7 份副本。 +3. **清理入口**:给知识删除接口加"可删除已过期行"的口径(或提供一个管理端"清理历史副本"动作), + 否则重复副本只能靠直接改库清理。 +4. (可选)**门槛策略**:如果业务接受"同一主题多来源不算并列",可在判定里加"同主题"识别; + **本轮未改**——内容是能修的,策略一动影响面太大,需业务拍板。 diff --git a/tools/seed_knowledge_r1r5_faq.py b/tools/seed_knowledge_r1r5_faq.py new file mode 100644 index 0000000..a0d6572 --- /dev/null +++ b/tools/seed_knowledge_r1r5_faq.py @@ -0,0 +1,157 @@ +"""补一条"r1到r5分别代表什么"的 FAQ 知识(幂等;解决该问法答不出来的问题)。 + +## 为什么需要它 + +用户实测:问「r1到r5分别代表什么」时客服不给答案(走兜底 + 转人工)。排查结论(2026-09-14): + +- 向量库**有**内容,检索也命中(top1 = 那一套"高频问答对"里的 `R1到R5风险等级是什么意思?`, + 相似度 **0.6621**); +- 但客服的判定是"中置信(0.55–0.75)必须**领先次优 ≥0.07**"( + `app/service/agent/implementations/customer_service.py:148-150`),而这一问的次优是 + 0.6412 → **只领先 0.021** → 判"候选并列",转人工。 + +根因是**同一主题有多个来源**:`高频问答对.txt` 的 R1–R5 条目、《个人投资者适当性管理指南》 +第十一条(还切成好几块)、产品手册的 1.4 节——它们分数天然挤在一起,gap 永远拉不开。 + +## 这条为什么有效 + +实测(本机,qwen3-embedding): + +| 条目形态 | 「r1到r5分别代表什么」的 top1 | 结论 | +|---|---|---| +| 长条目(表格 + 详细说明,488+433 字,切两块) | 0.6622 | 向量被长正文稀释,仍不过门槛 | +| 短条目(272 字,标题与问法近似) | 0.7214 | gap 0.059,仍差一点 | +| **短条目 + 标题与问法逐字对齐**(本条的做法) | **0.7384**,次优 0.6623 → **gap 0.0761 ≥ 0.07** | ✅ **可答** | + +也就是:**命中条目的标题/正文与客户问法越贴近、块越短,相似度越高**;高置信档(≥0.75) +不要求 gap,中置信档靠"领先次优"过关。内容侧这一条就能修好,不必改门槛策略。 + +## 用法 + +```bash +# 先看要做什么(默认只读探测) +python tools/seed_knowledge_r1r5_faq.py + +# 真正上传(需要 API 在跑,且 Worker 在跑以便投向量) +python tools/seed_knowledge_r1r5_faq.py --apply +``` + +幂等:若已存在同 `source_file` 的**未过期**知识,直接跳过(要覆盖先加 `--force`, +它会先删旧的再传新的)。 +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import sys +import time +import uuid +from pathlib import Path + +import httpx + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") + +BASE_DEFAULT = "http://127.0.0.1:8000" +SOURCE = Path(__file__).resolve().parent.parent / "data" / "knowledge" / "faq_r1r5.md" +FILENAME = "r1到r5分别代表什么.md" +PROBE_QUERY = "r1到r5分别代表什么" + + +def _login(client: httpx.Client, base: str, username: str, password: str) -> str: + response = client.post( + f"{base}/api/v1/auth/tokens", json={"username": username, "password": password} + ) + if response.status_code != 200: + raise SystemExit(f"登录失败:HTTP {response.status_code} {response.text[:160]}") + return str(response.json()["data"]["access_token"]) + + +def _headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}", "Idempotency-Key": uuid.uuid4().hex} + + +def _existing_active(client: httpx.Client, base: str, token: str) -> list[dict[str, object]]: + """列未过期知识,挑出同 source_file 的条目(用于幂等)。""" + response = client.get(f"{base}/api/v1/knowledge/list", headers=_headers(token)) + body = response.json() + items = body.get("items") + if items is None: + data = body.get("data") + items = data.get("items") if isinstance(data, dict) else data + if not isinstance(items, list): + return [] + return [row for row in items if isinstance(row, dict) and row.get("source_file") == FILENAME] + + +def _probe_scores() -> None: + """上传后核对检索评分(与客服判定同一口径)。""" + from app.service.agent.bootstrap import get_knowledge_search_service + + service = get_knowledge_search_service() + outcome = asyncio.run(service.search(PROBE_QUERY, top_k=5)) + if not outcome.hits: + print(f" ⚠️ 「{PROBE_QUERY}」仍然没有命中") + return + top = outcome.hits[0].score + second = outcome.hits[1].score if len(outcome.hits) > 1 else 0.0 + gap = top - second + verdict = "高置信直接答" if top >= 0.75 else ( + f"中置信 gap={gap:.4f} → " + ("可答" if gap >= 0.07 else "仍会转人工") + ) + print(f" 检索:top1={top:.4f}({outcome.hits[0].title[:30]}) 次优={second:.4f} → {verdict}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="补 R1–R5 的 FAQ 知识(幂等)") + parser.add_argument("--base", default=BASE_DEFAULT, help="平台地址") + parser.add_argument("--username", default="admin_t") + parser.add_argument("--password", default="88888888") + parser.add_argument("--apply", action="store_true", help="真正上传(默认只看要做什么)") + parser.add_argument("--force", action="store_true", help="已存在时删旧的再传") + args = parser.parse_args() + + if not SOURCE.exists(): + raise SystemExit(f"知识正文不存在:{SOURCE}") + content = SOURCE.read_text(encoding="utf-8") + print(f"知识正文:{SOURCE}({len(content)} 字)") + + with httpx.Client(base_url=args.base, timeout=120) as client: + token = _login(client, args.base, args.username, args.password) + existing = _existing_active(client, args.base, token) + print(f"库内同源未过期知识:{len(existing)} 条 {[r.get('knowledge_id') for r in existing]}") + if existing and not args.force: + print("已存在 → 跳过(要覆盖加 --force)。") + _probe_scores() + return 0 + if not args.apply: + print("\n[dry-run] 未上传。加 --apply 真写。") + return 0 + + for row in existing: + kid = row.get("knowledge_id") + resp = client.delete(f"{args.base}/api/v1/knowledge/{kid}", headers=_headers(token)) + print(f" 删除旧条目 {kid}: HTTP {resp.status_code}") + resp = client.post( + f"{args.base}/api/v1/knowledge/upload", + headers=_headers(token), + json={ + "filename": FILENAME, + "content_base64": base64.b64encode(content.encode("utf-8")).decode("ascii"), + "knowledge_type": "faq", + }, + ) + print(f"上传:HTTP {resp.status_code} {resp.text[:200]}") + if resp.status_code != 201: + return 1 + print("等 Worker 投向量 …") + time.sleep(12) + _probe_scores() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())