Files
group_fqcd_jr/app/service/knowledge_search_service.py
T
qyqy 395bad25b7 fix(knowledge): 检索服务适配现库集合 schema、发布对齐的工具白名单、免责声明只由治理层注入
合并暴露的三个真机问题(单测全绿但线上必挂):

1. 检索服务字段名与现库集合不符 -> 静默零召回
   架构师那套按 load_knowledge_milvus.py 的 schema 读 doc_id/content/chapter/section/
   visibility,而现库三集合的真实字段是 knowledge_id/title/snippet/tags/version/intent。
   Milvus 对不存在的字段直接报错 -> 三集合全失败 -> degraded -> 客服一律转人工。
   改法:只改读取侧,用 _FIELD_ALIASES 映射;KnowledgeHit 对外形状不变(下游与测试不动)。
   代价已注明:没有 visibility 字段 -> 检索层内部资料硬隔离失效(现库 356 行均为对外知识)。

2. 发布白名单与代码上限不匹配 -> AGENT_PERMISSION_DENIED
   active 版本白名单是 query_knowledge,而合并后代码上限是 search_knowledge/check_suitability/
   query_customer_profile -> 交集为空 -> 所有知识问题 failed。
   改法:publish_customer_service_config.py 补 PROFILE_TOOL 进 faq 白名单,并让同 key 的
   继承项被本次定义覆盖(旧值原样继承会被子集校验 422 拒掉整次发布)。已激活版本 216。

3. 免责声明重复出现
   Agent 自己拼一句 + 治理层追加权威话术 -> 客户看到两条。改为只由治理层注入
   (话术属发布配置,改文案不该改代码)。风控等内部 Agent 不注入(结构化输出不被污染)。

测试:934 passed / 1 failed(test_fund_readonly_contract 既有空集缺陷,与本线无关)
真机:知识问答两问 succeeded 且只带一条声明;画像问答 succeeded;知识库三端点全绿
2026-09-11 15:27:16 +08:00

