Files
group_fqcd_jr/app/service/knowledge_search_service.py
T
lzf_0626 570493e71c feat(knowledge): 知识检索增加产品名的字面兜底召回
问题:客户问「季季盈90天的起投金额是多少」会被引导到人工客服,而知识库里明明有答案。
实测根因不是阈值拍错了,而是专有名词在 embedding 空间里不占优势——该问句的向量 top1
只有 0.6291,够不到 0.75 硬门槛,只能靠与次优的差值勉强通过;而同一次查询用
title like "%季季盈%" 是唯一命中 PROD-007。既然客户已经说出了产品名,就不该再赌相似度。

做法(三条边界都是实测逼出来的,不是设想):
1. 只对产品集合做字面匹配。客户问「季季盈90天的起投金额是多少」与通用 FAQ 标题
   「基金起投金额是多少?」有 7 个字连续重合;把 FAQ 纳入字面匹配会让它和真正的产品块
   一起拿到满分、差距归零,反而又退化成"转人工"。
2. 字面命中只在向量结果不够确定时采用。客户问「基金赎回几天到账」时向量已给出正确答案
   (FAQ-0016 得 0.8060),但手册章节标题「5.2 基金赎回流程」与问句也有 4 个字连续重合,
   无条件采纳会把"操作步骤"顶掉客户真正问的"到账时间"。
3. 重叠门槛取 6 字而不是 4 字:"基金赎回"这类业务动作词正好 4 字,会骗过 4 字门槛;
   产品名("南方季季盈90天")更长,6 字能同时保住产品名、挡住动作词。

未改动任何转人工判定阈值;VECTOR_CONFIDENT_SCORE 与 Agent 的 HIGH_SCORE 由单测锁定一致,
避免两处各自漂移出"谁都答不出来"的死角。

验证:季季盈类问法由"转人工"变为正确答出,基金赎回问法仍答 FAQ-0016;
ruff / mypy(113 文件) / 453 unit+contract / 29 integration 全绿。
2026-09-10 22:15:50 +08:00

