Featured image of post 第二话|怎么根据用户的提问找到相关的退货政策?

第二话|怎么根据用户的提问找到相关的退货政策?

从一次退货咨询出发,亲手把查询和政策片段变成向量,计算余弦相似度并取 Top-K,理解语义检索能解决什么、又不能替你保证什么。

系列 GYR(Get Your RAG) 第 2 / 2 篇
主题 RAGEmbedding语义检索AI Agent

“这双鞋七天内能退吗?”

NovaShop 的资料里没有这句话。资料写的是:“NovaTrail X2 签收后 7 个自然日内可申请退货;商品需保持完好且配件齐全。”

两句话说的是同一件事,字面上却对不上。如果助手只会查关键词,这个问题很可能直接被漏掉——用户不会觉得是“没搜到”,只会觉得这个助手不懂人话。

这一话就把这次匹配拆开,看三件事:问题怎样变成数字,三条政策怎样被排出先后,以及“排第一”到底意味着什么。

先别急着谈向量

先准备三段极小的候选资料:

标识候选片段
return-policyNovaTrail X2 签收后 7 个自然日内可申请退货;商品需保持完好且配件齐全。
shipping-policyNovaTrail X2 标准配送通常在付款后 1 至 3 个工作日发货,偏远地区时效可能延长。
size-guideNovaTrail X2 提供黑色 M 码与 L 码;下单前请参考尺码表确认脚长。

如果只做关键词匹配,“七天内能退”里的“退”,和资料里的“申请退货”,靠的是同一个意思,而不是同一个词。想接住这种说法,传统做法是在词表里补同义词规则——把“退”“寄回去”“不合适”都映射到“退货”上。

规则不是没有价值。SKU、订单号这类精确标识,恰恰适合这种方式:用户原样报出“NS-X2-BLK-M”,关键词匹配又快又准。

可客服问题远不止 SKU 和订单号。用户会说“能退吗”“不喜欢能不能寄回去”“尺码不合适怎么办”,把每种说法都写进词表,维护成本很快失控。

Embedding 换了个思路:不比字符串,先把文本变成一串数字(向量),再比较两段文本在这个空间里离得近不近。阿里云百炼把 Embedding 定义为把文本等数据转换为数值向量,用于语义搜索等下游任务。官方文档

系统比较的到底是什么

候选政策可以提前编码、保存。用户发来问题时,系统只需要编码这一个问题,再拿它和候选向量逐个比较。

这里有三件事不能混:

  1. 查询和候选片段必须用同一个 Embedding 模型编码,否则两者未必落在同一套表示空间里。
  2. 参与比较的两条向量必须等长;维度不同,余弦相似度无从算起。
  3. 得到的只是当前候选集合里的排序,不是“答案正确率”。

这很像 Java 里的 Comparator:它能给对象排出先后,却不会替你判断“排第一的就是正确答案”。向量分数也一样——只负责排队,不负责下结论。

密集检索的通行做法,就是分别编码问题和文本片段,再用相似度挑选候选上下文,DPR 论文用的正是这种双编码器思路。Karpukhin 等,2020 我们选余弦相似度,只是为了把计算摊开看,不代表所有检索系统都这么算。

相似度怎么算

候选片段和查询都变成了向量,“近不近”就变成了数值比较。那这个数值具体怎么算?这一节用余弦相似度回答。

$$ \operatorname{cosine}(q,d)=\frac{q \cdot d}{\lVert q \rVert\lVert d \rVert} $$

q 是查询向量,d 是一条候选片段向量。分子把两个向量逐维相乘再相加(点积),衡量它们在每个维度上是不是朝同一个方向使劲;分母乘上两个向量的长度,避免“向量长分数就高”。结果有直观的含义:同方向的向量分数接近 1,垂直的接近 0,反方向接近 -1。排序器关心的,就是谁离 1 更近。

余弦公式有两个必须预先处理的边界:两条向量要同维、非零。维度不一致,逐维相乘根本对不上;零向量没有方向,分母一除就崩。排序器在这里主动报错而不是硬算,是为了让配置错误早点暴露,而不是让一个坏分数混进排序。

def cosine_similarity(left: Sequence[float], right: Sequence[float]) -> float:
    """计算两个同维、非零向量的余弦相似度。"""
    if len(left) != len(right):
        raise ValueError("向量维度不一致,不能计算余弦相似度")

    # 点积衡量两个向量在各维度上的同向程度。
    dot_product = sum(a * b for a, b in zip(left, right, strict=True))
    left_norm = math.sqrt(sum(value * value for value in left))
    right_norm = math.sqrt(sum(value * value for value in right))

    # 零向量没有方向;继续计算会产生除零错误,也没有检索意义。
    if left_norm == 0 or right_norm == 0:
        raise ValueError("零向量没有方向,不能计算余弦相似度")

    return dot_product / (left_norm * right_norm)

