从 LlamaIndex 提炼出两个很有价值的思路:检索粒度和生成粒度不必相同,以及知识库变大后要学会先过滤、再检索。
这一篇承接前面的 Naive-RAG 实战。目标不是切换框架,而是把 LlamaIndex 在索引层提供的两个思路吸收下来,再映射回自己当前的
LangChain + Milvus + FastAPI主线。
RAG - 索引优化
1. LlamaIndex
这一章开始接触 LlamaIndex。它和 LangChain 的定位不完全一样,LangChain 更像一个通用的 LLM 应用框架,链、工具、Agent、RAG 都能做;而 LlamaIndex 更聚焦在"怎么把数据接进 LLM",也就是文档、索引、检索、查询这一套抽象会更清楚一些。
不过,我当前的主线仍然是 LangChain + Milvus + FastAPI,所以这里没必要切到另一个框架去重做一遍 demo。更合适的做法是,把 LlamaIndex 里面对 RAG 有启发的索引优化思想吸收下来,看懂它到底在解决什么问题,之后再映射回我自己的项目。
这一章主要记两种索引优化:
- 上下文拓展
- 结构化索引
2. 上下文拓展
RAG 里一直有一个很经典的矛盾:如果 chunk 切得很小,检索时通常更精确,因为语义更集中,更容易命中真正相关的那一句或那一小段;但与此同时,小 chunk 给 LLM 的上下文又太少,最后回答可能不完整,甚至语义不连贯。反过来,如果 chunk 一开始就切得很大,那上下文当然更完整,但检索时又容易混入很多无关信息,召回虽然"看起来相关",其实噪音会明显增加。
LlamaIndex 针对这个问题给出的一个很直观的思路就是句子窗口检索(Sentence Window Retrieval)。它不是简单地说"应该切大一点"或者"应该切小一点",而是把检索阶段和生成阶段分开看:检索时,仍然用非常小的单位去找;但在交给 LLM 生成答案之前,再把它恢复成一个更大的上下文窗口。
它的核心代码并不复杂:
Python3
点击展开代码
展开代码
乍一看其实也没有什么魔法,核心就两步:
第一步,SentenceWindowNodeParser 会把文档切成一个个句子。注意这里不是普通的固定长度切块,而是按句子切。每个句子都会变成一个节点,同时它还会额外保存一段"窗口"信息,也就是这个句子前后若干句组成的上下文。
第二步,在真正查询时,检索器先检索的仍然是这些小句子节点。也就是说,相似度匹配依然发生在"小而精"的粒度上。等命中之后,再通过 MetadataReplacementPostProcessor 把节点里原本那一句话,替换成之前存在 metadata 里的整段窗口文本。这样最后送给 LLM 的就不再是孤立的一句话,而是带有前后文的一小段内容。
所以这个方法的本质可以直接记成一句话:
检索时用小块保证精度,生成时用大块补足上下文。
它解决的不是"向量模型不够强",而是 chunk 粒度设计的矛盾。
LlamaIndex 底层怎么做这件事,其实想清楚也不难。它先把文档拆成句子列表,然后遍历每个句子,对第 i 个句子来说,去取前后若干句,把它们拼起来存进 metadata 里。注意这里很关键的一点是:真正参与 embedding 的,还是当前句子本身;窗口信息只是被当成附加元数据,供后处理阶段替换使用。这样才能保证检索时保持高精度,而不是一开始就把大窗口拿去做 embedding。
这一点对我很有启发,因为我现在自己的 Naive-RAG 做法是:
- 先按 markdown 标题切
- 再对过长 chunk 递归切分
- 检索到什么 chunk,就直接把什么 chunk 交给 LLM
这个方案能跑,而且已经能做出一个端到端 demo。但它的问题也很明显:如果后面遇到那种"某一句特别关键,但单句上下文不足"的情况,我就很容易在两个坏选项之间摇摆:
- 把 chunk 切得更小,检索更准,但回答更碎
- 把 chunk 切得更大,上下文更多,但噪音上来
句子窗口检索给的启发其实非常直接:检索粒度和生成粒度,不一定必须相同。
这并不是 LlamaIndex 专属能力,换到我现在的 LangChain + Milvus 项目里也完全能借鉴。比如以后我可以:
- 用更小粒度的句子/短段落建索引
- 在 metadata 里保存所属段落或者前后句窗口
- 检索时命中小块
- 返回给 LLM 时再替换成更大的文本窗口
所以这一节真正值得记住的,不是某个类名,而是这个思路本身。
3. 结构化索引
前面的上下文拓展,主要解决的是 chunk 粒度矛盾;而结构化索引解决的是另一个问题:知识库大了以后,不能总是对全库做无差别检索。
如果知识库里只有几篇文档,那对所有 chunk 直接做 top-k 向量搜索问题不大。但当文档库规模变大,例如几百份 PDF、多个表格、多个年份、多个专题,很多问题其实从一开始就只和一小部分数据有关。这个时候,如果还是在全库里盲搜,不仅效率差,还很容易被无关 chunk 干扰。
这时候就要引入结构化索引。它的本质不是一种新向量模型,而是:除了文本和向量,还给每个 chunk 附加结构化元数据,用这些元数据先做过滤或路由。
这些元数据可以很简单,比如:
- 文件名
- 文档类型
- 日期
- 作者
- 一级、二级、三级标题
- 自定义标签
然后在检索时走两步:
- 先根据 metadata 把搜索范围缩小
- 再在这个缩小后的候选集合里做向量检索
这种"先过滤,再搜索"的思路,在数据规模变大后会非常有用。
这里其实和我当前项目已经能直接对应上了。因为在第二章我就用了 MarkdownHeaderTextSplitter,它会自动把 h1 / h2 / h3 写进每个 chunk 的 metadata 里。后面入 Milvus 的时候,我也把这些字段一起存进去了。所以严格来说,我现在的系统虽然还只是 Naive-RAG,但已经有了结构化索引的雏形。
换句话说:
基于文档结构切块,并保留标题 metadata,本身就是结构化索引的一部分。
这意味着后面我完全可以继续往前走,而不是另起一套:
- 只在某一篇文档里搜索
- 只在某个一级标题下搜索
- 只在某个章节范围里搜索
这些都属于结构化索引的直接应用。
LlamaIndex 在这一块常见会讲到两种比较典型的实现方式。
第一种比较直白,就是 metadata filtering。
也就是先根据结构化信息筛一遍,再做向量搜索。比如问题是"请总结 2023 年第二季度财报里关于 AI 的论述",那就没必要在整个知识库乱搜,而是先限定在:
- 文档类型 = 财报
- 年份 = 2023
- 季度 = Q2
然后再做相似度搜索。
第二种更进一步,是"先路由,再进入目标数据源做检索",也就是递归检索或者分层检索的思路。LlamaIndex 经常举的例子是多工作表 Excel:每个 sheet 单独是一个数据源,先用摘要节点判断问题属于哪个表,再进入那个表里继续查询。
这个例子的代码挺长,但真正需要记住的不是 RecursiveRetriever 或者 PandasQueryEngine 的具体调用,而是它背后的逻辑:
- 先为每个子数据源准备摘要
- 用摘要做第一层路由
- 命中后再进入目标数据源做第二层查询
也就是说,这种方法的核心不是"递归"这个形式,而是:
先决定去哪搜,再决定怎么搜。
这个思路如果放回我自己的项目里,其实也很容易理解。比如以后如果我不只是有一套 RL 笔记,而是有:
- 强化学习
- RAG
- LangChain
- 系统设计
几套完全不同的知识库,那我就没必要每次都把所有内容放在一起无差别检索。更合理的做法是,先判断这个问题大概属于哪个知识域,再进入对应知识域检索。这就是结构化索引更进一步的价值。
另外,LlamaIndex 在多表格例子里还用了 PandasQueryEngine 这类能让 LLM 生成 Pandas 代码并执行的工具。这个思路很强,但它也带来明显的安全问题,因为本质上已经接近"让模型生成代码然后执行"。所以这里我更应该记住的是:
- 结构化索引和路由是有价值的
- 但具体实现方式要考虑安全性
对于我现在的主线来说,最实际的落点还是前面那条:
优先把 metadata 用起来,先做过滤,再做向量检索。
4. 关于框架
框架是加速开发的强大工具,是帮助我们快速跨越技术鸿沟的"桥梁"。但任何桥梁都有其设计边界和局限性。我们的目标不是成为一个熟练的"过桥者",而是成为一个懂得如何设计和建造桥梁的"工程师"。
如果希望深入某个框架的细节,官方文档永远是最好的选择。但是现在的学习,是为了建立起关于RAG的坚实知识体系,这样在切换工具的时候也能游刃有余。
专题阅读
RAG
这篇文章属于同一条阅读链。你可以直接在这里切换,不用再回到列表页重新找。
部分信息可能已经过时
留言区
留言
欢迎纠错、补充、交流。昵称和评论内容必填;如果你愿意,也可以留下联系方式,仅站主可见。