基础 RAG 技术栈

43 阅读1小时+

在第 1 章中,我们介绍了检索增强生成的核心思想:让大语言模型能够访问外部知识,而不是只依赖它们在训练期间学到的内容。在本章中,我们将更深入地研究让 RAG 系统能够在实践中运行的技术组件。这些组件形成了一条数据流动的流水线——通常被称为 RAG 技术栈——它覆盖从准备原始文档,到生成高质量、基于上下文的回答的全过程。

我们首先会考察定义每个 RAG 系统的两条主要流程:摄取流程和查询流程。摄取流程负责转换并存储数据,以便未来向 LLM 提供它未见过的知识;查询流程则在推理时被激活,用来服务用户请求。两条流程中的每一步——解析、切分、嵌入、索引、向量搜索、重排序,以及基于 LLM 的生成——都有各自明确的角色,也都有各自的取舍。理解这些组成部分,对于诊断错误、提升质量,以及设计在生产环境中行为可预测、可扩展的 RAG 架构至关重要。

在逐层讲解时,我们不仅会描述相关概念,还会用真实代码示例、实践指导,以及常见设计选择背后的理由来说明。读完本章后,你将清楚理解基础 RAG 技术栈如何端到端运作,以及各个组件如何协同工作,从而交付准确、高效、可信赖的 AI 驱动信息检索能力。

RAG 技术栈流程

正如第 1 章所见,基础 RAG 技术栈有两条主要流程:摄取流程和查询流程。摄取流程通常在有新数据可用,或者已有数据需要更新时运行一次。查询流程则会在每次用户向 RAG 系统发送查询时触发,并使用摄取流程中准备好的数据来响应用户查询。

摄取流程

在摄取流程中,源文档会被预处理,也就是被解析和切分;预处理后的产物会以一种支持后续高效搜索和检索的方式存储起来,以便 LLM 完成某个任务或响应用户查询。

摄取流程通常包含四个步骤:解析、切分、嵌入和索引,如图 2-1 所示。

图 2-1 展示了摄取流程的步骤:解析、切分、嵌入和索引,数据来源包括数据库、文件和网页抓取等。

image.png

图 2-1 摄取流程中的主要组件或步骤;注意,在生产系统中,这些步骤通常会通过异步编排并带有重试机制

用于摄取的数据可以来自多种来源,例如数据库、文件(本地或云端)、API,或网页抓取。数据库和 API 中的数据通常更容易摄取,因为它们是结构化的。在这些情况下,schema——也就是定义字段和数据类型的结构——是提前已知的。例如,如果你要摄取 LLM 与用户之间的对话,我们事先就知道每条消息除了消息文本本身之外,还有一个 role 字段(表示该消息来自 LLM 还是用户)和一个时间戳;并且一段对话就是由这类复合消息对象组成的列表。来自数据库或 API 的数据预处理通常比较简单且最小化,例如可能包括将嵌套结构拍平成扁平结构,并在后续摄取步骤中丢弃对 RAG 任务无用的字段。在摄取 LLM—用户对话的例子中,我们可以把对话拍平成一个二维表,包含四列:消息文本、角色(谁发送了消息,是用户还是 LLM)、时间戳,以及 conversation ID,用来对应属于同一段对话的消息。后续做语义搜索时,只有消息文本会被嵌入。

相比之下,文件的预处理会更复杂,纯文本文件(例如 *.txt)除外,因为文件可能有各种格式,例如 PDF、DOCX、PPTX、HTML 等,而这些格式中包含的信息往往多于你希望 RAG 操作的信息。例如,在 Microsoft Word 文档中,格式和样式信息通常对 RAG 没有意义,因此需要识别并丢弃。摄取文件的另一个挑战是,你想要的文本数据或表格数据可能包含在扫描图片中,这就需要使用光学字符识别(OCR)等技术来提取文本。将不同模态、不同用途的信息分离出来,并只抽取你需要的部分,这个过程称为解析。我们将在“文档解析”一节中详细介绍这些主题。

解析会产生离散的文本片段,这些片段为 LLM 生成准确回答提供必要上下文。然而,由于上下文窗口大小、成本和 LLM 有效性等原因,这些片段通常太长,LLM 无法一次性处理。而且,大多数问题只需要关注文档中很长原始片段里的一小部分。冗长上下文不仅浪费,还可能适得其反,例如降低检索效果。因此,我们需要将它们拆分成更小的片段,称为 chunks。将文本拆分成 chunks 的过程称为 chunking,我们将在“文本切分”中介绍。

到目前为止,你的数据仍然保持原始形式(文本仍然是一串线性的字符序列)或原始模态,这可能会使搜索和检索效果不佳。例如,如果你的查询文本是 “United States”,那么你无法直接在字符空间中匹配到 “USA”。又比如,如果你的查询文本是 “Silicon Valley”,你也无法通过字符串比较直接找到包含“硅谷”或“矽谷”的中文文档。提升可搜索性的一种方法,是把文本转换成能够捕捉其语义含义的数字向量。这个过程及其输出被称为嵌入,更准确地说,是稠密嵌入或向量嵌入。我们将在“嵌入模型”中介绍。

除了把数据转换成数值向量的嵌入之外,还有其他方法可以在不改变数据表示或格式的情况下提升可搜索性,例如使用倒排索引或基于关键词的索引。这些方法更传统,常用于搜索引擎,但它们可能无法像嵌入那样有效捕捉文本的语义含义。不过,在某些场景下,它们可以补充嵌入,例如查找与某个特定企业相关的合同,而这个企业名称此前从未出现过。另一个例子是容忍用户查询中的拼写错误,在这种情况下,基于关键词的索引可以帮助匹配相似词项。这些方法将在第 3 章“混合搜索”一节中详细介绍。

连同嵌入在内,所有为了后续快速、容易检索而转换和组织信息的方法,都被归入“索引”这个大类。索引可以让我们在大型数据集中进行快速查找,并高效利用资源。

信息表示只是提升可搜索性的第一步。对于稠密嵌入来说,下一步是以一种支持高效检索的方式存储这些嵌入。这就是向量数据库发挥作用的地方,我们将在“向量数据库”中介绍。

查询流程

完成摄取流程之后,RAG 技术栈就可以使用被摄取的数据来响应用户查询。查询可以是一个问题、一条命令、一串指令,或者任何你希望 RAG 技术栈执行的任务。例如:“根据我 2025 年的旅行经历写一首诗。”

RAG 的能力在于,它让 LLM 能够响应那些需要训练数据中未见知识的查询。一个现成的 LLM 可能不知道你是谁,也不知道你在 2025 年去过哪里。但是,如果你已经把 2025 年的日记或博客文章摄取进 RAG 技术栈,那么它就可以从这些文档中检索你 2025 年旅行的信息,并提供给 LLM,让它基于这些信息生成一首诗。图 2-2 展示了查询流程中的步骤。

图 2-2 展示了 RAG 查询流程:用户查询被转换成向量用于检索,相关上下文被加入,最终构造出给 LLM 的提示词。

image.png

图 2-2 查询流程中的主要组件或步骤

查询流程的第一步是查询改写,也就是把用户查询转换成一种能提升 LLM 表现的格式。例如,查询“根据我 2025 年的旅行经历写一首诗”包含两部分:“写一首诗”和“根据我 2025 年的旅行经历”。第一部分是命令,第二部分则是需要检索并发送给 LLM 的上下文,用来完成这个命令。如果把“写一首诗”也包含进检索查询中,可能会导致无关结果,例如检索出不是关于旅行和 2025 年的诗,却漏掉关于旅行和 2025 年的非诗歌信息。这样,LLM 最后可能会基于其他诗歌给你写一首诗,但内容并不是关于旅行或 2025 年。因此,我们需要重写查询,让它聚焦于上下文部分,也就是“我 2025 年的旅行经历”。我们把用于信息检索的查询称为检索查询,以便与原始用户查询区分开来。

下一步是基于检索查询检索相关信息。通常有两类搜索:语义搜索,也称为稠密检索;以及关键词搜索,也称为稀疏检索。

在语义搜索中,检索查询会被转换成嵌入向量,然后用这个向量在向量数据库中搜索相似向量。两个向量之间的相似度可以通过一种称为点积的数学运算高效衡量,点积也称为内积或数量积。如今的嵌入模型几乎都会产生归一化向量或单位嵌入向量,也就是向量长度为 1。当点积运算中的两个向量都是单位向量时,点积等价于余弦相似度,用来衡量两个向量方向的对齐程度。

关键词搜索是一组搜索技术,例如词频—逆文档频率(TF-IDF)和 BM25(BM 表示 best matching),它们早在语义搜索之前就已经发展出来。不同于语义搜索需要将字符串转换成向量,并在向量空间中操作,关键词搜索会直接匹配字符串,因此是在词典空间中操作。现代嵌入模型也可以生成表示词或短语的向量用于匹配。为了把这类向量与稠密嵌入向量区分开来,我们称其为稀疏向量。

语义搜索和关键词搜索各有优缺点。为了结合两者的优势,可以同时使用二者,这被称为混合搜索。在基础技术栈中,也就是本章的主题,我们聚焦于纯语义搜索;而混合搜索——它在罕见 token、拼写错误和满足精确匹配约束方面更稳健——将在第 3 章中讨论。