这一段代码和公式一一对应:先检查输入,再算点积,再算两个长度,最后相除。拒绝分支不是额外负担,它就是“余弦在不同输入上的定义边界”。

只取前 K 名

三个候选都有分数了,接下来怎么挑?答案朴素:按分数从高到低排,取前 K 个。这就是 Top-K 里的“K”。

排序里有一个容易忽略的小决策:分数并列时怎么办。rank_candidates 用原始下标做第二排序键,保证同一份输入反复运行得到同一份顺序——方便测试和复现,但不代表两个并列候选在业务上有高低。

def rank_candidates(
    query_vector: Vector,
    candidates: Sequence[Candidate],
    candidate_vectors: Sequence[Vector],
    *,
    top_k: int,
) -> list[RankedCandidate]:
    """按余弦相似度降序返回前 ``top_k`` 个候选。"""

    if len(candidates) != len(candidate_vectors):
        raise ValueError("候选片段数量与候选向量数量必须一致")
    if top_k <= 0:
        raise ValueError("top_k 必须大于 0")

    ranked_with_index = [
        (index, RankedCandidate(candidate, cosine_similarity(query_vector, vector)))
        for index, (candidate, vector) in enumerate(zip(candidates, candidate_vectors))
    ]
    ranked_with_index.sort(key=lambda item: (-item[1].score, item[0]))
    return [item[1] for item in ranked_with_index[:top_k]]

RankedCandidate 只是把候选片段和分数绑在一起的容器。真正的逻辑只有三步:逐条算分数、按分数降序排(分数打平看原始下标)、切片取前 top_k

用一个玩具例子看:查询向量 [1.0, 0.0],三个候选向量是 [1.0, 0.0][2.0, 0.0][0.0, 1.0],分数分别是 1.0、1.0、0.0。取 top_k=2,结果是前两个候选——同分时保住原始顺序,第三个出局。

先把本地验证跑起来

排序逻辑不依赖云端,验证也先离线做。运行:

cd /Users/renxin/ai_brain/GYR
python3.11 -m unittest discover -s c02-embedding-topk/tests -v

测试喂的全是固定小向量,把排序器的不变量直接写进断言:同方向向量 [3.0, 4.0][6.0, 8.0] 的余弦必须是 1.0;并列分数的三个候选取前两名必须稳定得到 firstsecond

self.assertAlmostEqual(cosine_similarity([3.0, 4.0], [6.0, 8.0]), 1.0)

results = rank_candidates(
    [1.0, 0.0],
    candidates,
    [[1.0, 0.0], [2.0, 0.0], [0.0, 1.0]],
    top_k=2,
)
self.assertEqual([result.candidate.chunk_id for result in results], ["first", "second"])

这次实际运行 4/4 通过:同向向量、零向量拒绝、维度不一致拒绝、并列分数稳定排序。

为什么坚持先离线验证?因为云端调用不可重放——每次可能在小数后几位漂移,还要消耗配额;而排序逻辑是确定性的,它该在本地一次测对。云端只负责一件事:把文本变成向量。

接上真实模型

本地排序器验证完毕,现在把玩具向量换成百炼 Embedding 的真实输出。接云端要做三件事:设环境变量、跑 main.py、看输出。

请求走的是 OpenAI 兼容接口的 /embeddings:把模型名和输入文本发过去,拿回一串浮点数。Base URL 和 API Key 从环境变量读取,不要写进代码、文章或 Git。

export DASHSCOPE_API_KEY='你的密钥'
export DASHSCOPE_COMPATIBLE_BASE_URL='https://你的业务空间域名/compatible-mode/v1'
python3.11 c02-embedding-topk/main.py --top-k 3

这次真实运行(模型 qwen3.7-text-embedding,请求 1024 维、实际返回 1024 维)的输出是这个形状,以第一个问题为例(候选正文为排版截断,分数与排序未改):

model=qwen3.7-text-embedding
requested_dimension=1024
actual_dimension=1024

query=这双鞋七天内能退吗?
1. return-policy	score=0.650210	NovaTrail X2 签收后 7 个自然日…
2. shipping-policy	score=0.445476	NovaTrail X2 标准配送通常在付款后…
3. size-guide	score=0.418887	NovaTrail X2 提供黑色 M 码与 L 码…

模型、地域和业务空间配置都可能变化,接入时以你的控制台和官方接口说明为准。

