RAG 在野外:两周 Chunking 实验后的收获
Source: Dev.to
请提供您希望翻译的正文内容,我将为您把它翻译成简体中文。
三个月前,我交付了一个让我真正自豪的 RAG 流水线
在内部文档上进行语义搜索,使用 OpenAI 嵌入,后端是 Pinecone。感觉很现代。后来我们团队里有人问它 “我们的产假政策是什么?”,它却自信地返回了一个完全捏造的三段答案——把一份旧的 HR 文档、一页关于 PTO 的 Confluence 页面,以及我只能猜测的“氛围”拼凑在一起。
这就是我的警钟。嵌入模型没有坏,向量数据库也没有坏。检索步骤——我基本上是从教程里复制粘贴后直接用了的——才是问题所在。我接下来的两周里疯狂地修复它,以下是我的发现。
你的块大小可能不对(我的就是)
大多数教程告诉你把块大小设为 512 token,然后完事。我照做了。对短的事实查询还能凑合,但一旦问题需要在更长的文档中综合信息——比如跨越三个章节并有交叉引用的政策——就会崩溃。
| 块大小 | 优点 | 缺点 |
|---|---|---|
| 小块(≈512 token) | 检索精度更高(相关句子更容易进入 top‑k 结果) | 上下文被剥离,答案质量受损 |
| 大块(≈1,200 token) | 保留上下文,适合综合 | 精度降低;相关信息可能埋在大块深处 |
我在我们的文档语料库(≈800 份文档,包括 Markdown 和 PDF)上做了受控实验。三种策略:
- 固定大小切块 – 512 token,重叠 50 token。基线。实现简单,性能可预期。也是我最初的做法。
- 语义切块 – 按句子边界切分,然后在检测到语义转变时(通过相邻句子嵌入的余弦距离衡量)将句子分组。我使用
langchain的SemanticChunker(LangChain v0.2.x)。产生的块大小在 80 到 600 token 之间,取决于文档结构。 - 层级 / 父文档检索 – 为检索存储小块,但当检索到某块时,返回其更大的父块给 LLM。这才是真正提升效果的办法。
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 子块 —— 实际嵌入并搜索的内容
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# 父块 —— LLM 实际看到的内容
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=800)
store = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# 索引子块,但存储父块
retriever.add_documents(docs)
# 查询时,检索在子块嵌入上进行,
# 但返回的上下文是完整的父块
results = retriever.invoke("what is the parental leave policy?")
结果(100 对手工标注的 QA)
| 策略 | 准确率 |
|---|---|
| 固定大小(512 token) | 61 % |
| 语义切块 | 68 % |
| 父文档检索 | 79 % |
要点: 先用固定大小(≈512 token)做基线。然后在投入语义切块之前,先尝试父文档检索。对大多数真实语料库而言,语义切块的复杂度‑收益比令人失望。
选型向量数据库时不让自己抓狂
我测试了四个选项:Pinecone、Qdrant、Weaviate 和 pgvector。我的部署是单节点,服务约 30 人的团队——不是百万用户的产品——所以请结合实际情境看待性能数据。
| 数据库 | 优点 | 缺点 |
|---|---|---|
| Pinecone | 完全托管,Python SDK 干净,基础设施零运维 | 元数据过滤有坑 |
| Qdrant | 开源,可自行托管,支持过滤和过滤索引 | 文档较少,社区支持相对薄弱 |
| Weaviate | 支持混合搜索,内置模块化插件 | 部署和调优相对复杂 |
| pgvector | 直接使用 PostgreSQL,易于集成现有业务 | 缺乏专用向量索引的高级特性,规模受限 |
(后续内容请见下一部分)
(cardinality limits); pricing penalises large metadata payloads; early‑2025 bug with high‑cardinality string filters | | Qdrant | Open source, Docker‑ready, expressive query API, hybrid (sparse + dense) search, Rust core = fast | Docs have gaps; async Python client a bit rough (v1.7) | | Weaviate | Built‑in BM25, native hybrid search, GraphQL interface; great for multi‑modal retrieval | Large surface area; overkill for simple RAG pipelines | | pgvector | Leverages existing Postgres, HNSW index gives decent latency up to a few hundred k vectors | Not tested beyond that scale; may need tuning for larger corpora |
我的推荐
- 完全托管且不需要混合搜索 → Pinecone
- 需要开箱即用的混合搜索 → Qdrant
- 已经在使用 Postgres 且数据量 ≤ 500 k 块 → pgvector(这是一个合法的一线选择,而不仅仅是备选方案)
检索步骤是大多数 RAG 流程性能瓶颈所在
基础向量搜索——对查询进行嵌入,找到最近邻,然后把 top‑k 块喂给 LLM——可以工作,但它只是基础。真正的提升来自:
- 智能分块(如上所述)。
- 层次化检索,在不牺牲精度的前提下为 LLM 提供足够的上下文。
- 为你的规模、延迟要求和功能集选择合适的向量库。
通过紧抓这三把杠杆,你可以把“自信但捏造”的答案转变为可靠、基于事实的响应——这正是任何生产级 RAG 系统应当交付的效果。
rs 是一个合理的起点。它也是人们常常止步的地方,而且它说明了一切。
混合搜索(稀疏 + 密集) 带来了意外的大幅提升。密集嵌入捕捉语义,但在精确关键词匹配——产品名称、错误码、特定版本号——方面表现不佳。稀疏检索(BM25)恰好擅长这些。将两者通过倒数排名融合(RRF)结合,就能兼顾两者优势。
from qdrant_client import QdrantClient, models
# Assuming you've set up a collection with both dense and sparse vectors
results = client.query_points(
collection_name="docs",
prefetch=[
# Dense vector search (semantic)
models.Prefetch(
query=dense_embedding, # your query embedding
using="dense",
limit=20,
),
# Sparse vector search (BM25‑style)
models.Prefetch(
query=models.SparseVector(
indices=sparse_indices,
values=sparse_values,
),
using="sparse",
limit=20,
),
],
# RRF fusion happens here
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=5,
)
混合搜索在包含大量产品专有术语的技术文档上效果最为显著。对于更偏对话或政策类的内容,提升则相对温和。如果你的语料库充斥着行话或版本号,值得投入实现成本。
重新排序(Reranking) 是另一个显著提升的杠杆。完成初始检索(例如 top‑20 块)后,使用交叉编码器对它们重新排序,再交给 LLM。背后的直觉是:双编码器(用于初始检索)对查询和文档分别编码,速度快;交叉编码器则同时考虑查询 + 文档,准确度更高——但在检索规模上太慢,所以只能在候选集合上使用。
我使用了 HuggingFace 上的 cross-encoder/ms-marco-MiniLM-L-6-v2。在 CPU 上对 20 条候选进行重新排序会额外增加约 80 ms 的延迟,这对我们来说是可以接受的。Cohere 的 Rerank API 是托管版的替代方案——我尚未在生产环境中使用,但听说评价不错。
MMR 陷阱:我加入了最大边际相关性(Maximal Marginal Relevance)来降低检索块的冗余,原本以为会有帮助。对某些查询它确实有效,但也会把包含精确相关细节的块过滤掉,因为一个更通用的块排得更高,被认为“太相似”。这提醒我们,在引入去冗余机制时需要仔细评估其对关键信息的影响。
similar.” 我的召回率实际上下降了。我最终禁用了 MMR,改用分块策略来解决冗余问题。不要在没有针对你的特定数据集进行测试之前就假设它是免费的。
- 如果你的语料库关键词密集,实施混合搜索。
- 如果检索质量仍然不理想,加入重新排序器——它通常是单一最高 ROI 的改进。
- 对 MMR 持怀疑态度。
评估这些是否真的有帮助
没人足够谈论这一点:在开始微调之前,你需要一个 eval harness,否则就是盲目操作。我使用 ragas(v0.1.x)和大约 100 条我们实际文档中手动挑选的问答对构建了自己的评估系统。
我跟踪的四个指标
- Faithfulness — 答案是否严格依据检索到的上下文?
- Answer relevancy — 答案是否真正响应了提问?
- Context precision — 检索到的片段是否相关?
- Context recall — 相关信息是否至少出现在检索到的片段中?
我最初的流水线在 Faithfulness 上表现良好(模型没有在检索到的文档之外产生幻觉),但 Context recall 极差——相关片段只有约 60 % 的概率被检出。这就是为什么关于产假(parental‑leave)的答案错误:相关文档根本没有进入前 5 条结果。一旦发现这个问题,解决办法显而易见——改进分块方式并使用混合搜索,以便将 “parental leave” 作为关键词匹配。
如果没有评估设置,我只会继续调 Prompt。这正是陷阱所在。
我今天实际上会构建的东西
- 如果团队已经在使用 Postgres,就从
pgvector开始。它消除了基础设施依赖,对大多数内部工具来说已经足够强大。 - 当遇到扩展性问题或迫切需要混合搜索时迁移到 Qdrant;数据迁移并不痛苦。
嵌入模型
- OpenAI 的
text-embedding-3-large(3072 维)或 nomic-embed-text,一个可靠的开源选项。
我并不认为最新的嵌入模型在大多数 RAG 场景下值得比 text-embedding-3-large 更高的成本——尽管我还没有对最新发布的模型进行基准测试。
检索策略
- 基于父文档的检索 而不是语义切块:实现更简单,调试更容易,在我的测试中性能更好。
- 从一开始就使用混合搜索,前提是你可以自行选择向量数据库。BM25 并未过时。
重排序
- 在将上下文发送给 LLM 之前,先进行一次交叉编码器重排序。其延迟成本是值得的。
评估
- 在做任何其他工作之前先构建评估框架。即使只有 50 对问答,也足以告诉你改动是有帮助还是有害。没有评估,你只能凭感觉迭代,我就是在这条路上吃了两周的苦。