AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

稀疏、稠密与混合检索

我的学习 →

正在读取学习记录…

检索是从资料库中挑出与问题有关的候选资料。稀疏检索比较词项,适合精确术语;稠密检索比较文本的数字表示,尝试找到措辞不同但含义接近的资料;混合检索合并两路结果,减少单一路径的遗漏。

“词项”就是拿来搜索的词,例如“退款”“到账”。把资料库的所有词看成一张很长的清单,一篇文章通常只用其中少量词,其余位置是0,这样的表示叫稀疏。文本经嵌入模型转换后,会得到一组通常多数位置有数值的数字,称为稠密向量。前一路像查书后面的关键词索引,后一路比较文章在数字空间中有多接近。

用退款资料观察两路检索

客户问“退款什么时候到账”。词项检索容易找到直接写着退款的规则,也会找到退款申请说明。稠密路径用人工给定的二维业务特征观察相似度:第一维表示到账时间,第二维表示申请流程。两路排序会怎样合并?

P1写“退款 到账 三至五日”,P2写“原支付渠道 入账 三至五日”,P3写“退款 申请 订单编号”,D1写“配送 两天”。词项查询使用“退款 到账”;人工查询向量设为(1,0),表示只关心到账维度。后面的向量数值可以直接按公式核对。

先把 (1,0) 看成一支向右的箭头,P2的 (0.9,0.1) 也几乎向右,P3的 (0.1,0.9) 则主要向上。比较箭头方向,可以看到P2更靠近问题的方向。

横轴到账时间、纵轴申请流程,查询与P1向右,P2贴近横轴,P3贴近纵轴
查询和P1的方向相同;P2与它们夹角很小,P3的夹角较大。坐标对应代码中给定的两项特征。

余弦相似度把方向接近程度算成数值。两支箭头同向时为1,垂直时为0。设查询为 q=(1,0),资料向量为 v=(x,y),计算如下:

TXT
cosine(q, v) = (1×x + 0×y) / (√(1²+0²) × √(x²+y²))
P2:(1×0.9 + 0×0.1) / √(0.9²+0.1²) ≈ 0.994
P3:(1×0.1 + 0×0.9) / √(0.1²+0.9²) ≈ 0.110

所以向量一路把P2排在P3之前。关键词一路则给P3一个“退款”命中。混合检索接下来要综合这两份排名。

保存完整程序并运行

先在 PowerShell 创建并进入学习目录:

Bash
mkdir "F:\学习\l5-retrieval" -Force
cd "F:\学习\l5-retrieval"

保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。

新建学习目录,例如 F:\学习\l5-retrieval。将下面代码完整保存为 retrieval_demo.py;代码包含固定资料和运行入口,保存后就能执行。

先在 PowerShell 创建并进入学习目录:

Bash
mkdir "F:\学习\l5-retrieval" -Force
cd "F:\学习\l5-retrieval"

保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。

Python
"""比较词项命中、固定向量余弦与名次融合。"""
import math

DOCUMENTS = {
    "P1": {"text": "退款 到账 三至五日", "vector": (1.0, 0.0)},
    "P2": {"text": "原支付渠道 入账 三至五日", "vector": (0.9, 0.1)},
    "P3": {"text": "退款 申请 订单编号", "vector": (0.1, 0.9)},
    "D1": {"text": "配送 两天", "vector": (0.0, 1.0)},
}

def sparse(query, documents):
    terms = set(query.split())
    scores = [(doc_id, len(terms & set(doc["text"].split())))
              for doc_id, doc in documents.items()]
    return sorted((row for row in scores if row[1] > 0),
                  key=lambda row: (-row[1], row[0]))

def cosine(left, right):
    denominator = math.sqrt(sum(v*v for v in left) * sum(v*v for v in right))
    return sum(a*b for a, b in zip(left, right)) / denominator if denominator else 0.0

def dense(vector, documents):
    scores = [(doc_id, cosine(vector, doc["vector"])) for doc_id, doc in documents.items()]
    return sorted((row for row in scores if row[1] > 0),
                  key=lambda row: (-row[1], row[0]))