这里有一个容易忽略的边界。main.py 把“候选片段”和“用户问题”分成两批请求,代码里保留了 documentquery 两种角色,这是为了对齐百炼“检索任务建议区分查询和文档”的惯例;但我们这次用的是 OpenAI 兼容模式,接口里没有对应的角色字段——本地变量叫 query,不代表服务端真的按“查询模式”去编码。

跑出来的结果:直觉对了,边界也露出来了

三个问题都跑完,Top-1 汇总如下:

用户提问Top-1 候选分数能说明什么
这双鞋七天内能退吗?return-policy0.650210这次口语化退货问题把退货片段排在第一。
下单后多久能发货?shipping-policy0.614013换成物流问题后,同一候选集的第一名随之变化。
NS-X2-BLK-M 什么时候发货?shipping-policy0.686929“发货”语义占了主导;不能据此证明 SKU 精确检索可靠。

顺带说明:同一模型、同一候选集,分数会在第四位小数附近轻微漂移,这正是“分数不能当阈值”的一部分。以上面这次运行为准。

第三行最容易让人误判。“NS-X2-BLK-M 什么时候发货”确实把“发货政策”排到了第一,看着像答对了方向;可候选资料里根本没有这件 SKU 的发货记录。这个结果只能说明“发货”两个字在语义上占了上风,证明不了 SKU 检索可用。

分数能做什么、不能做什么

开篇承诺的三件事,到这里都能回答了:问题是怎样变成数字的(Embedding),三条政策是怎样排出先后的(余弦相似度 + Top-K),排第一意味着什么——它意味着“在当前候选集合里最靠前”,仅此而已。

Top-K 的作用很朴素:把后续要看的资料缩小到几段。至于分数本身,别指望它替你判断下面这些事:

不能直接据此判断原因
最终答案正确回答仍可能误读、遗漏或编造候选内容。
证据足够高分只表示相对靠前,不代表覆盖了回答所需条件。
可以忽略精确词SKU、订单号、政策版本等字段常需要过滤或关键词能力补充。
一个分数阈值通吃分数依赖模型、候选集合、切分方式和相似度定义。

一句话收束:排第一不等于答案可信。 0.65 只说明“退货片段排在最前”,不说明“这条退货规则就是答案”。所以 Agent 的检索工具到这里就该停下——它交回候选证据和来源,而不是把分数包装成“答案可信”。之后的回答、过滤、引用、拒答,仍然是编排层要承担的事。

结尾:一整篇政策,只有一个分数

前面用到的候选都是精心切成一段的小文本。如果候选本身就是一整篇长政策呢?我们把退货政策扩成六个条款:

NovaTrail X2 退货政策:签收后 7 个自然日内可申请退货;商品需保持未使用状态、包装完整,并保留全部配件与购买凭证;因商品质量问题退货时,运费由 NovaShop 承担,退款将在收货后 3 个工作日内原路退回;因个人原因(尺码、喜好等)退货时,运费由买家承担;特价清仓商品与定制商品不支持无理由退货;运输途中损坏的商品可以申请换货。

问它:“鞋收到是坏的,退货运费谁出?”(环境变量沿用上一节,运行 main_long_policy.py

这次的真实结果:

-- 整篇政策作为一个候选(整篇只有一个分数)--
1. return-policy-full	score=0.585282	NovaTrail X2 退货政策:…

-- 同一篇政策拆成条款逐条打分(预告第三话的切分视角)--
1. clause-4	score=0.708807	因个人原因(尺码、喜好等)退货时,运费由买家承担;
2. clause-3	score=0.697954	因商品质量问题退货时,运费由 NovaShop 承担,退款将在收货后 3 个工作日内原路退回;
3. clause-6	score=0.675624	运输途中损坏的商品可以申请换货。

整篇政策只有一个分数 0.585282——我们只知道“这篇政策相关”,却不知道是哪个条款把它顶上去的。拆成条款后,三条与“运费/损坏”相关的条款全部浮到前面,定位明显变细。

这里还有一层诚实的噪声:排第一的是条款 4(“个人原因,买家承担”),而不是条款 3(“质量问题,NovaShop 承担”)。“坏的”按语义应该指向质量问题,但“运费谁出”的表层表达把条款 4 顶了上来。也就是说:分块让“哪一段相关”变得可见,但并没有让排序变成正确——检索找到候选是一回事,候选能不能支撑回答是另一回事,那要等后面的章节处理。

第二话到这里,检索单元的粒度问题就摆上台面了:一整篇政策只算一个候选片段,粒度还是太粗。长文档到底该切成多大一块,才能既找得到规则,又不丢掉规则的上下文?

参考资料

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。