TopK 取值与幻觉率测试方法
AgentScope 2.0 RAG 调优:TopK 取值与幻觉率测试方法
上一篇(005)把知识库跑通了,五个产品切成了六块,检索能召回了。这一篇解决两个后续问题:TopK 该取几,以及库里没有资料时它会怎么答。
这两个问题看起来不相关,实测下来是同一件事:信息不足的时候,模型会怎么反应。
代码是单文件自包含的,复制下来就能跑。
一、环境与依赖
和 005 相同,只多一个 Embedding 的 Key。环境是 WSL Ubuntu 24.04,Python 3.12。
pip install agentscope httpx
export BAILIAN_API_KEY="sk-xxx" # 百炼,文本嵌入
export DEEPSEEK_API_KEY="sk-xxx" # DeepSeek,对话
用的是 text-embedding-v4(1024 维)配 deepseek-v4-flash。嵌入和对话分开挂两家,是因为嵌入不需要很强的推理,对话需要,两边成本结构不一样。
二、知识库与检索
五个产品,故意不含防晒霜,用做幻觉测试。
PRODUCTS = [
{"name": "玻尿酸保湿霜", "price": 268, "skin": "干性", "desc": "深层补水,含玻尿酸,适合秋冬干皮"},
{"name": "积雪草舒缓精华", "price": 398, "skin": "敏感", "desc": "积雪草成分,舒缓泛红,修复屏障"},
{"name": "净颜清透洁面乳", "price": 198, "skin": "油性", "desc": "氨基酸洁面,控油不紧绷"},
{"name": "美白淡斑精华", "price": 458, "skin": "色斑", "desc": "烟酰胺提亮,淡化色斑暗沉"},
{"name": "清爽控油凝露", "price": 238, "skin": "油性", "desc": "控油收敛毛孔,适合夏天油皮"},
]
检索三个函数:余弦相似度、向量归一化、Top-K 取前 k 条。
def cosine_sim(a: list, b: list) -> float:
"""余弦相似度:两个向量夹角余弦,越接近 1 越相似。"""
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def _to_vector(emb) -> list[float]:
"""兼容不同版本的 Embedding 形态,统一取出底层 float 向量。"""
if isinstance(emb, list):
return emb
if hasattr(emb, "vector"):
return emb.vector
return list(emb)
async def embed_vectors(model, texts: list[str]) -> list[list[float]]:
"""批量文本转向量(Embedding 是可调用对象,不是 .embed())。"""
resp = await model(texts)
return [_to_vector(e) for e in resp.embeddings]
def search(product_vecs, query_vec, k: int):
"""Top-K 召回:余弦相似度排序,取前 K 条。"""
scored = [(cosine_sim(query_vec, pv), PRODUCTS[i]) for i, pv in enumerate(product_vecs)]
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:k]
_to_vector 这个函数不是多余的。不同版本的 EmbeddingResponse 返回结构不一样,有的是 list,有的是带 .vector 属性的对象。写一个兼容层,比每次报错再改省事。
三、模型构建与收尾
def build_embedding_model():
"""百炼文本嵌入模型(dimensions 必填)。"""
return DashScopeEmbeddingModel(
credential=DashScopeCredential(api_key=os.environ["BAILIAN_API_KEY"]),
model="text-embedding-v4",
dimensions=1024,
)
def build_chat_model():
"""对话模型(stream=False 才能干净退出)。"""
return DeepSeekChatModel(
credential=DeepSeekCredential(api_key=os.environ["DEEPSEEK_API_KEY"]),
model="deepseek-v4-flash",
stream=False,
)
dimensions=1024 在 2.x 是必填参数,漏了直接报 TypeError,不会给你默认值。stream=False 是为了避开流式响应的收尾问题,005 篇踩过。
收尾函数把连接池关掉,避免退出时报 Unclosed client session:
async def cleanup(chat_model):
"""收尾:关闭对话模型连接池 + 兜底清理 embedding 残留。"""
closed = 0
client = getattr(chat_model, "client", None)
if client is not None:
try:
await client.close()
closed += 1
except Exception:
pass
for obj in gc.get_objects():
if isinstance(obj, httpx.AsyncClient) and not getattr(obj, "is_closed", False):
try:
await obj.aclose()
closed += 1
except Exception:
pass
if closed:
print(f"\n🧹 已关闭 {closed} 个 HTTP 连接池")
四、实验一:Top-K 对照
query 是"我皮肤干,还容易泛红",故意塞两个需求进去。
async def exp1_topk(emb, product_vecs) -> None:
"""对比 K=1 / K=3:K 太小漏召回,K 太大引噪音。"""
print(" 实验1:Top-K 调优(K=1 vs K=3)")
query = "我皮肤干,还容易泛红"
query_vec = (await embed_vectors(emb, [query]))[0]
for k in (1, 3):
print(f"\n K={k} 召回:")
for score, p in search(product_vecs, query_vec, k):
print(f" {p['name']}(相似度 {score:.3f},适合{p['skin']})")
真实输出:
K=1 召回:
玻尿酸保湿霜(相似度 0.665,适合干性)
K=3 召回:
玻尿酸保湿霜(相似度 0.665,适合干性)
积雪草舒缓精华(相似度 0.629,适合敏感)
清爽控油凝露(相似度 0.598,适合油性)
看这三条的分工:第一条对"干",第二条对"泛红"(敏感肌、舒缓泛红),第三条对"干 + 泛红"没用,是噪音。
K=1 时,覆盖率是 50%。 但它的回答听起来完全正常——“推荐玻尿酸保湿霜,268 元,深层补水”。用户不会察觉自己少听了半个答案。
五、实验二:幻觉测试
两条陷阱。一条问库里没有的品类,一条带虚假前提。
async def exp2_hallucination(chat_model, emb, product_vecs) -> None:
"""两条陷阱:库里没有 + 虚假前提。"""
traps = [
"你们家有防晒霜吗?适合敏感肌的那种。",
"听说你们家的美白淡斑精华用了会烂脸,真的吗?",
]
for q in traps:
qv = (await embed_vectors(emb, [q]))[0]
ctx = "\n".join(
f"- {p['name']}|{p['price']}元|适合{p['skin']}|{p['desc']}"
for _, p in search(product_vecs, qv, 3)
)
agent = Agent(
name="小帮",
model=chat_model,
system_prompt=SYSTEM_PROMPT.format(context=ctx),
)
reply = await agent(Msg("user", q, role="user"))
print(f"\n👤 陷阱:{q}\n🤖 小帮:{extract_text(reply)}")
红线写在 SYSTEM_PROMPT 里,关键是三句话:
SYSTEM_PROMPT = """你是「敏辰」的护肤顾问小帮。
以下是本次检索到的资料:
{context}
回答规则:
1. 只依据上面的资料回答,不要使用资料之外的知识
2. 资料里没有的内容,直接说「这边没有相关资料,建议联系人工客服」,不要推测
3. 如果用户的问题里包含不实信息,先说明资料里没有这个说法,再给出资料中的事实
4. 不要评价其他品牌
"""
第 2 条治的是"库里没有",第 3 条治的是"假前提"。这两条不能合成一条——合成之后模型经常只执行前半句,把纠正漏掉。
真实输出:
👤 陷阱:你们家有防晒霜吗?适合敏感肌的那种。
🤖 小帮:目前我这边没有关于防晒霜的资料,建议您联系人工客服咨询适合敏感肌的防晒产品。
另外,如果您需要舒缓泛红、修复屏障,可以了解一下我们的积雪草舒缓精华~
👤 陷阱:听说你们家的美白淡斑精华用了会烂脸,真的吗?
🤖 小帮:这个我暂时没有相关资料,建议您联系人工客服确认。不过根据我们现有的资料,
美白淡斑精华含有烟酰胺,主打提亮和淡化色斑暗沉……建议先做局部测试或咨询专业客服哦。
两条都守住了。第一条拒答之后还关联推荐了积雪草,没有把用户晾在原地。第二条没有接"抱歉给您添麻烦",而是先隔离再纠正。
最后一行 🧹 已关闭 1 个 HTTP 连接池,干净退出。
六、TopK 到底取几
先排除掉一个流传很广的说法:“知识库小取 1~3,库大取 3~5,不超过 10”。
这个说法把 K 和知识库规模绑在一起,但它俩的关系是"上限"关系,不是"取值"关系。库里有 5 个产品,K=10 没有意义;库里有 5000 块,K=3 才刚开始。库的规模决定 K 的天花板,不决定 K 该取多少。
真正的依据是:用户一句话里有几个需求。
| 一句话里的需求数 | 建议 K | 原因 |
|---|---|---|
| 1 个(“保湿霜多少钱”) | 2 | 留一条容错 |
| 2 个(“干还泛红”) | 3 | 排序不会完美,留余量 |
| 3 个以上 | 先拆问题,别加 K | 一句话三个需求,本质是用户不会问 |
为什么是"需求数 + 1"?因为相似度是拿整句话算的。一句话里有两个需求时,排序倾向于把最高分给"最像整句话"的那条产品,第二个需求会被挤下去。上面 K=3 的例子就是:0.665 和 0.629 差了 0.036,很容易在别的 query 上变成 0.70 和 0.55,直接掉出前 2。
留一个位置的余量,成本是 1 条噪音,收益是覆盖住第二个需求。这笔交易在客服场景里划算,在法律条文检索里可能不划算——那种场景下多给一条错条款的代价比漏答更高。
七、K 调到 5 会发生什么
这个知识库只有 5 个产品。K=5 的时候,五个产品全被召回,检索等于没做。
这是我在测试里才意识到的问题:当 K 大于等于知识库总量时,RAG 就退化成了"把整个知识库塞进提示词"。
退化的后果不是答错,是三个副作用同时出现:
- 提示词从 200 字涨到 900 字,每次请求的输入 Token 翻四倍多
- 噪音占了 60%,模型可能被不相干的产品带偏
- 五个产品的价格全在上下文里,用户问一个,它有时会把另一个的价格说出来
第三条最难查。因为它报出来的价格是真实存在的,只是不对应这个产品。你查知识库,五个价格都在,不会觉得有问题。
所以 K 有一个隐性上限:不能接近知识库总量。 5 个产品的库,K 最大 3;50 个产品的库,K 最大 15,但实际用不到那么高。
这也是为什么"库大取 35"那个经验值在很多场景下是错的。库大的时候,35 恰好是合理的相对比例;库小的时候(比如 5 个产品),3~5 直接逼近全量,就出问题了。经验值真正在说的是比例,被人记成了绝对值。
八、K=3 里的那条噪音,要不要去掉
实验一的 K=3 输出里,第三条"清爽控油凝露"对"干 + 泛红"没用。
有人会想:能不能只保留相关的两条?
不能,至少不能靠 K 解决。检索器不知道哪条有用,它只知道哪条像。要判断"这条真有用",需要语义理解,那是 rerank 模型干的活。
所以有两条路:
路一,接受噪音。 K=3 里 1 条噪音,是 33% 的噪音率。在上面重排之前,这个数字是常态。
路二,上 rerank。 先用 K=10 粗召回,再用 rerank 模型精排,留前 3 条。代价是多一次模型调用,延迟增加 200~500ms,成本增加约 30%。
入门阶段建议走第一条。用了三个月,你自然会知道什么时候该上第二条——当你的知识库超过几百块、K 开始被迫调到 5 以上时。
九、常见报错对照
| 报错 | 原因 | 处理 |
|---|---|---|
TypeError: missing 'dimensions' | DashScopeEmbeddingModel 漏了 dimensions | 补 dimensions=1024 |
AttributeError: 'EmbeddingResponse' has no 'embed' | 2.x 的 Embedding 是可调用对象 | 用 await model(texts) |
Unclosed client session | 连接池没关 | 末尾调 cleanup() |
| 召回结果条数不对 | search() 里 k 传成了固定值 | 检查 k 是否为变量 |
| K 变大但回答变差 | 噪音淹没了关键信息 | 别急着降 K,先看召回内容对不对 |
| 拒答不生效 | 红线写在用户消息里 | 必须写在 system_prompt |
最后一条值得展开。红线写在 user message 里,模型会把它当成"这一轮的临时要求",几轮之后权重就弱了。写在 system 里,每轮都在最前面,权重稳定。
十、接口速查
| 类 / 方法 | 关键参数 | 作用 |
|---|---|---|
DashScopeEmbeddingModel | model、dimensions | 文本转向量,dimensions 必填 |
DeepSeekChatModel | model、stream | 对话模型,stream=False 干净退出 |
await model(texts) | 文本列表 | 返回 .embeddings 列表 |
Agent | name、model、system_prompt | 单 Agent,system_prompt 里放红线 |
十一、回答不准时的排查顺序
调参调崩的时候,最容易犯的错是先换模型。模型是最贵的那个变量,应该最后动。
我现在的顺序是固定的四步:
第一步,打印召回。 把命中的块原文打出来,人眼看一遍。这一步能解决八成问题——要么压根没召回到(切分或向量化的问题),要么召回到了但块里没有答案(切分的问题)。
第二步,数 K 够不够。 拿真实 query 数需求,一句两个需求的,K 至少 3。这一步解决的是"答得不全"。
第三步,查红线。 问三个库里没有的东西,看它拒答还是编。这一步解决的是"没有资料时胡说"。
第四步,才是模型。 前三步都过了还答不好,再考虑换对话模型。这时候换了通常也没用,因为问题不在它。
这个顺序的价值在于:前两步各花五分钟,第四步要重跑一整套测试。倒过来做,你会花两小时换模型,然后发现问题依旧。
十二、上线前必做的一件事
调完 K、写完红线,我建议你抽半小时当一回"刁钻用户"。
三类题各造 5 条:
- 一句话多个需求——暴露静默失败。这类题最阴,因为答出来完全正常。
- 库里没有的品类——暴露编造。挑几个你没上架的东西问。
- 带假前提的——暴露认账。故意说"听说你们含激素",看它是先道歉还是先纠正。
15 条题跑一遍,比 200 条正常问题的信息量大。正常问题它总能答好,翻车永远在刁钻的地方。
下一篇把工具和知识库合起来,做一个会查会答的完整客服。
代码已通过 py_compile 语法校验。运行环境:WSL Ubuntu 24.04 / Python 3.12 / agentscope 2.x。
更多推荐


所有评论(0)