def fuse(rankings, k=60):
    scores = {}
    # 名次从1开始,越靠前贡献越大,避免混加不同量纲的原始分值。
    for ranking in rankings:
        for rank, (doc_id, _) in enumerate(ranking, 1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1 / (k + rank)
    return sorted(scores.items(), key=lambda row: (-row[1], row[0]))

def main():
    lexical = sparse("退款 到账", DOCUMENTS)
    semantic = dense((1.0, 0.0), DOCUMENTS)
    print("词项排名:" + "、".join(f"{key}={score}" for key, score in lexical))
    print("固定向量排名:" + "、".join(f"{key}={score:.3f}" for key, score in semantic))
    print("融合排名:" + "、".join(f"{key}={score:.5f}" for key, score in fuse([lexical, semantic])))

if __name__ == "__main__":
    main()

在保存文件的文件夹地址栏输入 powershell 并回车,再执行:

macOS:先在 Python 篇的学习文件夹中打开终端,执行 source .venv/bin/activate。再输入 cd ,把当前例子的文件夹拖入终端并回车,运行下面的命令。

Bash
cd "F:\学习\l5-retrieval"
ls
python -X utf8 retrieval_demo.py

使用其他目录时,把第一行换成自己的路径。dir 应能列出 retrieval_demo.py。终端输出如下:

TXT
词项排名:P1=2、P3=1
固定向量排名:P1=1.000、P2=0.994、P3=0.110
融合排名:P1=0.03279、P3=0.03200、P2=0.01613

P1命中退款和到账两项,词项分数2;P3只命中退款,分数1。P2用“原支付渠道、入账”描述同一业务,没有命中查询词,却在人工业务向量中靠近到账维度。融合时P1两路都排第一,分值最高;P3同时出现于两路,因此可能超过仅在一路出现的P2。融合仍需后续精排来区分申请与到账。

两路排序为何需要融合

sparse计算查询词项与文档词项集合的交集数量,得到透明的关键词排序。实际BM25还会考虑词频、文档长度和词项稀有程度;词项越常见,区分能力通常越弱。型号、订单号、错误码等精确字符适合词项路径。

dense计算余弦相似度,即两条数字向量的方向有多接近。这里先用有明确含义的两个坐标理解计算;实际嵌入由模型生成,通常有更多维度,每一维的含义要结合模型理解。检索质量通过真实问题与资料的匹配结果评估。

fuse实现RRF,中文叫倒数名次融合。每条路径第r名贡献1/(60+r),同一文档的贡献累加。它只需要名次,避免将关键词计数与余弦数值直接相加。文档编号统一才能跨路径去重;每路候选数和常数k也会影响结果。

对照输出中的小数,逐项代入名次就能算出来:

TXT
P1:两路都排第1 → 1/(60+1) + 1/(60+1) ≈ 0.03279
P3:词项第2、向量第3 → 1/(60+2) + 1/(60+3) ≈ 0.03200
P2:只在向量路第2 → 1/(60+2) ≈ 0.01613

P3因两路都出现而排在P2之前;这正是后续重排要继续检查的问题:综合排名靠前的片段,是否真的回答了到账时间。

Recall@k表示正确依据有多少进入前k个候选。它检查“有没有找回来”,不能说明最终回答是否正确。在固定问题集上标出正确资料,再比较稀疏、稠密和混合的遗漏;同时观察误召回、耗时与上下文成本。权限、渠道、资料版本应在每路召回及最终合并阶段共同约束。

查询:退款 到账;词项路:P1 → P3;固定向量路:P1 → P2 → P3;按文档编号融合:每路1/(60+名次);候选排名:P1 → P3 → P2
词项补精确匹配,向量补不同措辞,名次融合合并候选。

改一处输入,核对机制变化

把 main 中查询向量 (1.0, 0.0) 改为 (0.0, 1.0),运行 python -X utf8 retrieval_demo.py。固定向量一路变为D1=1.000、P3=0.994、P2=0.110,P1不进入这一路;词项排名保持P1、P3。查询方向改变了向量信号,关键词信号保持原样。恢复向量再重跑。

运行配套检查

把下面测试保存为 test_retrieval_demo.py,与主程序放在同一目录。5项测试检查上面的实际处理结果,也覆盖同义措辞、融合贡献和空结果。

查看测试代码:test_retrieval_demo.py
Python
"""核对稀疏、稠密与混合检索的正常结果与具体边界。"""
import unittest
import retrieval_demo as demo


class DemoTests(unittest.TestCase):
    def test_sparse_prefers_two_term_match(self):
        # 两个查询词命中优先于单个词命中。
        self.assertEqual(demo.sparse("退款 到账", demo.DOCUMENTS), [("P1", 2), ("P3", 1)])

    def test_dense_recovers_different_wording(self):
        # 向量路径补回措辞不同的P2。
        self.assertEqual([row[0] for row in demo.dense((1, 0), demo.DOCUMENTS)], ["P1", "P2", "P3"])

    def test_fusion_adds_two_channel_contributions(self):
        # 同一文档分别累计两路名次贡献。
        result = dict(demo.fuse([[("P1", 2)], [("P1", 1), ("P2", 0.9)]]))
        self.assertAlmostEqual(result["P1"], 2 / 61)
        self.assertAlmostEqual(result["P2"], 1 / 62)

    def test_no_term_hit_and_zero_vector(self):
        # 无词项命中与零向量得到空或零结果。
        self.assertEqual(demo.sparse("海外", demo.DOCUMENTS), [])
        self.assertEqual(demo.cosine((0, 0), (1, 0)), 0)

    def test_fusion_empty_and_stable_tie(self):
        # 空列表与分数平局都有稳定处理。
        self.assertEqual(demo.fuse([]), [])
        self.assertEqual([key for key, _ in demo.fuse([[("B", 1)], [("A", 1)]])], ["A", "B"])


if __name__ == "__main__":
    unittest.main()
Bash
python -X utf8 -m unittest -v test_retrieval_demo.py

测试名称后应显示 ok,末尾是 Ran 5 tests 和 OK。

运行时怎样定位问题

看到的情况检查与操作
找不到 retrieval_demo.py运行 dir,切换到保存主程序的目录
换成入账后词项结果变少加入同义词改写或采用稠密路径
混合分数被某一路压过采用名次融合或校准分值,再核对候选集合
准确订单号搜不到保留原编号并加精确词项检索

遇到口语与资料术语不一致时,查询改写可以补充检索表达。

参考阅读:LlamaIndex 源码与检索流程。阅读时可以沿文档、节点、索引和检索入口对照这里的数据流。

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.1

选择题 · 先判断边界

关于“稀疏、稠密与混合检索”,哪种做法更稳妥?

对应知识点:L5-retrieval

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 解释失败

写出一个失败输入并说明记录字段。

对应知识点:L5-retrieval

练习 3 / 4 · v1.0.0

代码题 · 边界测试

补充正常、边界、失败三组输入和预期结果。

对应知识点:L5-retrieval

练习 4 / 4 · v1.0.0

场景题 · 结果未知

证据不足时如何设计状态和人工接管?

对应知识点:L5-retrieval

下一篇:查询改写与多查询检索。

图片查看器
100%

文章配图 · 按 Esc 关闭