夜雨聆风学习资料网

ARTICLE · 1243146

转岗FDE的RAG课 2: 文档怎么切——切块与入库

转岗FDE的RAG课 2: 文档怎么切——切块与入库
本文是“AI之旅”合集 转岗FDE的RAG课 系列的第2篇。
文档不能整篇塞给模型,得先切成块再入库。这篇用开发文档问答助手的例子,讲切太碎与切太大各出什么问题、为什么要按文档结构切,以及怎样用 Contextual Retrieval 给每个块补上“出身”,最后说明入库时该存什么。
图片制作:Nano Banana 2

本系列涉及的模型名、参数和数字,均以 2026 年 10 月 16 日的官方文档和文章为准。

为什么不能整篇文档塞进去

上一篇转岗FDE的RAG课 1: 模型没读过我们的文档——RAG 的整体流程与 embedding我们走通了最小的 RAG:建索引,提问时找出相关内容,再交给模型作答。当时为了省事,有一个细节被一笔带过:索引里存的“一条”到底是什么。
最直觉的做法是一篇文档存一条。比如我们给移动端团队做的开发文档问答助手,语料用的是 Flutter 官方文档,那就是一整页“列表与滚动”存成一条向量。
这样做会出两个问题。
第一,一条向量只能代表一个整体的“大意”。一页文档里可能讲了五件事,向量算出来的是五件事的混合味道,用户问其中一件时,匹配度反而不高。就像给一首歌打一个标签叫“流行”,没法靠它找到副歌那一句歌词。
第二,就算找到了,整页塞进模型也不划算。输入越长越贵,真正有用的往往只有其中两三段,其余的是干扰。
所以要先把文档切成一块一块,每块单独存、单独找。块就是检索的最小单位,用英文说叫 chunk。

切太碎与切太大,各出什么问题

做过移动端的人,对这个问题应该有手感:它很像列表 cell 的粒度。
一个 cell 做得太大,比如把整个页面塞进一个 cell,加载慢,复用不起来,用户只想看一行字,却被迫拿到一大堆东西。块切太大,也是一样:一个块里夹着好几件事,向量变得含糊,找回来后塞进模型的杂质也多。
一个 cell 做得太碎,比如每个文字标签都单独做成 cell,单拿出来谁也看不出它是干什么的。块切太碎,问题同理:一句话“它默认是关闭的”,单独拿出来,谁都不知道“它”是什么。检索到了这块,模型也答不出来;或者更糟,检索根本匹配不上。
两头都有问题,那么中间的合适位置在哪里?
这里要老实说:没有一个通用的最优块大小。Anthropic 在《Introducing Contextual Retrieval》里,只是说块通常控制在不超过几百个 token,同时强调块大小、块边界和块重叠都会影响检索效果,值得针对自己的语料去试。网上流传的各种“默认 500 字、重叠 50 字”,如果找不到出处,就当作某个工具恰好选的起点,别当成定律。
那怎么试?用评测来定。每调一次块大小,看检索命中率是升还是降,就像改了 cell 的布局要跑一遍 UI 黄金用例。这套做法留到这个系列的第 6 篇再细讲。

按文档结构切,而不是按字数硬切

最省事的切法是按字数硬切:每 N 个字一刀。它的毛病,就像用菜刀把一本书从中间随便劈开:经常一刀下去,把一段代码劈成两半,或者标题留在上一块,正文落在下一块。
文档本来就带着结构:标题分层,段落成块,代码块自成一体。顺着这些纹理下刀,切出来的块天然就是“一件完整的事”。
对 Flutter 这类 Markdown 文档,最简单的办法是按标题切:每个二级标题到下一个二级标题之间,作为一块。块太长,再按段落往下切;代码块始终不拆开。
import redef split_by_headings(doc_title: str, markdown: str) -> list[dict]:    chunks = []    # 按二级标题切,保留标题本身    parts = re.split(r"(?m)^(?=##)", markdown)    for part in parts:        part = part.strip()        if not part:            continue        first_line = part.splitlines()[0]        heading = first_line.lstrip("# ").strip() if first_line.startswith("## ") else ""        chunks.append({"doc_title": doc_title, "heading": heading, "text": part})    return chunks
这段代码很朴素,只做一件事:顺着标题切,并把每块所在的文档名和标题记下来。真实项目里还会处理超长的块、代码块不拆开等细节,这里不展开。
重叠(overlap)是另一个常见的旋钮:相邻两块之间重复一小段,免得一句话刚好被切在边界上。按结构切之后,需要重叠的场合少了很多,但对没有标题的长段落,仍然可能用得上。要不要用、重叠多少,同样由评测来定。

给每个块带上“出身”:元数据与上下文

