RAG 在野外:两周 Chunking 实验后的收获

发布: (2026年3月9日 GMT+8 07:09)
14 分钟阅读
原文: Dev.to

Source: Dev.to

请提供您希望翻译的正文内容,我将为您把它翻译成简体中文。

三个月前,我交付了一个让我真正自豪的 RAG 流水线

在内部文档上进行语义搜索,使用 OpenAI 嵌入,后端是 Pinecone。感觉很现代。后来我们团队里有人问它 “我们的产假政策是什么?”,它却自信地返回了一个完全捏造的三段答案——把一份旧的 HR 文档、一页关于 PTO 的 Confluence 页面,以及我只能猜测的“氛围”拼凑在一起。

这就是我的警钟。嵌入模型没有坏,向量数据库也没有坏。检索步骤——我基本上是从教程里复制粘贴后直接用了的——才是问题所在。我接下来的两周里疯狂地修复它,以下是我的发现。

你的块大小可能不对(我的就是)

大多数教程告诉你把块大小设为 512 token,然后完事。我照做了。对短的事实查询还能凑合,但一旦问题需要在更长的文档中综合信息——比如跨越三个章节并有交叉引用的政策——就会崩溃。

块大小优点缺点
小块(≈512 token)检索精度更高(相关句子更容易进入 top‑k 结果)上下文被剥离,答案质量受损
大块(≈1,200 token)保留上下文,适合综合精度降低;相关信息可能埋在大块深处

我在我们的文档语料库(≈800 份文档,包括 Markdown 和 PDF)上做了受控实验。三种策略:

  1. 固定大小切块 – 512 token,重叠 50 token。基线。实现简单,性能可预期。也是我最初的做法。
  2. 语义切块 – 按句子边界切分,然后在检测到语义转变时(通过相邻句子嵌入的余弦距离衡量)将句子分组。我使用 langchainSemanticChunker(LangChain v0.2.x)。产生的块大小在 80 到 600 token 之间,取决于文档结构。
  3. 层级 / 父文档检索 – 为检索存储小块,但当检索到某块时,返回其更大的父块给 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、Weaviatepgvector。我的部署是单节点,服务约 30 人的团队——不是百万用户的产品——所以请结合实际情境看待性能数据。

数据库优点缺点
Pinecone完全托管,Python SDK 干净,基础设施零运维元数据过滤有坑
Qdrant开源,可自行托管,支持过滤和过滤索引文档较少,社区支持相对薄弱
Weaviate支持混合搜索,内置模块化插件部署和调优相对复杂
pgvector直接使用 PostgreSQL,易于集成现有业务缺乏专用向量索引的高级特性,规模受限

(后续内容请见下一部分)

Source: https://example.com/your-original-article

(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——可以工作,但它只是基础。真正的提升来自:

  1. 智能分块(如上所述)。
  2. 层次化检索,在不牺牲精度的前提下为 LLM 提供足够的上下文。
  3. 为你的规模、延迟要求和功能集选择合适的向量库

通过紧抓这三把杠杆,你可以把“自信但捏造”的答案转变为可靠、基于事实的响应——这正是任何生产级 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 条我们实际文档中手动挑选的问答对构建了自己的评估系统。

我跟踪的四个指标

  1. Faithfulness — 答案是否严格依据检索到的上下文?
  2. Answer relevancy — 答案是否真正响应了提问?
  3. Context precision — 检索到的片段是否相关?
  4. 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 对问答,也足以告诉你改动是有帮助还是有害。没有评估,你只能凭感觉迭代,我就是在这条路上吃了两周的苦。
0 浏览
Back to Blog

相关文章

阅读更多 »