451 lines
22 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**(2026-09-11 实测 describe_collection)。
#
# ⚠️ 这里与灌库脚本 `tools/load_knowledge_milvus.py` 的设计字段**不一致**,是刻意的:
# 那个脚本自述"临时脚本,跑完即删",所需的 `knowledge/_chunks.jsonl` 不在仓库里,
# 它那套 schema(`doc_id` 主键 + `chapter`/`section`/`doc_no`/`visibility`)在本环境
# **从未建起来过**。现库三个集合的真实字段是:
# knowledge_id / title / snippet / tags / version / intent / embedding
# 字段名不匹配的代价是**静默零召回**:Milvus 会对不存在的字段直接报错,
# 三个集合全失败 → `degraded=True` → 客服一律"引导人工",知识问答整体失效(实测复现)。
#
# 对齐策略:**只改读取侧**,不改检索逻辑与 `KnowledgeHit` 的对外形状——
# · `doc_id` ← `knowledge_id`(`KnowledgeHit.doc_id` 的名字保持不动,
# 下游 Agent 与 `test_knowledge_keyword_recall` 都依赖它)
# · `content` ← `snippet`(现库正文存在 snippet 字段)
# · `title` / `version` 同名直取
# · `chapter`/`section`/`doc_no`/`source_file`/`visibility` 现库没有 → 留空/默认,
# 依赖它们的增强逻辑(父子块选择、来源编号)自然退化为"不启用"而不会报错。
#
# 若将来按灌库脚本的 schema 重建集合并回填,把下面的 `_FIELD_ALIASES` 改回
# `{"doc_id": "doc_id", "content": "content", ...}` 并把 `_HAS_VISIBILITY` 置 True 即可,
# 检索逻辑一行都不用动。
_FIELD_ALIASES: dict[str, str] = {
"doc_id": "knowledge_id",
"title": "title",
"content": "snippet",
"tags": "tags",
"version": "version",
"intent": "intent",
# 现库未落这些字段 → 显式不请求,读取处按缺省值处理
}
#: 现库集合是否有 `visibility` 字段。没有时**不能**再拼 `visibility == "public"`:
#: 该表达式会让 Milvus 报 "field visibility not exist",整次检索失败。
_HAS_VISIBILITY = False
#: 现库集合是否有 `source_file` 字段(没有时来源标题只能靠 `doc_no`,通常为空)。
_HAS_SOURCE_FILE = False
OUTPUT_FIELDS = tuple(_FIELD_ALIASES.values())
# 关键字精确召回:客户问到产品名这类**专有名词**时,字面匹配比相似度更确定。
#
# 为什么需要:专有名词在 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 # 一次最多扫描多少条标题;知识库到上千块后应改为倒排索引
# 行级子块命中时,其父块(整节)按子块分数的这个比例一并返回:排在子块之后,
# 既不抢"起投多少"这类聚焦答案,又不至于把差距压到转人工门槛之下。
PARENT_SCORE_RATIO = 0.9
# 字面匹配只在向量结果**不够确定**时介入。
#
# 这条门禁是实测逼出来的:客户问「基金赎回几天到账」,向量已给出正确答案
# (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)
# 可见性过滤只在集合真有该字段时拼:现库没有 `visibility`,拼上去会让 Milvus
# 报 "field visibility not exist",三个集合全失败 → 一律走兜底(实测复现)。
# 代价必须说清楚:`_HAS_VISIBILITY=False` 时**没有**检索层的内部资料硬隔离,
# 只能靠入库侧只放对外知识来保证(现库 356 行均为对外知识,已核对)。
expression = (
None if (include_internal or not _HAS_VISIBILITY) 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))
# 命中行级子块时把父块(整节)一并带回,供调用方按问句选粒度:
# 「起投多少」要那一行,「介绍一下」要整节。
collected.extend(self._parent_hits(client, targets, collected, 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)
# "整节块"= **有行级子块挂在它下面**的块。FAQ/政策/公司信息的块没有子块,
# 它们本身就是细粒度答案,不能和产品手册的整节块混为一谈:第一版用"doc_id 不含
# 两位数字后缀"判断,把 FAQ 块全当成整节块排到最后,直接害得「基金赎回几天到账」
# 转人工(正确答案被挤出了 top1)。
section_ids = {
parent for parent in (self._parent_of(hit.doc_id) for hit in deduped) if parent
}
plain = [hit for hit in deduped if hit.doc_id not in section_ids]
sections = [hit for hit in deduped if hit.doc_id in section_ids]
selected = plain[: max(1, top_k)]
if sections:
# 整节块保底占最后一个名额:它按分数容易被 top_k 截掉,而「介绍一下」这类
# 概括问句只能靠它拿到整节(实测被截后客户只收到一行"产品期限 90天封闭期")。
# 放末尾是为了不让它参与 top1/top2 判定:实测它挤到第 2 位时 gap 会从
# 0.090 掉到 0.076,几乎跌破 0.07 的转人工门槛。
selected = [*selected[: max(0, top_k - 1)], sections[0]]
return KnowledgeSearchOutcome(
hits=tuple(selected),
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=[_FIELD_ALIASES["doc_id"], _FIELD_ALIASES["title"]],
limit=KEYWORD_SCAN_LIMIT,
)
except Exception:
return []
matched_ids = [
str(row.get(_FIELD_ALIASES["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"{_FIELD_ALIASES['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(_FIELD_ALIASES["content"]) or "")
if not content:
continue
hits.append(self._hit_from_row(row, score=KEYWORD_MATCH_SCORE))
# 一个产品名可能命中多个块(产品概览、费率表各一块):全都保留,
# 是不是"只有一个明确候选"交给上层的 gap 判定,这里不替它做选择。
return hits
def _parent_hits(
self, client: Any, targets: Sequence[str], hits: list[KnowledgeHit], expression: str | None
) -> list[KnowledgeHit]:
"""把命中到的行级子块的**父块**一并带回来。
行级子块让「起投多少」拿到了聚焦答案,但客户问「介绍一下」时会被某一行抢答
(实测返回了"产品期限 90天封闭期",而客户要的是整个产品的介绍)。父块是同一
产品的整节内容,一并带回来,由调用方按问句自己选粒度——检索层不猜客户想听多细。
分数按子块的 0.9 折算:既排在子块之后(不抢聚焦答案),又不会把差距压到转人工
门槛之下(0.869 折算成 0.782,与子块差 0.087,仍在 0.07 之上)。
失败一律返回空:父块是**补充**,取不到不影响子块结果。
"""
# 只取"得分最高的那个子块"的父块:整节只可能来自一个产品,把命中到的子块的父块
# 全带上只会挤占 top_k 名额(实测带上多个后,父块反而被截断在门外、整节拿不到)。
best_child = max(
(hit for hit in hits if self._parent_of(hit.doc_id) is not None),
key=lambda hit: hit.score,
default=None,
)
lookup = getattr(client, "query", None)
if best_child is None:
return []
parent_id = self._parent_of(best_child.doc_id)
if not parent_id or lookup is None:
return []
parent_scores = {parent_id: best_child.score * PARENT_SCORE_RATIO}
quoted = ", ".join(f'"{parent_id}"' for parent_id in parent_scores)
found: list[KnowledgeHit] = []
for collection in targets:
try:
rows = lookup(
collection_name=collection,
filter=self._anded(expression,
f"{_FIELD_ALIASES['doc_id']} in [{quoted}]"),
output_fields=list(OUTPUT_FIELDS),
limit=KEYWORD_SCAN_LIMIT,
)
except Exception:
continue # 某个集合查不到不影响其它集合
for row in rows if isinstance(rows, list) else []:
if not isinstance(row, dict):
continue
doc_id = str(row.get(_FIELD_ALIASES["doc_id"]) or "")
content = str(row.get(_FIELD_ALIASES["content"]) or "")
if not content or doc_id not in parent_scores:
continue
found.append(self._hit_from_row(row, score=parent_scores[doc_id]))
return found
@staticmethod
def _parent_of(doc_id: str) -> str | None:
"""行级子块的父块 doc_id;整节块返回 None。
判据是"末段恰为 2 位数字"(PROD-007-04 → PROD-007),**不是**"含连字符":
整节块自己的编号就形如 PROD-901,用连字符判断会把整节块误判成子块。
"""
head, _, tail = doc_id.rpartition("-")
if head and tail.isdigit() and len(tail) == 2:
return head
return None
@staticmethod
def _anded(expression: str | None, extra: str) -> str:
"""把可见性过滤与 doc_id 过滤合成一个 Milvus 表达式。"""
return f"({expression}) and ({extra})" if expression else extra
@staticmethod
def _hit_from_row(row: Any, *, score: float) -> KnowledgeHit:
"""把一行 Milvus 标量查询结果折成 `KnowledgeHit`。
字段名经 `_FIELD_ALIASES` 映射(现库是 `knowledge_id`/`snippet`);现库没有的字段
(`source_file`/`doc_no`/`chapter`/`visibility`)按缺省值处理——留空字符串而不是
编造内容,`reference_title` 会因此退回纯标题,来源引用退化但不失真。
"""
return KnowledgeHit(
doc_id=str(row.get(_FIELD_ALIASES["doc_id"]) or ""),
title=str(row.get(_FIELD_ALIASES["title"]) or ""),
content=str(row.get(_FIELD_ALIASES["content"]) or ""),
score=score,
source_file=str(row.get("source_file") or "") if _HAS_SOURCE_FILE else "",
visibility="public",
doc_no="",
version=str(row.get(_FIELD_ALIASES["version"]) or ""),
chapter="",
)
@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(_FIELD_ALIASES["content"]) or "")
if not content:
continue # 没有正文的命中无法作为答案来源,直接丢弃而不是猜造
hits.append(KnowledgeSearchService._hit_from_row(
entity, score=float(row.get("distance") or 0.0)))
return hits