出于成本和效果考虑,我们希望挑选最相关、信息量最大的 chunks,并将它们提供给 LLM。这听起来很简单,对吧?只需要根据它们与检索查询的相似度分数排序,然后选出最靠前的几个即可。遗憾的是,事情并没有这么简单。有许多原因会导致嵌入模型给出的排序对 LLM 来说并非最优,因此我们经常会额外应用一步重排序。

第一,检索模型可能返回多个传达重复信息的 chunks。把这些都发送给 LLM 会浪费 token 预算和计算资源。这个问题在 LLM 出现之前很久就已经被观察到。最大边际相关性(MMR)重排序器于 1998 年被提出,它通过在相关性和多样性之间取得平衡,减少被选中 chunks 之间的冗余。

第二,对于语义搜索来说,基于嵌入的检索中使用的点积相似度分数,并没有充分利用 LLM 底层的注意力机制。嵌入模型会独立表示每段文本,随后通过向量运算事后衡量它们的相似度。

与嵌入模型不同,基于 Transformer 的重排序器会联合评估查询和候选 chunk,使交叉注意力能够更好地捕捉两段文本之间细微的关系、上下文对齐和语义相关性。

一旦检索完成,我们终于可以把 chunks 发送给生成式 LLM。LLM 会在被检索出来的 chunks 所提供的上下文中检查原始用户查询,并生成响应。我们将在“生成式 LLM”中详细介绍这一步。

接下来,我们将详细查看 RAG 技术栈的每一层。

文档解析

很多时候,你想在 RAG 中使用的文本数据并不是简单的文本格式,而是 PDF、PPTX、DOCX、HTML 和 Markdown 等格式。对于这些格式来说,包含可用于回答 RAG 查询的知识文本,通常会与排版/样式信息混在一起,例如字体,而这些信息可能对回答 RAG 查询没有帮助。

PDF 文件尤其具有挑战性,因为 PDF 中的一页由单个字符及其坐标组成。你不仅要在 RAG 摄取时排除坐标,而且还需要根据字符坐标把字符拼接成单词或句子。在一些更具挑战性的情况下,感兴趣的文本可能是从纸质文档扫描来的图片,这就需要使用光学字符识别等技术来提取文本。OCR 的准确率高度依赖图像质量(分辨率和清晰度)、所用字体的复杂度和风格,以及版式本身。

把包含我们希望在 RAG 中使用的知识的文本与其他部分分离出来,这一步称为文档解析。之所以称为解析,是因为这些格式像计算机程序一样遵循指定语法或结构,我们需要从语法上确定哪些文本片段包含我们希望在 RAG 中使用的知识。

正确解析所有这些文件格式至关重要,因为这个阶段的准确性极其关键;摄取阶段的任何错误或不一致,都会损害 RAG 系统知识库的完整性和可靠性,并影响其为用户查询提供高质量响应的能力。

从各种文件格式中提取文本

我们先从 PDF 文件开始,这是被摄取进 RAG 的最常见文档类型之一。

PDF 根植于 PostScript 语言,从根本上说,它最初是为了渲染文档的视觉外观而设计的,而不是为了表达文档的逻辑结构。因此,大多数 PDF 文件并不包含 schema 或层级化内容。相反,一个单词会被存储为一组带有二维坐标的单个字符,这就是文本抽取如此困难的原因。

这也解释了为什么从 PDF 文件中提取单词、句子或段落,需要复杂算法来分析字符坐标之间的关系。例如,要判断一页是否有两栏,你需要分析页面上所有字符的聚类和对齐关系。否则,你可能会把两栏之间的行混在一起,导致句子毫无意义。

幸运的是,有很多库可以提供帮助。只需在 Google 中搜索 “PDF text extraction library”,就会得到大量解决方案,包括一些流行库,例如 pypdf(包括 PyPDF2、PyPDF3 和 PyPDF4——这段历史相当疯狂!)、PyMuPDF 和 pdfminer.six(PDFMiner 的社区版)。PDF 的发明者 Adobe 也提供了一个商业产品,称为 PDF Extract API,可以将 PDF 文件解析成 JSON 字符串。Unstructured.io 是一家创业公司,其产品也包括 PDF 提取器。

很多时候,PDF 文件来自扫描文档,其中每一页都是一张图片。要从这类页面中获得文本或表格信息,就需要使用 OCR。Tesseract 是最著名的 OCR 开源库之一,而 Google Cloud、Microsoft Azure 等超大规模云厂商也提供 OCR API。此外,还有不少专注于 OCR 的创业公司,例如 Reducto.ai。

与 PDF 相比,DOCX 或 HTML 格式相对更容易处理,因为它们属于 XML 格式家族,其中信息及其属性(例如字号、颜色等)以交错且结构化的方式存储。例如,在 HTML 中,文本 “The capital of France is Paris.” 会被表示为 The capital of France is <b>Paris</b>。在这个例子中,<b> 标签表示文本 “Paris” 应该以粗体显示。然而,当回答查询 “What is the capital of France?” 时,<b> 标签大概率并不需要。因此,在解析过程中,我们希望丢弃 <b> 标签,只返回文本 “The capital of France is Paris.”

有很多库可以用来解析并从 DOCX 和 HTML 文件中提取文本,例如用于 DOCX 文件的 python-docx,以及用于 HTML 文件的 Beautiful Soup。而且和 PDF 文件一样,DOCX 或 HTML 也是容器格式,可能包含表格或图片等多模态数据,这一点我们会在第 8 章中更详细讨论。

标签和属性并不总是应该被丢弃。有些标签或属性可以为后续流水线提供有用线索。假设要处理的文本是一段聊天日志,如下所示:

<div class="chat-log">
    <div class="user" id="msg1">What is the capital of France?</div>
    <div class="AI" id="msg2">The capital of France is Paris.</div>
    <div class="user" id="msg3">What language is spoken there?</div>
    <div class="AI" id="msg4">French.</div>
</div>

如果我们丢弃标签和属性,就会丢失“谁在什么时候说了什么”的上下文。之后,如果查询是 “According to AI, where does the President of France live?”,我们将无法回答。

更好的解决方案是把 class 记录为元数据,如表 2-1 所示。

表 2-1 将 class 记录为元数据

要摄取的文本元数据(JSON)
What is the capital of France?{"who":"user"}
The capital of France is Paris{"who":"AI"}

元数据是关于文本片段的额外信息,可用于提供上下文或其他有用信息。我们将在“向量数据库”中看到如何在向量数据库中存储元数据。

在查询改写期间,你现在可以把查询拆分成一个主查询,也就是 “where does the President of France live”,用于语义搜索;以及一个元数据过滤器,用来将 “who” 限制为 “AI”。这样,即使用户消息中提到了法国首都,也会被排除在检索之外。元数据过滤是一种数据库操作,类似于 SQL 中的 where 子句,它与语义搜索本身无关。

使用视觉—语言模型进行文档解析

如今的 LLM,例如 GPT-5.x 系列,可以处理非文本数据。为了与只能处理文本数据的 LLM 区分开来,我们有时会使用视觉—语言模型(VLM)这个术语,表示能够同时处理视觉信息(静态图片或视频)和文本的模型。VLM 可以被用来直接解析各种文件格式。

这种方法特别适用于布局复杂的文档,例如幻灯片、表单或信息图。不过,使用这个选项时要非常谨慎。VLM 在抽取文本时并不总是准确。由于它们具有生成式性质,因此容易产生幻觉;而且截至目前,它们通常无法复现提供给它们的文档中的图片。对于大规模企业级数据集,使用 VLM 进行解析可能相当昂贵且缓慢。

因此,这种方法应该保留给困难且高价值的文档类型,或者在传统解析器失败时作为兜底方案。我们建议采用“最便宜且能成功的解析器优先”策略,也就是先使用 PDF 库、HTML 解析器、OCR,再调用 VLM。

代码示例:解析文件

现在我们来看三个代码示例:使用 PyMuPDF 解析 PDF 文件,使用 python-docx 解析 DOCX 文件,以及使用 GPT-5.1 解析 PDF 文件。本章可运行的 Jupyter notebooks 可以在本章对应的 GitHub 仓库中找到。

使用 PyMuPDF 处理 PDF 文件

PyMuPDF 是一个用于在 Python 中处理 PDF 文件的强大库。PyMuPDF 文档提供了关于如何从 PDF 文件中提取文本、图片和表格的详细示例。这里我们简要看一些例子。

如前所述,在 PDF 文件中,文本不是字符序列。相反,字符是单独存储的。PyMuPDF 的工作就是根据字符之间的邻近关系对字符进行分组。

下面的代码会遍历一个 PDF 文件,并将每一页上的所有文本作为一个字符串提取出来:

with pymupdf.open("sample_data/sample_data.pdf") as doc:
  for page in doc:
    text: str = page.get_text()
    print(text, end="---")

输出大致如下:

Sample doc for RAG Book Chapter 2
This is a great chapter 
Revision
Year
0.0.1
2025
Book
Revision
Year
Author
Memory and RAG
0.1.0
2026
A great researcher
RAG and AI
0.0.1
2025
---

显然,这并不理想,因为表格中的文本和普通的非结构化文本混在了一起。

一种改进方法是按 block 提取文本,其中 block 是 PyMuPDF 分组到一起的一组文本:

with pymupdf.open("sample_data/sample_data.pdf") as doc:
  for page in doc:
    text: str = page.get_text("blocks")
    for block in text:
      print(block[4], end="---\n")

为了让 block 边界更清楚,我们特意在每个 block 末尾加上 ---\n。输出大致如下:

Sample doc for RAG Book Chapter 2
---
This is a great chapter 
---
Revision
Year
0.0.1
2025
---
Book
Revision
Year
Author
Memory and RAG
0.1.0
2026
A great researcher
RAG and AI
0.0.1
2025
---

不过,这仍然不够好,因为我们无法判断哪个 block 属于表格,哪个 block 属于普通文本。为了只得到非结构化文本,我们需要先抽取表格内容,再从所有文本中减去表格上下文。

使用 PyMuPDF 抽取表格内容相对容易:

with pymupdf.open("sample_data/sample_data.pdf") as doc:
  for page in doc: 
    tables = page.find_tables()
    for i, table in enumerate(tables):
      print (f"Table {i+1}")
      print(table.extract(), end="\n\n")

find_tables() 函数会找到给定页面中的所有表格。每个表格可以使用 extract() 函数抽取,该函数会返回一个二维 Python 列表形式的表格,其中每一行都是一个子列表。表 2-2 是我们的示例。

表 2-2 用于解释表格到 JSON 序列化的示例表格

RevisionYear
0.0.12025

例如,一个像表 2-2 这样的表格,会被上面的代码抽取为:

[    ['Revision', 'Year'], 
    ['0.0.1', '2025']
]

有了表格内容之后,我们可以通过用表格内容过滤所有文本,来获得非表格文本。这个过程稍微复杂一些,如下面代码所示:

# Extract non-table text
def extract_unstructured_text(pdf_path):
    """
    Extract unstructured text from PDF, excluding table content.
    Returns clean paragraph text without tabular data.
    """
    unstructured_text = []
    
    with pymupdf.open(pdf_path) as doc:
        for page in doc:
            # Get all text from the page
            page_text = page.get_text()
            
            # Find tables on the page to exclude their content
            tables = page.find_tables()
            
            # Get table text blocks to filter out
            table_text_blocks = []
            if tables.tables:
                for table in tables:
                    # Get table bounding box
                    bbox = table.bbox
                    # Extract text within table bounds
                    table_text = page.get_text(clip=bbox)
                    table_text_blocks.append(table_text.strip())
            
            # Split page text into lines and filter out table content
            lines = page_text.split('\n')
            filtered_lines = []
            
            for line in lines:
                line = line.strip()
                if line:  # Skip empty lines
                    # Check if this line is part of any table
                    is_table_content = False
                    for table_text in table_text_blocks:
                        if line in table_text:
                            is_table_content = True
                            break
                    
                    # Only include non-table content
                    if not is_table_content:
                        filtered_lines.append(line)
            
            # Join filtered lines back into paragraphs
            page_unstructured = '\n'.join(filtered_lines)
            if page_unstructured.strip():
                unstructured_text.append(page_unstructured)
    
    return '\n\n'.join(unstructured_text)

# Test the function
unstructured_content = extract_unstructured_text("sample_data/sample_data.pdf")
print("Unstructured text extracted:")
print(unstructured_content)

这一次,我们得到了预期结果:

Unstructured text extracted:
Sample doc for RAG Book Chapter 2
This is a great chapter

最后,我们来看一下如何从 PDF 文件中提取图片:

# Extract images 
with pymupdf.open("sample_data/sample_data.pdf") as doc:
 for page in doc:
   # Get images from the page
   image_list = page.get_images()
   for img_idx, img in enumerate(image_list):
     # Extract image data
     xref = img[0]
     base_image = doc.extract_image(xref)
     image_bytes = base_image["image"]

     # Display the image directly in Jupyter notebook
     display(Image(data=image_bytes))

     # Save image
     img_fmt = base_image["ext"]
     img_filename = f"img_{page.number}_{img_idx}.{img_fmt}"
     with open(image_filename, "wb") as img_file:
         img_file.write(image_bytes)

从 PDF 文件中提取图片稍微有些复杂,因为图片被视为“外部”信息。get_images() 函数实际上并不会获取图片的二进制表示,而是获取图片的“指针”信息。真正利用与图片关联的引用编号(xref)获取图片数据的是 extract_image() 函数。上面的代码示例既会在 Jupyter notebook 中显示图片,也会把它以 PDF 文件中包含的图片原生格式保存到文件中。

处理 DOCX

与 PDF 不同,DOCX 是一种结构化格式,会保留文档中元素的元数据。这使得抽取文本、表格和图片更容易。DOCX 文件本质上是一个 XML 文件的 ZIP 归档(也称为 ZIP 包),其中包含存储文本、表格和媒体文件的 XML 文件,媒体文件包括图片。

首先,我们使用 python-docx 抽取文本和表格。python-docx 是一个很适合处理 DOCX 格式文件中文本和表格的库:

#!pip install python-docx
from docx import Document
from IPython.display import display, Image

document = Document('sample_data/sample_data.docx')

# extract text 
for para in document.paragraphs:
  print(para.text)

# extract tables
for table in document.tables:
  print("\n--- Table ---")
  for row in table.rows:
    row_text = [cell.text for cell in row.cells]
    print(row_text)

然后,我们可以继续从 DOCX 文件中提取图片。正如前面提到的,DOCX 文件其实是一个包含 XML 文件和媒体文件的 ZIP 归档。这些媒体文件可以通过 zipfile 模块访问。在下面的代码中,我们会用常见后缀/扩展名来搜索图片文件:.jpg.jpeg.png.gif

import zipfile

zipf = zipfile.ZipFile('sample_data/sample_data.docx')
filelist = zipf.namelist()

for fname in filelist:
  _, ext = os.path.splitext(fname)
  if ext in ['.jpg', '.jpeg', '.png', '.gif']:
    # read image and display in Jupyter
    with zipf.open(fname) as img_file:
        img_data = img_file.read()
        display(Image(data=img_data))

现在,我们已经介绍了如何从 DOCX 格式中提取三种主要模态:文本、表格和图片。

使用 LLM 解析 PDF 文件

从上面的例子中你可能会注意到,解析文件可能相当复杂。例如,在 PyMuPDF 示例中,为了得到非结构化文本,我们不得不手动从所有文本中过滤掉表格内容。LLM 是强大的工具,在很多情况下可以让这个过程变得更容易。现在让我们看看如何只用几条英文指令,就使用 GPT-5.1 分别解析一个 PDF 文件,得到非结构化文本和表格。

提示

将环境变量 OPENAI_API_KEY 设置为你的 OpenAI API key。

首先,初始化一个 OpenAI client 并加载 PDF 文件:

from openai import OpenAI

client = OpenAI()

file = client.files.create(
    file=open("sample_data/sample_data.pdf", "rb"),
    purpose="user_data"
)

然后要求 GPT-5.1 提取文本,但排除表格和图片中的文本:

completion = client.chat.completions.create(
    model="gpt-5.1",
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "file",
                    "file": {
                        "file_id": file.id,
                    }
                },
                {
                    "type": "text",
                    "text": """Extract the text content from the file. Exclude 
                    texts from tables or images.""",
                },
            ]
        }
    ]
)

print(completion.choices[0].message.content)

发给 GPT-5.1 的消息包含两部分:第一部分是要解析的文件,第二部分包含指令。这些用普通英语写成的指令要求从文件中提取文本内容,同时排除表格或图片中的文本。

接下来,我们从同一个 PDF 文件中提取表格:

completion = client.chat.completions.create(
    model="gpt-5.1",
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "file",
                    "file": {
                        "file_id": file.id,
                    }
                },
                {
                    "type": "text",
                    "text": """Extract the tables from the file. Return in 
                    Markdown tables.""",
                },
            ]
        }
    ]
)

print(completion.choices[0].message.content)

这段代码和上一段类似,只是指令现在要求 GPT-5.1 抽取表格,并以 Markdown 表格格式返回。

在本节中,我们探索了如何为检索和 LLM 准备数据的基础知识。我们展示了如何使用两个流行库 PyMuPDF 和 python-docx,从 PDF 和 DOCX 两种最常见文档格式中提取文本。接下来,我们将进入流水线的下一步:文本切分。

文本切分

Chunking 是将一大段文本字符串,例如一篇文档的全文,拆分成较小部分的过程,这些较小部分称为 “chunks”。例如,我们可以把一篇长文章拆分成单独的段落或章节,甚至拆分成句子。

为什么需要切分?切分的首要原因是 LLM 上下文窗口的有限性。下面是写作本书时最先进 LLM 的上下文窗口:

OpenAI 的 GPT-5.1:400k
Anthropic 的 Claude Sonnet 4.5:1M
Google 的 Gemini 3:1M

这些数字听起来很大,但用于企业级任务时,这种“充裕”具有欺骗性。普通人每分钟大约说 120 个单词。由于一个英文单词大约是 1.3 个 token,一个 400k 的上下文窗口足以容纳大约 42 小时的连续讲话。这听起来很多,但举例来说,如果你想基于所有相关财报电话会议转录文本来获得市场对某个主题的共识,42 小时根本不够。

切分有助于最大化可以塞进上下文窗口的相关信息量。通过切分,我们可以尽可能用相关信息填满上下文窗口,让 LLM 在完整图景下生成回答。

在上面的财报电话会议例子中,如果上下文窗口不足以容纳所有财报电话会议转录文本,而你只关心某个行业前 10 家企业的员工人数增长,那么只有与这个主题相关的 chunks 会被放入上下文窗口。关于其他主题的 chunks,例如收入或技术突破,会被排除在外。