块切好之后,还有一个更隐蔽的问题:块离开了原文,就失去了上下文。
想象一个人被问到“你负责什么”,他只回答“就是上个季度那个”。单独听这句话,没人知道指什么。块也一样。比如一块里写着:“把 shrinkWrap 设为 true 之后,性能会下降。”它是在讲哪个组件?哪个场景?原文里标题交代了,切下来就没了。
有两层办法给块补上“出身”。
第一层是元数据,也就是机器能直接用的标签:来自哪篇文档、哪个标题、哪个版本、更新日期。这和给 App 里的每条数据带上来源字段是同一个习惯。元数据不参与向量计算,但可以用来过滤(比如只查某个版本的文档),也能在答案里标出处,方便用户核对。
第二层是上下文,也就是在块前面补一句话,说清“这块在整篇文档里是哪一部分”。Anthropic 把这个做法叫 Contextual Retrieval,文章发布于 2024 年 9 月 19 日。做法是让 Claude 读整篇文档和其中一块,用一两句话写出这块的上下文,补在块的前面,再拿去算向量和做关键词索引。官方给的提示词大意是:请给出一小段简短的上下文,把这一块放回整篇文档里;只回答这段上下文本身,不要多说。
照着写出来,大概是这样:
import anthropicclient = anthropic.Anthropic()def add_context(doc: str, chunk_text: str) -> str:    prompt = (        f"<document>\n{doc}\n</document>\n"        f"下面是这篇文档中的一个片段:\n<chunk>\n{chunk_text}\n</chunk>\n"        "请用一两句话说明这个片段在整篇文档中的位置和主题,"        "方便之后检索使用。只回答这段说明,不要多说。"    )    response = client.messages.create(        model="claude-sonnet-5-5",        max_tokens=200,        messages=[{"role": "user", "content": prompt}],    )    context = next(b.text for b in response.content if b.type == "text")    return f"{context}\n\n{chunk_text}"
补完之后,那句“把 shrinkWrap 设为 true 之后,性能会下降”前面就多了一句“这是 Flutter 官方文档中关于 ListView 滚动性能的一节”。用户问“ListView 为什么卡”时,它终于有机会被找到。
效果怎么样?Anthropic 在文章里给了一组数字。在他们的测试里,取前 20 个块时,基线的检索失败率是 5.7%。只用 Contextual Embeddings,降到 3.7%,减少 35%;再加上 Contextual BM25(一种关键词检索,下一篇会讲),降到 2.9%,减少 49%;再叠加 rerank,降到 1.9%,减少 67%。这些数字来自 Anthropic 自己选的数据集和评测设置,换一份语料,结果不一定一样。所以更稳妥的看法是:它是一个值得在自己语料上试一试的办法,而不是保证提升多少。
还有一个现实问题:每一块都要调用一次模型,会不会很贵?文章给了一个估算:借助 prompt caching,整篇文档只需要完整读一次,后面每块都读缓存,给所有块生成上下文,大约是每百万文档 token 1.02 美元。这个数字有前提:按的是当年 Claude 3 Haiku 的价格,块约 800 token,文档约 8000 token,每块生成约 100 token 的上下文。上面的代码用的是 Sonnet 5.5,前提一变,成本也会变,需要重新核算。缓存的基本机制,回看这个合集 020 系列的第 6 篇。

入库:存什么、怎么存

块切好了,上下文也补好了,最后一步是入库。索引里每一条至少要存四样东西:
  • 带上下文的文本:用来算向量,也用来做关键词检索。
  • 原始文本:最终交给模型看的内容,可以按需决定要不要带上补的那句话。
  • 向量:上一篇讲过的“语义坐标”。
  • 元数据:文档名、标题、版本、更新日期等。
import jsonimport sqlite3import numpy as npimport voyageaivo = voyageai.Client()  # 和第 1 篇一样,用 Voyage 做 embeddingdb = sqlite3.connect("docs.db")db.execute(    """CREATE TABLE IF NOT EXISTS chunks (        id INTEGER PRIMARY KEY,        doc_title TEXT,        heading TEXT,        raw_text TEXT,        context_text TEXT,        embedding BLOB    )""")def index_chunks(doc_title: str, markdown: str):    chunks = split_by_headings(doc_title, markdown)    contextual = [add_context(markdown, c["text"]) for c in chunks]    vectors = vo.embed(contextual, model="voyage-4", input_type="document").embeddings    for c, ctx, v in zip(chunks, contextual, vectors):        db.execute(            "INSERT INTO chunks (doc_title, heading, raw_text, context_text, embedding) "            "VALUES (?, ?, ?, ?, ?)",            (c["doc_title"], c["heading"], c["text"], ctx,             np.asarray(v, dtype="float32").tobytes()),        )    db.commit()
这里向量直接存成二进制,查询时读出来用 numpy 算相似度,规模不大的时候足够用。向量库要不要上,我们放到后面讲检索时再谈。
有一件事值得提前想好:文档会更新。移动端的人对缓存失效这件事不陌生,索引也一样。文档改了,对应的块要重新切、重新补上下文、重新算向量。所以元数据里记下文档版本或更新日期,不是锦上添花,而是为后面的更新留的抓手。
另外,切块和补上下文这一步,只要改了规则,整个索引都得重建。这也是前面强调“用评测来定”的原因:每次重建都要花时间和钱,不能凭感觉反复试。

块入库之后

回头看,这一篇做的事情,其实都是在回答同一个问题:怎样让每一块单独拿出来,也能说清自己是谁。切多大看评测,沿着结构下刀,再补上元数据和上下文。
块已经整整齐齐躺在库里了,但用户一提问,我们要从成百上千块里找出对的那几块。向量检索能做到多少,又有哪些漏洞,关键词检索能不能补上?下一篇我们来看看怎么把对的块找出来。

相关学习资料