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 里,每轮都在最前面,权重稳定。

十、接口速查

类 / 方法关键参数作用
DashScopeEmbeddingModelmodel、dimensions文本转向量,dimensions 必填
DeepSeekChatModelmodel、stream对话模型,stream=False 干净退出
await model(texts)文本列表返回 .embeddings 列表
Agentname、model、system_prompt单 Agent,system_prompt 里放红线

十一、回答不准时的排查顺序

调参调崩的时候,最容易犯的错是先换模型。模型是最贵的那个变量,应该最后动。

我现在的顺序是固定的四步:

第一步,打印召回。 把命中的块原文打出来,人眼看一遍。这一步能解决八成问题——要么压根没召回到(切分或向量化的问题),要么召回到了但块里没有答案(切分的问题)。

第二步,数 K 够不够。 拿真实 query 数需求,一句两个需求的,K 至少 3。这一步解决的是"答得不全"。

第三步,查红线。 问三个库里没有的东西,看它拒答还是编。这一步解决的是"没有资料时胡说"。

第四步,才是模型。 前三步都过了还答不好,再考虑换对话模型。这时候换了通常也没用,因为问题不在它。

这个顺序的价值在于:前两步各花五分钟,第四步要重跑一整套测试。倒过来做,你会花两小时换模型,然后发现问题依旧。

十二、上线前必做的一件事

调完 K、写完红线,我建议你抽半小时当一回"刁钻用户"。

三类题各造 5 条:

  1. 一句话多个需求——暴露静默失败。这类题最阴,因为答出来完全正常。
  2. 库里没有的品类——暴露编造。挑几个你没上架的东西问。
  3. 带假前提的——暴露认账。故意说"听说你们含激素",看它是先道歉还是先纠正。

15 条题跑一遍,比 200 条正常问题的信息量大。正常问题它总能答好,翻车永远在刁钻的地方。

下一篇把工具和知识库合起来,做一个会查会答的完整客服。


代码已通过 py_compile 语法校验。运行环境:WSL Ubuntu 24.04 / Python 3.12 / agentscope 2.x。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