除了上下文窗口严格的容量约束之外,切分还有一个很有说服力的性能理由:计算开销和推理延迟。Nvidia 的 NIM LLMs benchmark 检查了在 2× H100 GPU、FP8 精度下,不同上下文窗口长度对 Llama 3.3 70B 首 token 时间(TTFT)的影响。表 2-3 展示了结果:

表 2-3 Llama 3.3 中延迟随序列长度变化的情况

Token 数量延迟
200 tokens31 ms
500 tokens47 ms
1000 tokens82 ms
5000 tokens406 ms
10000 tokens1833 ms

粗略估计,基于 Transformer 的 LLM 的推理时间会随着上下文长度呈二次方增长。可以合理推测,当我们接近数十万 token 时(假设 GPU RAM 还能支持那么多 token),TTFT 会带来令人无法接受的糟糕用户体验。还要注意,上面的实验使用了 2 块 H100,这已经是非常奢侈的配置。写作本书时,AWS 上单块 H100 实例(p5.4xlarge)的按需价格为 6.88 美元/小时,或 5022 美元/月。AWS 不提供双 H100 配置。如果 AWS 提供,那么达到上述延迟的成本会超过 1 万美元/月,几乎相当于硅谷一名工程师 base salary 的一半——相当昂贵。通过切分降低上下文长度,可以显著降低推理延迟和成本。

切分的第二个理由也与上下文窗口有关,但这一次是嵌入模型的上下文窗口。嵌入模型用于将文本 chunks 转换成向量,以便存储在向量数据库中,并支持快速语义检索。然而,嵌入模型的上下文窗口远小于 LLM。正如我们将在“嵌入模型的选择标准”中看到的,最先进的嵌入模型上下文窗口较小。例如,OpenAI 第三代和第四代嵌入模型 text-embedding-3-{small, large} 的上下文窗口是 8k,Gemini 第二代嵌入模型 gemini-embedding-002 也是 8k。因此,在 OpenAI 和 Google 的情况下,我们必须将 chunk 大小限制在 8k token 以内。

切分的第三个原因是提升 RAG 的响应质量。这种提升同时体现在检索阶段和生成阶段。如果一个大 chunk 覆盖多个主题,那么这个大 chunk 的嵌入向量会较弱地反映每个主题。如果我们把大 chunk 拆成较小 chunks,每个小 chunk 会聚焦于一个主题,因此它的嵌入会更好地表示该主题。在检索阶段,这意味着你会获得更高相关性的 chunks;在生成阶段,较小 chunks 意味着呈现给 LLM 的无关信息更少,从而带来更准确的响应。人们普遍承认,随着上下文长度增加,LLM 的推理能力会下降。例如,Anthropic 在 1M 上下文正式可用公告中的一张图显示,来自三个供应商的 LLM 都会随着上下文增加而检索能力下降。使用较小 chunks 时,LLM 可以专注于最相关的信息,而不会被无关细节分散注意力。

总之,你现在可以看到为什么切分是 RAG 的必要步骤。下面我们来看看 RAG 中常见的各种切分策略。

切分策略

RAG 流水线中已经提出了多种切分策略。

固定大小切分是最基础的策略,它会把文档拆分成预定义大小的 chunks,通常以字符数、单词数或 token 数衡量。它的主要限制在于无视文本的自然结构,可能会切断句子或语义单元。常见缓解方法是引入重叠 chunks,也就是把一个 chunk 末尾的 token 在下一个 chunk 开头重复,从而保留边界处的局部上下文。

内容感知切分利用语言和句法线索,例如句子边界和换行,来生成更连贯、更具上下文意义的片段。它有几种变体:

基于句子或段落的切分

这种方法使用换行符(例如 \n\n)将文本切分成段落,或使用句子边界检测规则(例如标点符号)将文本切分成句子。由于存在大量边界情况,句子切分具有挑战性,例如缩写中的句点并不表示句子边界。专门工具——通常称为 sentencizers 或 sentence segmenters——由 spaCy、Stanza 和 NLTK(Natural Language Toolkit)等自然语言处理(NLP)库提供。每个得到的单元都可以作为一个 chunk。如果单句 chunks 过于细碎,可以合并连续句子,并可选择在 chunks 之间加入重叠来保留上下文。

递归切分

这种方法使用分隔符层级,逐步将文本切分成更小单元,例如段落 → 句子 → 从句。一个代表性实现是 LangChain 的 RecursiveCharacterTextSplitter

文档结构切分