316 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识库检索:把客户问题向量化后在三个知识集合里检索(只读)。
为什么不复用记忆那套 `VectorMemoryAdapter`:它只把命中折叠成 `(memory_uuid, score)`,
会把知识块的标题与正文丢掉。而客服回答必须能把**原文与来源**一起交给客户——金融场景
里「答案出自哪份文件的哪一条」本身就是交付物的一部分,丢了正文等于没法给来源引用。
失败语义与基座一致:**任何一步失败都不抛异常给主链路**,而是返回 `degraded=True`
的空结果,由调用方(客服 Agent)据此走「引导客户致电人工客服」的兜底路径。
金融场景下"答不了"是可接受的结果,"答错"不是。
"""
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
from typing import Any, Protocol
# 三个知识集合(方案 §2.4.4 / §4.1)
FAQ_COLLECTION = "fin_faq_collection"
PRODUCT_COLLECTION = "fin_product_collection"
POLICY_COLLECTION = "fin_policy_collection"
DEFAULT_COLLECTIONS: tuple[str, ...] = (FAQ_COLLECTION, PRODUCT_COLLECTION, POLICY_COLLECTION)
# 检索输出字段:与灌库脚本写入的 schema 对齐
OUTPUT_FIELDS = (
"doc_id", "title", "content", "chapter", "section",
"tags", "doc_no", "version", "source_file", "visibility",
)
# 关键字精确召回:客户问到产品名这类**专有名词**时,字面匹配比相似度更确定。
#
# 为什么需要:专有名词在 embedding 空间里不占优势。实测 160 条知识,客户问
# 「季季盈90天的起投金额是多少」向量 top1 = PROD-007 仅 0.6291(够不到 0.75 的硬门槛,
# 只能靠与次优的差值勉强通过);而同一次查询用 `title like "%季季盈%"` 是**唯一命中**
# PROD-007。既然客户已经明确说出了产品名,就不该再让相似度去赌。
KEYWORD_MATCH_SCORE = 1.0 # 字面命中的确定分,压过任何相似度分
# 与标题的最长公共子串至少要这么长,才算"客户确切提到了它"。
# 为什么不是 4:实测客户问「基金赎回几天到账」,与手册章节标题「5.2 基金赎回流程」
# 正好有 4 个字连续重合——但"基金赎回"是业务动作词,不是专有名词。产品名
# ("南方季季盈90天")通常比业务动作词长,取 6 字能同时保住产品名、挡住动作词。
MIN_KEYWORD_OVERLAP = 6
KEYWORD_SCAN_LIMIT = 500 # 一次最多扫描多少条标题;知识库到上千块后应改为倒排索引
# 字面匹配只在向量结果**不够确定**时介入。
#
# 这条门禁是实测逼出来的:客户问「基金赎回几天到账」,向量已给出正确答案
# (FAQ-0016「基金赎回到账需要多长时间?」得 0.8060),但产品手册里的章节标题
# 「五、申购赎回操作指南」与问句也有 6 个字连续重合,无条件字面匹配会把它顶到第一,
# 用操作步骤替换掉客户真正问的到账时间。所以字面匹配是**兜底**,不是优先。
#
# 门槛必须与客服 Agent 的高置信门槛一致,tests/unit 有断言锁定两者相等。
VECTOR_CONFIDENT_SCORE = 0.75
class VectorSearcher(Protocol):
"""只依赖用到的两个方法,便于测试替身注入。"""
def search(self, **kwargs: Any) -> Any: ...
Embedder = Callable[[str], Awaitable[list[float]]]
@dataclass(frozen=True)
class KnowledgeHit:
"""一条知识命中;`score` 为 COSINE 相似度(越大越相似)。"""
doc_id: str
title: str
content: str
score: float
source_file: str = ""
visibility: str = "public"
doc_no: str = ""
version: str = ""
chapter: str = ""
@property
def reference_title(self) -> str:
"""给客户看的来源标题:优先带内部文件编号,便于人工核对。"""
return f"{self.title}({self.doc_no})" if self.doc_no else self.title
@dataclass(frozen=True)
class KnowledgeSearchOutcome:
"""检索结果;`degraded=True` 表示检索链路故障,调用方必须走兜底而非当'没找到'。"""
hits: tuple[KnowledgeHit, ...] = ()
degraded: bool = False
reason: str = ""
searched_collections: tuple[str, ...] = field(default_factory=tuple)
@property
def best(self) -> KnowledgeHit | None:
return self.hits[0] if self.hits else None
@property
def top_score(self) -> float:
return self.hits[0].score if self.hits else 0.0
class KnowledgeSearchService:
def __init__(
self,
client: VectorSearcher | None,
embedder: Embedder | None,
*,
collections: Sequence[str] = DEFAULT_COLLECTIONS,
) -> None:
self._client = client
self._embedder = embedder
self._collections = tuple(collections)
@property
def available(self) -> bool:
"""向量库与向量化能力是否都在位;缺任一项都不做检索,直接走兜底。"""
return self._client is not None and self._embedder is not None
async def search(
self,
query: str,
*,
collections: Sequence[str] | None = None,
top_k: int = 5,
include_internal: bool = False,
) -> KnowledgeSearchOutcome:
"""检索知识库。
`include_internal=False`(默认)时在 Milvus 侧就过滤掉 `visibility=internal` 的块,
内部资料不进入面向客户的答案——这是检索层的硬隔离,不依赖提示词约束。
"""
text = query.strip()
if not text:
return KnowledgeSearchOutcome(reason="empty_query")
if not self.available:
return KnowledgeSearchOutcome(degraded=True, reason="vector_backend_unavailable")
client, embedder = self._client, self._embedder
if client is None or embedder is None:
# 与 available 重复,但这里需要类型收窄(mypy 不跨属性判断 Optional)
return KnowledgeSearchOutcome(degraded=True, reason="vector_backend_unavailable")
try:
vector = await embedder(text)
except Exception:
return KnowledgeSearchOutcome(degraded=True, reason="embedding_failed")
if not vector:
return KnowledgeSearchOutcome(degraded=True, reason="embedding_empty")
targets = tuple(collections or self._collections)
expression = None if include_internal else 'visibility == "public"'
collected: list[KnowledgeHit] = []
failures = 0
for collection in targets:
try:
raw = client.search(
collection_name=collection,
data=[vector],
limit=max(1, min(top_k, 20)),
output_fields=list(OUTPUT_FIELDS),
filter=expression,
)
except Exception:
failures += 1
continue
collected.extend(self._parse(raw, collection))
# 第二路召回:客户确切说出的产品名按字面取回。只在向量结果不够确定时介入,
# 否则会把向量已经答对的题顶掉(见 VECTOR_CONFIDENT_SCORE 的说明)。
best_vector_score = max((hit.score for hit in collected), default=0.0)
if best_vector_score < VECTOR_CONFIDENT_SCORE:
collected.extend(self._product_keyword_hits(client, targets, text, expression))
if not collected and failures == len(targets) and targets:
# 三个集合全查失败:是链路故障,不是"知识库里没有"
return KnowledgeSearchOutcome(
degraded=True, reason="search_failed", searched_collections=targets
)
collected.sort(key=lambda hit: hit.score, reverse=True)
# 同一内容可能同时存在于产品手册与问答对里,按 doc_id 去重保留最高分
deduped: list[KnowledgeHit] = []
seen: set[str] = set()
for hit in collected:
if hit.doc_id in seen:
continue
seen.add(hit.doc_id)
deduped.append(hit)
return KnowledgeSearchOutcome(
hits=tuple(deduped[: max(1, top_k)]),
degraded=failures > 0,
reason="partial_collection_failure" if failures else "",
searched_collections=targets,
)
# ---- 关键字精确召回(字面匹配) ----
@staticmethod
def _overlap_length(left: str, right: str) -> int:
"""两段文本的最长公共子串长度。
用最长公共子串而不是分词:知识库标题是「南方科技有限公司 个人理财产品手册 ·
2.1 南方季季盈90天」这种没有词边界的长串,任何分词器都得先养一份自定义词典,
而词典会和手册一起过期。子串匹配不需要词典,手册改版也不会失效。
"""
if not left or not right:
return 0
previous = [0] * (len(right) + 1)
best = 0
for i in range(1, len(left) + 1):
current = [0] * (len(right) + 1)
for j in range(1, len(right) + 1):
if left[i - 1] == right[j - 1]:
current[j] = previous[j - 1] + 1
if current[j] > best:
best = current[j]
previous = current
return best
def _product_keyword_hits(
self, client: Any, targets: Sequence[str], query: str, expression: str | None
) -> list[KnowledgeHit]:
"""客户确切说出某个产品名时,按字面把它取出来(兜底用)。
两处边界都是实测逼出来的:
1. **只对产品集合做**。客户问「季季盈90天的起投金额是多少」,与通用 FAQ 标题
「基金起投金额是多少?」的最长公共子串有 7 个字;若把 FAQ 也纳入字面匹配,
它会和真正的产品块一起拿到满分、差距归零,反而又退化成"转人工"。
2. **命中不能无条件优先**。产品手册的标题里不只有产品名,还有章节名:客户问
「基金赎回几天到账」时,「五、申购赎回操作指南」那一块与问句也有 6 个字连续
重合。所以本方法产出什么是一回事,是否采用由调用方按"向量是否已经足够确定"
决定(见 VECTOR_CONFIDENT_SCORE)。
失败一律返回空:关键字路径是**增益**,它坏了不能让整个检索变成故障。
"""
lookup = getattr(client, "query", None)
if lookup is None or PRODUCT_COLLECTION not in targets:
return [] # 客户端不支持标量查询(如测试替身),或本次没查产品集合
try:
rows = lookup(
collection_name=PRODUCT_COLLECTION,
filter=expression,
output_fields=["doc_id", "title"],
limit=KEYWORD_SCAN_LIMIT,
)
except Exception:
return []
matched_ids = [
str(row.get("doc_id") or "")
for row in (rows if isinstance(rows, list) else [])
if isinstance(row, dict)
and self._overlap_length(query, str(row.get("title") or "")) >= MIN_KEYWORD_OVERLAP
]
matched_ids = [doc_id for doc_id in matched_ids if doc_id]
if not matched_ids:
return []
quoted = ", ".join(f'"{doc_id}"' for doc_id in matched_ids)
try:
details = lookup(
collection_name=PRODUCT_COLLECTION,
filter=f"doc_id in [{quoted}]",
output_fields=list(OUTPUT_FIELDS),
limit=len(matched_ids),
)
except Exception:
return []
hits: list[KnowledgeHit] = []
for row in details if isinstance(details, list) else []:
if not isinstance(row, dict):
continue
content = str(row.get("content") or "")
if not content:
continue
hits.append(KnowledgeHit(
doc_id=str(row.get("doc_id") or ""),
title=str(row.get("title") or ""),
content=content,
score=KEYWORD_MATCH_SCORE,
source_file=str(row.get("source_file") or ""),
visibility=str(row.get("visibility") or "public"),
doc_no=str(row.get("doc_no") or ""),
version=str(row.get("version") or ""),
chapter=str(row.get("chapter") or ""),
))
# 一个产品名可能命中多个块(产品概览、费率表各一块):全都保留,
# 是不是"只有一个明确候选"交给上层的 gap 判定,这里不替它做选择。
return hits
@staticmethod
def _parse(raw: Any, collection: str) -> list[KnowledgeHit]:
"""把 pymilvus 的 `[[{id, distance, entity}]]` 折叠成命中列表(纯函数,不抛异常)。"""
hits: list[KnowledgeHit] = []
groups = raw if isinstance(raw, (list, tuple)) else [raw]
for group in groups:
rows = group if isinstance(group, (list, tuple)) else [group]
for row in rows:
entity = row.get("entity") if isinstance(row, dict) else None
if not isinstance(entity, dict):
continue
content = str(entity.get("content") or "")
if not content:
continue # 没有正文的命中无法作为答案来源,直接丢弃而不是猜造
hits.append(KnowledgeHit(
doc_id=str(entity.get("doc_id") or ""),
title=str(entity.get("title") or ""),
content=content,
score=float(row.get("distance") or 0.0),
source_file=str(entity.get("source_file") or ""),
visibility=str(entity.get("visibility") or "public"),
doc_no=str(entity.get("doc_no") or ""),
version=str(entity.get("version") or ""),
chapter=str(entity.get("chapter") or ""),
))
return hits