feat(knowledge): 产品知识拆到表格行级,并按问句选粒度

问题(客户实测反馈):同一会话里问「季季盈90天起投多少」和「那它风险高吗」,两次回答
**一模一样**——都是整个产品小节的表格。客户问的是风险,收到的是整张说明书,看起来像
客服没听懂问题。

根因是切分粒度:原来"一个叶子标题 = 一块",产品手册里就是整个产品小节(表格 + 说明)
成一块。这既让两个不同的问题命中同一块,也让整节几百字的向量成了"整节的混合语义",
与"起投多少"这种具体小问题相似度天然偏低(实测该问句向量 top1 仅 0.6291,够不到 0.75
硬门槛,只能靠与次优的差值勉强通过)。

改动三处:
1. 切分:Markdown 表格的每一行额外生成一个**自解释**的小块("南方季季盈90天:起投金额
   1万元"),挂在父块 doc_id 下(PROD-007-04),父块照旧保留。知识块 160 → 631。
   效果:该问句的命中分从 0.6291 升到 0.869,命中的正是"起投金额"那一行。
2. 检索:命中行级子块时把它的整节父块一并带回(分数按 0.9 折算),供调用方按问句选粒度。
   整节块保底占最后一个名额,且不参与 top1/top2 判定——实测它挤到第 2 位会把 gap 从
   0.090 压到 0.076,几乎跌破 0.07 的转人工门槛。
3. 客服:命中的是行级子块时,看问句与子块标签是否真的对得上——「起投多少」对「起投金额」
   对得上,用那一行;「介绍一下」对不上,换成整节。

过程中两次判据写错并已修正(都固化进了测试):用"含连字符"认子块时,整节块自己的编号
PROD-901 被误判成子块;用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块排到后面,
把正确答案挤出 top1、害得「基金赎回几天到账」转人工。

验证:起投/管理费等字段问法给出聚焦的单行答案;"介绍一下"给出整节;FAQ 与政策问法不受
影响(换话题、指代追问等此前修好的场景复测通过);
ruff / mypy(113 文件) / 468 unit+contract / 29 integration 全绿。
This commit is contained in:
2026-09-10 22:29:23 +08:00
parent a6c09fa3c4
commit 4c2b147793
5 changed files with 769 additions and 13 deletions
@@ -15,6 +15,8 @@
|`chitchat` → 模型生成(提示词走发布配置)|其余与异常 → 引导人工客服
"""
from typing import Any
from app.core.contracts import (
AgentDefinition,
AgentRequest,
@@ -146,6 +148,9 @@ class CustomerServiceAgent(BaseAgent):
if not confident and not (score >= MID_SCORE and gap >= MIN_GAP):
return self._guide_to_human(f"置信度不足:score={score:.3f} gap={gap:.3f}")
# 命中的是行级子块时,先分辨客户问的是"某个字段"还是"整个产品":
# 子块让「起投多少」拿到聚焦答案,但「介绍一下」会被某一行抢答。
best = self._prefer_section(request.message, best, hits)
content = str(best.get("content") or "").strip()
if not content:
return self._guide_to_human("命中内容为空")
@@ -162,6 +167,35 @@ class CustomerServiceAgent(BaseAgent):
intent=self._classified_intent,
)
@staticmethod
def _prefer_section(message: str, best: dict[str, Any], hits: list[Any]) -> dict[str, Any]:
"""客户问整个产品时,用整节块替换掉抢答的那一行。
行级子块是为了让「起投多少」拿到聚焦答案,但「介绍一下」会被某一行抢答
(实测返回了"产品期限 90天封闭期",而客户要的是整个产品)。
判据不用问句分类器,而是看问句与子块标签是否真的对得上。标签取自子块的
section 末段("起投金额""风险等级"):「起投多少」含"起投"、「风险高吗」含"风险",
都算对得上;「介绍一下」与任何标签都不重合,说明客户要的是整节。
父块由检索层按子块分数的 0.9 折算后一并带回。万一没带回来就仍用子块——
宁可答得窄一点,也不要拿不相干的块去搪塞。
"""
doc_id = str(best.get("doc_id") or "")
# 末段恰为 2 位数字才是行级子块(PROD-007-04);整节块自己的编号形如 PROD-901,
# 用"含连字符"判断会把整节块误判成子块。
parent_id, _, tail = doc_id.rpartition("-")
if not (parent_id and tail.isdigit() and len(tail) == 2):
return best # 命中的本来就是整节
# 标签取自子块 title 的末段(title 由 " · " 连接),如"起投金额""风险等级"
label = str(best.get("title") or "").split(" · ")[-1].strip()
if label and label[:2] in message:
return best # 客户问的正是这个字段
for hit in hits:
if isinstance(hit, dict) and str(hit.get("doc_id") or "") == parent_id:
return hit
return best
# ---- 出口二:闲聊(提示词走发布配置) ----
async def _chitchat(self, request: AgentRequest) -> CoreResult:
@@ -214,22 +248,43 @@ class CustomerServiceAgent(BaseAgent):
# 短到这种程度的问题,自己通常不构成完整意图("起投多少""风险高吗")
_MIN_STANDALONE_CHARS = 8
@staticmethod
def _topic_of(answer: str) -> str:
"""从客服上一轮的回答里取出"这一轮在说哪个产品",取不出来就返回空串。
回答是我们自己组装的,只有两种形状,都取自知识块的字段:
- 行级块:`南方季季盈90天:起投金额 1万元`(首个冒号前就是产品名)
- 整节块:`### 2.1 南方季季盈90天`(首个标题行去掉 # 号)
取不出来时宁可返回空、退化成不带主语,也不要拿兜底话术当主语:
「抱歉,这个问题我暂时无法给出准确答复」这种句子拿去检索只会把问题带偏。
"""
first = next((line.strip() for line in answer.splitlines() if line.strip()), "")
first = first.lstrip("#").strip()
topic = first.split(":", 1)[0].strip() if ":" in first else first
if not topic or len(topic) > 20 or "," in topic or "。" in topic:
return ""
return topic
@classmethod
def _search_query(cls, request: AgentRequest) -> str:
"""构造交给检索的查询串。
只有当客户这一句**自己说不清楚**时,才把上文接进来。
什么时候带上文(实测决定,两条都不能少):
为什么不能无条件拼接(实测):同一会话先问「季季盈90天的起投金额是多少」,
再问「基金赎回几天到账」,若把上一轮问题拼进检索词,第二问会命中季季盈的产品块、
答出产品介绍——**答非所问**。在金融场景里这比"引导转人工"糟得多:客户问 A 得到 B
的答案,会直接失去对客服的信任,而"答不了"至少是诚实的。
1. **客户这一句自己说不清楚时**才带。同一会话先问「季季盈90天的起投金额是多少」、
再问「基金赎回几天到账」,若无条件带上文,第二问会命中季季盈的产品块、
答出产品介绍——**答非所问**。在金融场景里这比"引导转人工"糟得多。
2. **带上文时只带"在说哪个产品",不带上一轮的原话**。把上一轮整句拼进来会让
检索词语义变"宽",反而只能命中粗粒度的整节块:实测「季季盈90天起投多少
那它风险高吗」命中的是整个产品小节,客户问的"风险"完全没有被聚焦;
换成「南方季季盈90天 那它风险高吗」才命中"风险等级"那一行。
所以拼接只留给两种真正需要上文的情况:句子里有指代词,或短到不足以独立表达意图。
其余一律以当前问题检索,客户换话题就不会被上一轮拖回去。
换句话说:上文的作用是**补主语**,不是**补内容**。
只取客户的话、不取 Agent 自己的回答:把 Agent 的措辞也拼进来会让检索偏向自己
上一轮的说法,而客户的真实意图可能已经在下一句里被修正过。
只取客户的话、不取 Agent 自己的回答当内容:把 Agent 的措辞也拼进来会让检索偏向
自己上一轮的说法,而客户的真实意图可能已经在下一句里被修正过。这里从回答里取的
只有产品名这一个"主语",不是它的论述。
"""
message = request.message.strip()
needs_context = (
@@ -238,8 +293,13 @@ class CustomerServiceAgent(BaseAgent):
)
if not needs_context:
return message[:500]
recent_user = [turn.content for turn in request.history if turn.role == "user"][-2:]
return " ".join([*recent_user, message])[:500]
for turn in reversed(request.history):
if turn.role == "assistant":
topic = cls._topic_of(turn.content)
if topic:
return f"{topic} {message}"[:500]
break
return message[:500]
# ---- 出口三:引导人工客服(不做工单,只回话并留痕) ----