这种方法扩展了递归切分,引入显式文档结构,例如章节和小节。例如,Markdown 文档可以根据标题层级(例如 ######)进行层级化切分,从而保留内容的逻辑组织。

语义切分

语义切分进一步推进了这一范式,它基于语义相似度来切分文本,例如根据主题对句子进行聚类。这种方法生成的 chunks 在语义上更加连贯,而不是只依赖表层结构。

表 2-4 给出了切分策略之间的比较。

表 2-4 切分策略比较

方法优点缺点
固定大小切分实现简单;chunk 大小可预测;便于索引和批处理会破坏语义和句法结构;可能切断句子;需要重叠来保留上下文(会增加冗余)
基于句子/段落的切分(内容感知)保留语言结构;chunks 更连贯;可用 NLP 工具轻松实现句子边界检测可能出错;chunk 大小不均匀;仍可能丢失更高层上下文
递归切分灵活;适应多层结构;避免 chunks 过大或过小实现/调优更复杂;依赖分隔符质量;仍可能忽略真实语义
文档结构切分与逻辑文档组织对齐(章节、标题);保留层级上下文需要结构良好的文档;对非结构化文本效果较差;实现依赖格式(如 Markdown、HTML)
语义切分生成语义连贯的 chunks;检索质量更好;与基于嵌入的搜索对齐计算成本高;需要嵌入/聚类;chunk 大小不太可预测;更难调试/调优

我们如何决定使用哪种切分策略?很难有一条适用于所有情况的最佳规则。常见做法是基于你的数据集进行评估。Chroma 提供了一个示例,你可以在决定使用哪种切分策略时参考。

切分效果可以在检索阶段或生成阶段进行评估。检索通常使用 BEIR 等检索基准以及 precision、recall、F1、nDCG(normalized discounted cumulative gain)和 MRR(mean reciprocal rank)等指标进行评估。第 6 章会更多讨论评估。生成通常使用那些需要从给定上下文/段落中找到答案的基准进行评估,例如问答(QA)或机器阅读理解(MRC)基准,并使用 BERTScore 或基于 LLM 的判断等指标。请注意,LLM 生成评估可能受到 chunks 排名的影响,因为 LLM 往往更关注开头和结尾位置的内容,这被称为“lost in the middle”效应。因此,不要仅仅基于生成评估就断定一种切分策略优于另一种。要记住,LLM 生成会受到许多因素影响。

有些令人意外的是,你可能会发现所有切分策略的表现都很相近,并不存在一种切分策略能在所有情况下优于其他策略。2024 年 EMNLP(Conference on Empirical Methods in Natural Language Processing)发表的一项近期工作《Is Semantic Chunking Worth the Computational Cost?》,作者包括 Vectara 的 Renyi Qu 和 Forrest Bao(本书作者之一),以及威斯康星大学麦迪逊分校的 Ruixuan Tu。该研究显示,在 BEIR 和 RAGBench 这两个业界知名的检索任务评估基准上,固定大小切分和语义切分并没有差异。但读者不应把这篇论文中的结论视为定论——现有基准是在 RAG 之前创建的,因此通常包含较短段落,而切分的影响在更长文本中可能更加明显。随着越来越多包含长段落的现代 RAG 专用基准出现,未来我们可能会看到不同结论。

代码示例:在 Python 中进行切分

SpaCy 是一个流行的自然语言处理任务预处理库。它的 sentencizer 可以将文本切分成句子。下面的代码将一段长文本(变量 text)切分成句子,在这个例子中句子就是 chunks,并逐一打印出来:

import spacy

# Load a pre-trained English model
nlp = spacy.load("en_core_web_sm")

text = """Mr. Wang is a teacher. He teaches A.I. (?). Does he love his work? 
Of course!"""
doc = nlp(text)

# Iterate over sentences
for sent in doc.sents:
  print(sent.text)

输出如下:

Mr. Wang is a teacher.
He teaches A.I. (?).
Does he love his work?
Of course!

相比使用标点符号切分句子,使用 spaCy 的 sentencizer 有一个优势:它能更好地处理边界情况,例如包含句点的缩写或头衔。在上面的例子中,句子中间的问号没有骗过 spaCy。

我们也可以使用 Python 原生字符串操作创建一个固定长度切分器。

# Fixed-length chunking with overlap
chunk_size = 30
chunk_overlap = 8
step_size = chunk_size - chunk_overlap
for i in range(0, len(text), step_size):
   chunk = text[i:i+chunk_size]
   print(chunk)

输出是:

Mr. Wang is a teacher. He teac
 He teaches A.I. (?). Does he 
Does he love his work? Of cour
Of course!

在这个极端例子中,每个 chunk 长度为 30 个字符,两个连续 chunks 有 8 个字符重叠。从输出可以明显看出,固定长度切分会切断单词,为嵌入制造噪声。不过,如果 chunks 足够长,这类噪声的影响很小。在这种情况下,固定长度切分由于速度快,是一个不错的选择。

嵌入模型

想象你想从自己的文档中找到所有关于 United States 的信息。一个初始方法是查找所有包含 “United States” 这两个词的 chunks。然而,这种方法会漏掉包含 “the U.S.” 或 “America” 等短语的 chunks,虽然它们指向同一个实体,只是措辞不同。更明显的例子是,试图用单词 “two” 去查找数字 “2”。

上面这段中看到的挑战,源于人类语言的一个根本特征:表层相似/不相似与语义相似/不相似并不一致。单词的拼写是一个概念的表层形式。两个表层形式相似的词,例如 “hat” 和 “mat”,并不一定语义相似,也不一定指向相同或相似概念。表层相似/不相似与语义相似/不相似之间的不一致,长期以来一直是人工智能,尤其是自然语言处理中的棘手问题。

这就是嵌入模型发挥作用的地方。它们可以帮助捕捉短语的语义含义,从而实现更灵活、更准确的信息检索。在上面的例子中,嵌入模型可以识别 “the U.S.” 和 “America” 与 “United States” 相关,尽管它们的表层形式不同。

什么是嵌入?

嵌入过程会将文本数据,也就是 RAG 语境中的被检索 chunk 或用户查询,映射到一个浮点数向量,也称为嵌入向量,或简称嵌入。这些向量捕捉文本的语义含义,使得简单关键词匹配无法实现的有效比较和检索成为可能。两个语义相似的短语,例如 “Uncle Sam” 和 “US Government”,尽管拼写差异很大,其嵌入向量之间的距离会很小。向量距离可以使用点积等操作高效计算,而如今的 GPU 已经针对这类操作进行了优化。

嵌入解决了 NLP 多年来一直困扰的瓶颈:在表层不相似的情况下捕捉语义相似性,例如 “Uncle Sam” 与 “US Government”;以及在表层相似的情况下捕捉语义不相似性,例如 “cat” 与 “hat”。如果查询包含 “Uncle Sam”,而我们完全依赖词的表层形式,那么就会漏掉包含 “US Government” 的 chunks,甚至可能偏好包含 “Uncle Tom” 或 “Uncle Bob” 的 chunks,尽管它们在语义上无关。

嵌入的目的是找到一种表示文本数据的方法,使表示之间的相似性与语义相似性一致。由于这类嵌入通常是通过训练神经网络获得的,因此这种表示通常被称为稠密表示,基于这些嵌入的检索器有时被称为稠密检索器。

注意

请不要因为 “Uncle Sam” 与 “US Government” 这个例子而误以为嵌入只用于匹配同义词,也就是含义相同的词。例如,如果查询是 “Find all cases that happened in the US”,而你的数据源只提到州名,比如 California 或 Alberta(加拿大的一个省),如果嵌入合适,模型仍然可以检索到发生在 California 的案例,同时忽略 Alberta 的案例。

将词表示为向量一直是自然语言处理中的长期问题。例如,所谓 Elman network 是 Jeffrey L. Elman 在 1990 年代的 “Finding Structure in Time” 中描述的,它是一篇关于循环神经网络(RNN)的有影响力作品。Elman network 使用 one-hot encoding 表示词。在 one-hot encoding 中,每个词被表示为一个长度等于词汇表大小的向量,除了对应词在词汇表中索引位置的一个元素为 1 外,其余元素全部为 0。

真正的突破来自 2010 年代深度学习的兴起。2013 年,Google 研究员 Tomas Mikolov 提出了 Word2Vec 模型,它通过一个简单任务把词映射到向量:给定目标词,预测它周围的词,这被称为 skip-gram 模型。例如,给定句子 “I had pizza for lunch” 中间的词 “pizza”,模型会预测 “I”、“had”、“for” 和 “lunch”。

结果令人兴奋。一个著名例子是,“queen” 与 “woman” 之间的向量差,几乎与 “king” 和 “man” 之间的向量差平行。这类嵌入被称为“静态嵌入”——“静态”指的是每个词的嵌入在训练后是固定的。2018 年,Google 推出的 BERT(Bidirectional Encoder Representations from Transformers)模型进一步推动了该领域发展,它启用了“上下文化嵌入”或“动态嵌入”,也就是根据上下文确定一个词的嵌入。

这个短课程是进一步学习嵌入模型内部机制的好资源,包括词嵌入和句子嵌入的区别,以及如何使用对比损失构建和训练双编码器模型。

嵌入模型的选择标准

像任何机器学习模型一样,选择嵌入模型时你首先要做的取舍,是模型大小和性能。更大的模型可以更好地刻画语义,但也可能更慢,并需要更多内存。你可以通过基准测试找到平衡点。

Massive Text Embedding Benchmark(MTEB)是一个用于评估嵌入模型性能的行业基准。你可以在 MTEB Leaderboard 上找到嵌入模型排名。

嵌入维度是选择嵌入模型时另一个需要考虑的重要因素。更高维度的嵌入可以捕捉更细腻的语义信息,但也需要更多计算资源。必须根据具体使用场景,在维度和效率之间找到平衡。

许多嵌入模型,例如 OpenAI 的 embedder 和 Google Gemini 的 embedder,允许你通过简单截断嵌入向量到任意维度来调整嵌入维度。这是在 embedder 训练过程中通过一种称为 Matryoshka Representation Learning(MRL)的技术实现的。MRL 通过把不同维度嵌入的损失函数相加,迫使最具区分性的语义信息集中到低维度。虽然 MRL 允许在任意位置截断,但标准实践是使用 2 的幂次子维度,例如完整向量的 1/8、1/4 或 1/2。这是因为模型通常在训练过程中针对这些特定维度粒度进行了优化。

像 LLM 有上下文窗口一样,嵌入模型也对可处理的输入大小有限制。写作本书时,代表性嵌入模型的上下文窗口如下(以 token 数计):

Alibaba 的 Qwen3-Embedding-{0.6B, 4B, 8B}:32k
OpenAI 第三代 text-embedding-3-{small, large}:8k
Google Gemini 第二代 gemini-embedding-002:8k
北京智源人工智能研究院(BAAI)的 BGE-M3:1k

实用建议与注意事项

确保你的切分策略与嵌入模型的上下文窗口长度匹配。如果你的 chunks 长于模型上下文窗口,大多数服务框架,例如 HuggingFace 的 Transformers 库,会静默截断 chunks,导致信息丢失和检索质量下降。

还要确保你的嵌入维度满足向量数据库的要求。向量数据库会在本章“向量数据库”中讨论。例如,pgvector 对 32-bit 全精度浮点嵌入的维度上限是 2000。

为了节省存储和计算,常见做法是降低嵌入精度,例如降到 16-bit 浮点,甚至 8-bit 整数。许多服务框架或软件即服务(SaaS)端点允许你指定希望返回嵌入的精度。

嵌入端点通常以 JSON 格式返回向量,而 JSON 对数值数据的效率是出了名的低。像 “123” 这样的单个 8-bit 整数,在二进制中只需要 1 byte,但作为 JSON 字符串会消耗 3 bytes。此外,通过 JSON 传输浮点数,在字符串序列化过程中存在精度损失风险。为解决这个问题,许多平台支持 Base64 编码;虽然这可以保留完整浮点精度并减少 payload 大小,但需要在客户端侧进行解码,才能恢复原始浮点数组。

代码示例:使用 Sentence Transformers 生成嵌入

Sentence Transformers 是一种用于生成有效句子嵌入的架构,它的 Python 包 sentence-transformers 提供了许多预训练嵌入模型。我们用它来演示嵌入如何工作。

首先,导入库并初始化模型:

from sentence-transformers import SentenceTransformer
import random 
import matplotlib.pyplot as plt

# Load the pre-trained Sentence Transformers model
model = SentenceTransformer('all-MiniLM-L6-v2')

然后,我们对一些示例句子进行嵌入:

# List of sentences to encode
sentences = [
    "I am a happy person.",
    "I am a joyful person.",
    "I am a pessimistic person.",
    "I am not an optimistic person."
]

# Generate embeddings for the sentences
embeddings = model.encode(sentences)

我们可以简单看一下这些嵌入:

print (embeddings.shape)
print (embeddings[:, :5]) # only the first 5 dimensions due to space constraints

第一个 print() 应输出 (4, 384),表示我们有四个句子,每个句子都有 384 维嵌入。第二个 print() 会显示每个嵌入的前五个维度,大致如下:

[[ 0.0046472   0.06651063  0.01479136 -0.02955691 -0.03556161]
 [ 0.03454593  0.05649192  0.00730661 -0.07299504 -0.06663913]
 [ 0.0615203   0.05317358  0.01788338  0.0348283  -0.03031245]
 [ 0.02471697  0.02527617 -0.00175561  0.02087232 -0.03713483]]

现在,我们可以计算句子嵌入之间的两两相似度。

similarity_matrix = model.similarity(embeddings, embeddings)
print (similarity_matrix)

我们会看到如下 4 × 4 张量:

tensor([[1.0000, 0.8151, 0.3864, 0.5210],
        [0.8151, 1.0000, 0.3383, 0.4128],
        [0.3864, 0.3383, 1.0000, 0.7047],
        [0.5210, 0.4128, 0.7047, 1.0000]])

我们把这个矩阵可视化为热力图:

plt.figure(figsize=(5, 5))
plt.imshow(
    similarity_matrix, cmap='RdYlGn_r', interpolation='nearest', vmin=0, vmax=1
)
plt.title('Cosine similarity between any pair of embeddings')
plt.xlabel('Sentence ID')
plt.ylabel('Sentence ID')
plt.yticks([0, 1, 2, 3])

# Add text annotations
for i in range(len(similarity_matrix)):
    for j in range(len(similarity_matrix)):
        plt.text(j, i, f'{similarity_matrix[i][j]:.3f}', 
                ha='center', va='center', color='black')

plt.show()

生成的热力图如图 2-3 所示。

图 2-3 展示了句子嵌入之间的余弦相似度分数热力图,其中第一句和第二句之间,以及第三句和第四句之间表现出较强相似性。

image.png

图 2-3 上述代码示例中句子嵌入之间的余弦相似度

对角线元素表示每个句子与自身的相似度,始终为 1。第一行表示第一个句子与所有句子的相似度。它与第二个句子的相似度是 0.8151,相当高,说明这两个句子在语义上相似。它与第三、第四个句子的相似度分别是 0.3864 和 0.5210,说明语义相似度较低。把注意力转向相似度张量的最后两行,我们可以看到第三个句子与第四个句子最相似,相似度为 0.7047;而它与第一、第二个句子的相似度分别为 0.3864 和 0.3383,比较低。

现在,让我们做一个有趣实验。如果我们随机取嵌入的一段切片并计算相似度,会发生什么?代码如下:

# generate two random integers between 0 and 384
rand1 = random.randint(0, 384)
rand2 = random.randint(0, 384)

if rand1 > rand2:
    rand1, rand2 = rand2, rand1

print (f"Using semantic dimensions {rand1} to {rand2}")

similarity_matrix = model.similarity(
    embeddings[:, rand1:rand2], embeddings[:, rand1:rand2]
)
print (similarity_matrix)

某次运行时,你会看到如下输出:

Using semantic dimensions 67 to 158
tensor([[1.0000, 0.8511, 0.3949, 0.5848],
        [0.8511, 1.0000, 0.3353, 0.4720],
        [0.3949, 0.3353, 1.0000, 0.6721],
        [0.5848, 0.4720, 0.6721, 1.0000]])

通过使用第 67 到 158 个语义维度之间的嵌入,我们可以看到相似度矩阵呈现出与使用完整嵌入时类似的模式:也就是说,句子 1 和句子 2 仍然最相似,其次是句子 3 和句子 4。

如果你用不同随机维度重复这个过程,很可能会在相似度矩阵中观察到类似模式。这说明如今的嵌入模型已经足够优秀,即使是随机选取的嵌入空间子空间,也仍然可以捕捉有意义的语义关系。

向量数据库与向量搜索

将文本转换成嵌入只是实现有效信息检索的第一步。下一步是以一种支持快速相似度查找的方式存储这些嵌入。这就是向量数据库发挥作用的地方。

理解基于向量的相似度搜索

在上一节中,我们了解到嵌入的美妙之处在于,两个嵌入之间的向量差异反映了这两个嵌入所代表文本之间的语义相似度。衡量向量相似度的一种常用方法是点积。点积越高,两个向量及其对应文本在语义上越相似。注意,当使用点积比较语义相似度时,我们可以去除向量长度的影响,只考虑两个嵌入之间的角度差异。因此,嵌入需要归一化,也就是长度为 1。当向量被归一化时,它们的点积等于另一个概念,称为余弦相似度。

给定一个查询的嵌入,一种朴素但暴力的方法,是计算查询嵌入与数据库中所有其他嵌入向量之间的点积,以找到与查询最相关的 chunks。因此,其复杂度为 O(n),其中 n 是数据库中的向量数量。

当数据很小时,暴力方法可以很好地工作。你的设置甚至可以简单到把所有预摄取向量都存储在 RAM 中。但对于包含数百万甚至数十亿 chunks 的大规模生产部署,我们需要比暴力搜索更聪明的方法。好消息是,如果我们愿意容忍小误差,就不需要计算查询与数据库中所有向量之间的点积。这就是近似最近邻(ANN)搜索的思想。相对地,暴力方法有时也称为精确最近邻(ENN)或 FlatIndex 搜索。

近似最近邻算法

为了加速向量搜索,几乎所有向量数据库,或传统关系型数据库管理系统(RDBMS)以及 NoSQL 数据库中的向量搜索插件,都会提供某种形式的 ANN 算法。ANN 中的 “approximate” 意味着该算法不保证找到精确最近邻,而是找到足够接近的邻居。

最突出的 ANN 算法称为分层可导航小世界图(HNSW)。HNSW 构建了一个多层结构,其中每个向量都会与同一层中附近的向量连接。上层包含少量向量,代表数据集的粗粒度视图;下层包含更多向量,支持更细粒度的细节。查询从顶部开始向下移动,在每一层都试图到达更接近查询的向量。

如果你对 HNSW 的算法细节感兴趣,可以阅读原始论文,或者阅读带代码实现的 HNSW 教程,该教程可以可视化 HNSW 网络的构建和搜索过程。

为了让这个过程更直观,想象你要找到距离加州 Los Altos 某个坐标最近的美国城市。暴力方法会计算这个坐标到全国每个城市的距离。而 HNSW 则分层推进。在最高层,也就是第 1 层,我们只将查询与两个主要地标城市进行比较:

Los Angeles
New York City

哪个更近,就会给我们一个强提示,说明下一步应该去哪里搜索。假设查询更接近 Los Angeles。然后我们进入第 2 层,把查询与美国西部几个大城市进行比较:

Los Angeles 本身
San Francisco
Seattle
Denver

如果 San Francisco 最接近,我们就缩小到北加州。然后下降到第 3 层,只比较湾区内的城市:

San Francisco 本身
Oakland
San Jose

假设在这一层 San Jose 最近。我们继续下降到第 4 层,现在评估 South Bay 的具体城市,例如:

Palo Alto
Mountain View
Sunnyvale
San Jose 本身

当我们到达最低层时,候选城市已经非常局部化,我们通过每一步只计算少量城市之间的距离,而不是计算全国所有城市的距离,就找到了最近匹配。这种逐步精化的搜索方式——先粗后细——就是 HNSW 在实践中如此高效的原因。

想查看 HNSW 的图示,可以看原始 HNSW 论文中的第一张图。

由于 HNSW 是通过图进行贪婪导航,因此仍然有可能——虽然很少见——在起始决策稍微偏离时被拉到“错误”区域,例如查询恰好位于两个主要都市圈边界上。在这种情况下,HNSW 可能返回一个距离真实最近城市非常接近的城市,但不是数学上绝对最近的那一个。这就是 ANN 中 “approximate” 的本质。

作为交换,HNSW 提供了出色性能:它只检查数据集中的一小部分,同时在大多数实际应用中实现几乎与精确搜索相同的准确率,但计算成本只是其一小部分。

一个实现 HNSW 算法的著名开源库是 Meta/Facebook 的 Facebook AI Similarity Search(FAISS)。FAISS 提供了高度优化的 HNSW 以及其他 ANN 算法实现,使开发者更容易把 ANN 搜索能力集成进自己的应用中。

向量数据库

向量数据库是用于管理、存储和查询高维嵌入的系统,并且主要实现一种可扩展的 ANN 形式。然而,向量数据库并不只是搜索相似向量,还会处理更广泛的数据库问题:持久化、更新、删除、元数据管理、过滤、可扩展性,以及与其他系统集成。

从历史上看,在主流数据库提供强大的向量索引之前,专门的向量数据库就已经出现了;如今,许多通用数据库也支持向量搜索。例如 PostgreSQL 的 pgvector、SQLite 的 sqlite-vec,以及 MongoDB 的 Atlas Vector Search。因此,今天我们不能简单地认为向量数据库只执行向量搜索,而传统 RDBMS 或 NoSQL 数据库只执行非向量搜索。

许多向量数据库系统,例如专有的 Pinecone,以及开源的 Milvus、Weaviate 和 Qdrant,都支持元数据过滤。元数据过滤并不是在嵌入上执行,而是在与嵌入关联的元数据上执行,例如文档 ID、时间戳或其他属性。元数据只是字段,类似于传统 RDBMS 或 NoSQL 数据库中的列,因此元数据过滤执行的是类似传统数据库过滤的操作。

虽然元数据过滤不是在向量嵌入本身上执行,但它会影响向量搜索的速度和质量。例如,假设你有数十年所有上市公司的季度财务报告。如果你想询问某家公司在某个特定时间段内的问题,那么数据库系统最好先根据公司名称和时间段过滤数据,从而缩小搜索空间,然后再执行向量搜索。向量搜索比传统过滤昂贵且慢得多。

元数据过滤可以在向量搜索之前、与向量搜索并行,或在向量搜索之后进行。我们上面描述的例子是预过滤。Pinecone 有一篇不错的博客解释了预过滤和后过滤。联合执行元数据过滤和向量搜索的算法包括 ACORN。

使用向量搜索时需要考虑的参数

RAG 流水线中向量搜索的一个关键参数,是要返回的结果数量,通常记为 k。k 的值不能太小,也不能太大:如果 k 太小,用户查询的真实答案可能因为排名在 k 之后而被漏掉;如果 k 太大,我们可能会遇到许多问题:首先,可能超过 LLM 的上下文窗口长度;其次,可能引入噪声和无关信息,从而降低生成质量;最后,可能不必要地增加生成步骤的成本和延迟。

前面我们提到过 Matryoshka Representation Learning,它给用户提供了选择任意嵌入维度的灵活性。降低维度会减小向量数据库大小,并加速向量搜索。然而,维度降低过多可能会降低向量搜索质量。平衡点取决于具体使用场景和所使用的嵌入模型。还要注意你的向量数据库所能支持的最大嵌入维度。例如,pgvector 对 32-bit 全精度浮点数最多支持 2000 维。

代码示例:使用 pgvector 存储和检索向量

Pgvector 是一个为 PostgreSQL 添加向量搜索支持的扩展。这里我们展示使用 pgvector 的一些关键步骤。完整可执行代码请参考本书 GitHub 仓库中的 Jupyter notebook pgvector-simple.ipynb

首先,我们使用 Sentence Transformers 对一些句子进行嵌入:

from typing import List
from sentence-transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
model = SentenceTransformer('all-MiniLM-L6-v2')
sample_sentences = [
    "I am a happy person.",
    "I am a joyful person.",
    "I am a pessimistic person.",
    "I am not an optimistic person."
]
embeddings = model.encode(sample_sentences)

嵌入是二维 NumPy ndarray,其中每一行对应一个句子的嵌入,每一列对应一个嵌入维度。

然后,我们创建一个名为 sentence_embeddings 的表,包含两列:sentenceembedding

import psycopg2
import numpy as np

# Connect to a PostgreSQL database
conn = psycopg2.connect(
    host="YOUR_HOST", port="YOUR_PORT",
    database="YOUR_DB", user="YOUR USER"
)
conn.autocommit = True
cursor = conn.cursor()
# Enable pgvector extension
cursor.execute("CREATE EXTENSION IF NOT EXISTS vector;")
# Create a table to store sentences and their embeddings
cursor.execute("""
    CREATE TABLE IF NOT EXISTS sentence_embeddings (
        sentence TEXT,
        embedding VECTOR(384)
    )
    """)
# Create HNSW index in the table "sentence_embeddings" for efficient 
similarity search
cursor.execute("""
    CREATE INDEX 
    ON sentence_embeddings 
    USING hnsw (embedding vector_l2_ops) 
    WITH (m = 16, ef_construction = 64);
""")

上面的 autocommit 设置省去了手动向数据库提交 SQL 命令的麻烦。在创建用于存储向量的 embedding 列时,我们指定其维度为 384。这是因为 all-MiniLM-L6-v2 的嵌入维度是 384。在上面代码片段末尾,我们在 sentence_embeddings 表中创建了一个名为 hnsw 的索引,用来支持该表上的高效搜索。

准备好 sentence_embeddings 表后,我们可以将预先计算好的嵌入插入其中。我们会使用 SQL INSERT 语句一次插入一个嵌入。注意,前面得到的变量 embeddings 是二维 NumPy ndarray。当插入 PostgreSQL(PG)表时,每一行都需要先转换成浮点数列表,然后序列化成字符串,以便正确形成一个字符串形式的 INSERT 语句:

# Insert sample sentences and their embeddings into the table 
"sentence_embeddings" for sentence, embedding in zip(sentences, embeddings):
    embedding_as_list: List[float] = embedding.tolist()
    cursor.execute(
        "INSERT INTO sentence_embeddings (text, embedding) "
        "VALUES (%s, %s::vector)",
        (sentence, embedding_as_list)
    )

我们简单看一下这个表:

# Take a sneak peek at the table contents
cursor.execute("SELECT text, embedding FROM sentence_embeddings;")
rows = cursor.fetchall()
for row in rows:
    print(row[0], row[1][:3])

如果你看到如下截断输出,那就说明状态不错:

I am a happy person. [0.00465,0.06651,0.01479]
I am a joyful person. [0.03455,0.05649,0.00731]
I am a pessimistic person. [0.06152,0.05317,0.01788]
I am not an optimistic person. [0.02472,0.02528,-0.00176]

最后,我们来运行向量搜索。为了能够反复搜索,我们创建一个函数:

def vector_search(query, model, top_k):
    query_embedding = model.encode([query])[0]
    query_embedding_as_list: List[float] = query_embedding.tolist()
    cursor.execute(
            """
            SELECT text, 
                    1 - (embedding <=> %s::vector) as similarity
            FROM sentence_embeddings
            WHERE embedding IS NOT NULL
            ORDER BY embedding <=> %s::vector
            LIMIT %s;
            """,
            (query_embedding_as_list, query_embedding_as_list, top_k)
        )
    results = cursor.fetchall()
    for row in results:
        print(row)

特殊标记 ::vector 会告诉 PG 将字段 embedding 视为向量。操作符 <=> 执行余弦相似度搜索——由于 sentence-transformers 会生成归一化向量,因此这里余弦相似度等于点积。在上面的 SQL SELECT 语句中,embedding 指的是 sentence_embeddings 表中的 embedding 列。

你可能会疑惑,为什么我们使用 1 - (embedding <=> %s::vector) as similarity。这是因为在 pgvector 中,<=> 操作实际衡量的是两个向量之间的不相似度。因此,我们必须使用它的补数来衡量相似度。

最后,让我们让向量搜索跑起来!

query = "I am a smiling person."
vector_search(query, model, 3)

打印结果应该是:

('I am a happy person.', 0.7639919010393534)
('I am a joyful person.', 0.6934391466808068)
('I am not an optimistic person.', 0.3835604305136333)

正如预期,从 “happy” 和 “joyful” 到 “not an optimistic person” 的相似度分数出现了明显下降,这意味着 “a happy person” 和 “a joyful person” 与 “a smiling person” 的语义相似度远高于 “not an optimistic person”。

我们再试一个不同查询:

query = "I have a bad feeling about the future."
vector_search(query, model, 3)

结果如下……很合理,对吧?

('I am a pessimistic person.', 0.5613031721891864)
('I am not an optimistic person.', 0.5059882553216297)
('I am a happy person.', 0.3224700977626531)

生成式 LLM

现在,我们来到了 RAG 技术栈的最后一步:将检索出的文本 chunks 和原始用户查询输入 LLM,以生成响应。

这一步是一个文本到文本的转换。虽然在前面的向量搜索步骤中,我们操作的是数值嵌入向量,但我们并不会把嵌入发送给 LLM。相反,如图 2-2 所示,我们发送给 LLM 的是一个完整的 LLM 提示词,其中包含指令,以及与被检索嵌入对应的原始文本 chunks,随后 LLM 以文本响应的形式作答。

LLM

LLM 对 RAG 至关重要,因为它们能够在分析上下文并进行推理之后,生成类似人类的文本。在 RAG 中,LLM 最常见的两类任务是摘要——将被检索出的 chunks 改写成连贯但简洁的内容;以及问答——基于被检索出的 chunks 生成用户查询的答案。LLM 很适合这些目的,因为 LLM 被训练为根据广泛的用户意图生成与输入文本相关的输出文本。摘要和问答恰好是最常见的用户意图之一。大量训练数据被用于这些任务,LLM 供应商也在这些功能上投入了大量精力。

使用神经网络产生输出称为推理。推理速度通常是使用 LLM 的瓶颈,尤其是当你的 RAG 系统运行在本地部署或隔离网络环境中,因此你必须自行服务 LLM 时。许多技术已经被开发出来,用于提升推理速度。例如:

量化通过降低精度来提升 LLM 吞吐量,例如从 32-bit(float32)降到 16-bit(使用 float16 或 bfloat16 表示)、8-bit(int8),甚至 4-bit(int4)。

FlashAttention 是一种流行方法,通过在计算注意力值时更高效地使用内存,加速基于 Transformer 的 LLM 推理。

vLLM 和 Ollama 等开源服务框架通过组合多种方法,加速 LLM 推理并降低硬件占用。

看到人类智慧被用来从现有硬件中挤出更多性能,真是一件美妙的事!

选择 LLM 时,需要在速度和质量之间取舍。通常来说,较大的 LLM 比较小的 LLM 具有更多能力,可以在复杂任务上产出更好的结果。但很多时候,任务足够简单,大模型相比小模型只略好一点,或者表现持平。在这种情况下,大模型带来的延迟和成本就很难被合理化。值得投入精力创建一个评估数据集,以找到在最低成本下满足你预期的最佳 LLM。关于 LLM 输出评估,参见“评估 LLM 和提示词模板”一节。

选择 LLM 时需要考虑的另一个因素是数据隐私。许多 RAG 使用场景要求本地部署或隔离网络部署,也就是说数据不能进入公共互联网。在这些情况下,不允许通过公共 HTTP endpoint 使用 OpenAI 的 GPT 系列或 Anthropic 的 Claude 模型等专有 LLM;对大多数开发者来说,开源 LLM 是唯一选择。写作本书时,在摘要和问答等常见 RAG 任务上,开源 LLM 与专有 LLM 之间的差距已经足够小;经验法则是,参数量超过 70B 的开源 LLM 已经足够不错。

RAG 提示词工程

LLM 有一种强大能力,称为指令遵循。这意味着它们可以根据提示词中提供的指令,被引导去执行特定任务。提示词工程就是设计和打磨这些提示词,以引出模型期望响应的实践。

在 RAG 语境中,需要正确组装检索结果和用户查询,从而引导 LLM 生成相关响应。这里没有绝对最佳方法,因为它可能取决于具体使用场景和所使用的 LLM。这里我们只是提供一些示例,用于启发读者开发自己的提示词模板。

下面是一个直接的提示词模板:

You are a helpful information-processing assistant. Extract answers related to a 
search query based on the context provided.

Here is the context: {retrieved_results}

Here is the search query: {query}

还记得前面“查询流程”一节中,我们提到要把原始用户查询改写成检索查询吗?现在我们已经到了生成步骤,因此这里可以使用原始用户查询。这有助于 LLM 生成更符合用户意图的响应。

不过,由于训练数据的原因,大多数 LLM 在查询放在上下文之前时表现更好。因此,下面这样的模板可能更适合:

You are a helpful information-processing assistant. Extract answers related to a 
search query based on the context provided.

Here is the search query: {query: str}

Here is the context: {retrieved_results: list[str]}

现代 LLM 具备执行复杂推理的能力,可以引导自己得出更好的答案。因此,你可能希望在提示词中加入这类指令,帮助模型利用其推理能力。例如,你可以在提示词中加入一个简单的思维链(CoT)指令:

If the answer is not obviously present in the context, please think step by step 
and provide a detailed explanation of your reasoning process.

由于幻觉是一个很大的问题,你可以明确告诉 LLM 遵循上下文中的信息,例如在提示词模板中加入如下指令:

If an answer cannot be reasonably inferred from the context, please simply say 
"I don't know." If you used any assumptions to arrive at your answer, please 
clearly state what assumptions are made.

到目前为止,我们的提示词模板都非常通用,或者说与任务无关。如果你知道用户会问的查询性质,可以将提示词定制得更具体、更有效。例如,如果你知道任务是问答,你的提示词模板可以简化为:

Answer the question {query: str}, 
given the context {retrieved_results: list[str]}

另一个例子,如果你知道用户查询不是正式问题,而更像 Google 搜索查询,那么你的提示词模板可以针对摘要任务进行定制:

You are a good summarizer. Please summarize the information about {query: str} 
from the context below:
 {retrieved_results: list[str]}. 

提供背景信息可能会提升 LLM 输出。例如,如果你知道 RAG 应用的领域是科学或体育,可以在提示词开头写类似“you are an expert in science/sports”的内容。

最后一个建议是,一种常见实践是使用 XML 标签划分提示词中的不同部分,帮助 LLM 理解提示词结构。例如:

You are a helpful information-processing assistant. Extract answers related to a 
search query based on the context provided.

<query>{query: str}</query>
<context>{retrieved_results: list[str]}</context>

评估 LLM 和提示词模板

市场上有许多 LLM,也有许多提示它们的方法。一个自然问题是:如何选择合适的 LLM 和对应的提示词模板?这里我们不深入 RAG 评估的兔子洞——第 6 章会对此作较详细讨论——只简要讨论一些关键步骤。

第一步是准备评估查询,这些查询应当反映或模拟用户可能发送给 RAG 系统的查询类型。

如果你有大量现有用户查询,那太好了。否则,可以结合你对数据、用户行为,以及潜在 RAG 系统其他信息的了解,通过提示 LLM 合成查询。

下一步是构建用于评估 RAG 响应的“批评者”。一种常见实践称为 LLM-as-a-judge。我们再次利用 LLM 的指令遵循能力,把它们配置成评估器,用来判断响应的不同方面。通常有两个常见方面:

相关性——响应是否回答了查询?

忠实性——响应是否被检索出的文档充分支持?

注意,我们可以使用同一个 LLM 同时进行生成和评估。得益于 LLM 的指令遵循能力,一个 LLM 可以被配置成执行不同任务。

LLM-as-a-judge 有几个缺点。第一,它可能很慢且昂贵,因为它使用昂贵的 LLM。第二,它并不总是准确。LLM 可能产生幻觉,例如从一步推理到下一步的过程可能有缺陷,因此 LLM 的判断也可能有缺陷。第三,它可能不一致。同一个 LLM 在不同时间可能给出不同判断。

LLM-as-a-judge 的替代方案是专用自然语言生成(NLG)评估模型,这类模型通常比 LLM 小得多。由于经过微调用来评估 LLM 生成的特定方面,这些模型比 LLM-as-a-judge 更快、更便宜,也更稳健。例如,Vectara 的 HHEM 是一个经过时间检验的忠实性判断模型,其共同作者包括本书作者。它在 2024 年 8 月首次发布到 2025 年 8 月之间被下载超过 500 万次。Galileo 的 Luna 是另一个幻觉判断模型。

最后一种,也是黄金标准的评估形式,是人工评估。人类评估者可以对 LLM 响应提供最准确、最细致的评估,能够考虑上下文、意图,以及自动化系统可能遗漏的细微差别。然而,人工评估也是资源消耗最大、耗时最长的方法。它通常用于自动化评估完成后,作为最终的小规模检查。

要评估 LLM 和提示词模板,只需将评估查询发送给 RAG 系统并收集响应。然后使用已建立的批评者评估响应质量。如果你的评估标准是多维的,可以考虑使用加权评分系统,来反映响应质量的不同方面。最后,选择根据你的评估标准表现最好的 LLM 和提示词模板组合即可。

代码示例:在 RAG 中使用 Anthropic Claude 生成响应

Jupyter notebook generative_LLMs.ipynb 提供了一个直接演示,说明如何使用 LLM 和提示词模板,基于用户查询和上下文生成响应。

我们使用“评估 LLM 和提示词模板”一节的实际文本作为上下文,并向 Anthropic 的 Claude Sonnet 4.5 提出三个查询:

“If I don’t care about budget or time, what’s the most accurate way to judge the RAG system?”

“What are the common aspects that people judge the RAG system on?”

“How many ways are there to evaluate the RAG system? Just method names.”

提示词模板类似于我们在“RAG 提示词工程”中介绍过的模板:

You are a good reader. Answer the query based on the context provided. Give me a 
short answer. 

Query: {query}
Context: {context}

给定一个查询和上下文后,我们通过 Python API 向 Anthropic 的 Claude Sonnet 4.5 发起请求:

import anthropic
response = anthropic.Anthropic().messages.create(
    model="claude-sonnet-4-5",
    messages=[
        {"role": "user", 
        "content": prompt_template.format(query=query, context=context)}
    ]
)
print (response.content[0].text)

对于第一个查询,响应是:

**Human evaluation** is the most accurate way to judge a RAG system when budget 
and time aren't constraints. The context explicitly states that "human 
evaluators can provide the most accurate and nuanced assessments of LLM 
responses, taking into account context, intent, and subtlety that automated 
systems may miss" and describes it as the "golden evaluation."

对于第二个查询,响应是:

Based on the context, people commonly judge RAG systems on two main aspects:
    1. **Relevance** - whether the response actually answers the query
    2. **Faithfulness** - whether the response is well supported by the 
       retrieved documents

These evaluations can be performed using LLM-as-a-judge, dedicated NLG 
evaluation models, or human evaluation.

对于第三个查询,响应是:

Based on the context, there are **3 ways** to evaluate RAG systems:

     1. **LLM-as-a-judge**
     2. **NLG evaluation models**
     3. **Human evaluation**

在这三个测试查询中,Anthropic 的 Claude Sonnet 4.5 提供的响应都与上下文相关且忠实于上下文。这说明我们的简单提示词模板是有效的。

总结

本章拆解了让 RAG 系统在实践中运行的基础元素。从解析混乱的真实世界文档,到为了效率对其进行切分;从将文本转换成有意义的嵌入,到把这些嵌入存储进向量数据库;从执行快速近似搜索,到提示 LLM 基于检索证据进行推理——我们探索了这些步骤如何形成一条连贯流水线,将静态数据转化为动态、由查询驱动的智能。

然而,当我们从理论走向实现时,会出现几个关键现实:

摄取瓶颈

摄取期间引入的错误——例如糟糕的 OCR 或丢失的格式信息——会沿着下游传播,并且无法在检索或生成阶段被可靠纠正。

切分的细微差别

默认或朴素切分策略很少足以用于生产;必须通过严格的检索评估来验证它们,以确保上下文得到保留。

检索限制

即使有高质量嵌入,检索仍然可能因为冗余、噪声或范围不匹配而失败。这正是我们需要混合搜索和过滤检索方法的原因,这些内容将在第 3 章中探索。

虽然每一层看起来都很模块化,但它们之间的交互才真正决定了一个 RAG 系统的成败。有效检索依赖深思熟虑的切分。高质量生成依赖准确检索。索引设计影响延迟和成本。嵌入模型选择影响相关性。没有哪个组件是孤立运作的,而改进一个 RAG 系统通常需要端到端检查这些连接。

本章中的概念和代码示例,是构建生产级 RAG 应用的基础工具箱。在下一章中,我们将在此基础上继续推进,探索如何用高级技术增强基础技术栈:混合搜索、重排序,以及能够让 RAG 系统更稳健、更可扩展,并能处理复杂真实世界工作负载的架构升级。

注 1:唯一例外是 tagged PDF 文件,这类文件是出于可访问性原因设计的,确实具有结构。
注 2:Jeffrey L. Elman, “Finding Structure in Time,” Cognitive Science 14, no. 2 (1990): 179–211。
注 3:Tomas Mikolov 等,“Distributed Representations of Words and Phrases and Their Compositionality,” Advances in Neural Information Processing Systems 26,NeurIPS 2013。
注 4:严格来说,被嵌入的不是词,而是 token。为了更精确地捕捉含义,现代方法有时会把词拆成子词 token,例如把 “geopolitical” 拆成 “geo” 和 “political”。
注 5:这个短课程由 DeepLearning.AI 提供,并由本书作者之一讲授。
注 6:Yu A. Malkov 和 D. A. Yashunin,“Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs,” IEEE Transactions on Pattern Analysis and Machine Intelligence 42, no. 4,2020 年 4 月:824–836。