用Python手把手教你实现RAG,让大模型检索更精准!收藏必备

本文详细讲解RAG(检索增强生成)的核心流程,包括基于大小、结构和语义的文本分块策略,使用VoyageAI生成文本嵌入,向量数据库模拟存储与余弦相似度检索,并融合BM25词法搜索实现混合检索;重点涵盖五步RAG实现:文本拆分、嵌入生成、向量存储、查询处理、相似内容检索,以及倒数排名融合(RRF)的多索引架构设计。

本文将带你从零开始,手把手实现RAG(检索增强生成)流程,通过文本分块、嵌入向量、语义搜索、BM25词法搜索等步骤,构建一个更精准的文档检索系统。每个环节都配有可运行的Python代码,帮助你理解和应用RAG技术。

写在前面

把一份 800 页的财务年报直接塞给大模型提问,不仅成本高、速度慢,效果也会大打折扣。RAG(检索增强生成) 用一种更聪明的方式解决这个问题:先把文档拆成小块,提问时只把最相关的部分喂给模型。

RAG 流水线全景

RAG 流水线全景

本文带你从零手写实现上图的完整流程——文本分块、嵌入向量、语义搜索、BM25 词法搜索,再到混合检索,每个环节都配有可运行的 Python 代码。

  1. 什么是 RAG

检索增强生成(Retrieval Augmented Generation,RAG)是一种帮助我们处理那些无法放入单个提示词的大型文档的技术。RAG 不是将所有内容塞进一个巨大的提示词,而是将文档拆分为多个块(chunk),在回答问题时只包含最相关的部分。

如果你有一份 800 页的财务文档,想向 Claude 提出具体问题,比如“这家公司面临什么风险?”你需要将文档中的相关信息传递给 Claude,最简单的一种方式就是像下面这样把这 800 页内容直接塞到提示词中:

根据技术文档回答用户的问题。

<用户问题>
{user_question}
</用户问题>

<财务文档>
{financial_document}
</财务文档>

但是这种方法存在严重的局限性:

  • 提示词长度有硬性限制——文档可能过长
  • Claude 在处理很长的提示词时效果会下降
  • 更大的提示词处理成本更高
  • 更大的提示词处理时间更长

RAG 采用了一种更聪明的方法。在预处理阶段先将文档拆分为较小的块,然后当用户提出问题时,找到与问题最相关的块,只将这些块放到提示词中。在上述示例中,如果有人问“这家公司面临什么风险?”你会在所有块中搜索,找到和“风险因素”相关的块,然后只将那个相关的块包含在提示词中:

image.png

image.png

RAG 带来的优势有很多:

  • Claude 可以只关注最相关的内容
  • 可扩展到非常大的文档
  • 支持处理多个文档
  • 更小的提示词成本更低、运行更快

但是也面临很多挑战,比如:

  • 需要预处理步骤来拆分文档
  • 需要搜索机制来查找“相关”的块
  • 包含的块可能无法提供 Claude 所需的全部上下文
  • 拆分文本有多种方法,哪种最好?

例如,你可以将文档拆分为等大的部分,也可以根据文档结构(如标题和章节)创建块。每种方法都有权衡,你需要根据具体使用场景进行评估。

RAG 涉及许多技术决策,并非简单地将所有内容放入提示词。需要结合应用场景具体分析,使用 RAG 的收益是否大于复杂性。当处理非常大的文档、多个文档,或需要优化成本和性能时,RAG 尤其有价值。

RAG 用简单性换取了可扩展性和效率,虽然它需要更多的前期工作来实现,但能够处理通过简单提示词填充无法应对的文档集合。

  1. 文本分块策略

文本分块(Text Chunking)是构建 RAG 流水线中最关键的步骤之一,文档的拆分方式直接影响整个系统的质量。糟糕的分块策略可能导致不相关的上下文被插入提示词,使 AI 给出完全错误的答案。

举个例子:你有一份包含医学研究和软件工程两个章节的文档,如果分块不当,当用户问“工程师今年修复了多少个 bug?”时,可能仅仅因为医学章节恰好在不同语境下包含了“bug”这个词,就会得到医学研究的信息而不是软件工程的信息。

这就是为什么选择正确的分块策略如此重要。我们最常见的分块策略有三种:

  • 基于大小分块
  • 基于结构分块
  • 基于语义分块

下面我们来分别探讨这三种主要方法。

基于大小分块

基于大小(Size-Based)分块是最简单的方法 —— 将文本划分为等长的字符串。如果你有一个 325 个字符的文档,可以将它拆分为三个大约 108 个字符的块。

image.png

image.png

这种方法易于实现,适用于任何类型的文档,但缺点也很明显:

  • 可能在句子中间截断单词
  • 块会丢失周围文本中的重要上下文
  • 章节标题可能与内容分离

为了解决这些问题,可以在块之间添加重叠(overlap)。这意味着每个块包含相邻块的一些字符,提供更好的上下文并确保单词和句子完整:

image.png

image.png

假设 text 是我们读取的完整文本,以下是一个基于大小分块的基本实现方法:

def chunk_by_char(text, chunk_size=150, chunk_overlap=20):
    chunks = []
    start_idx = 0
    
    while start_idx < len(text):
        end_idx = min(start_idx + chunk_size, len(text))
        chunk_text = text[start_idx:end_idx]
        chunks.append(chunk_text)
        
        start_idx = (
            end_idx - chunk_overlap if end_idx < len(text) else len(text)
        )
    
    return chunks

chunk_size 为块大小,chunk_overlap 为相邻块之间重叠的字符数。

基于结构分块

基于结构(Structure-Based)分块是指根据文档的自然结构来划分文本——标题、段落和章节。当你有格式良好的文档(如 Markdown 文件)时,这种方法效果很好。

image.png

image.png

对于 Markdown 文档,可以按标题标记进行拆分,以下是一个基本的实现方法:

def chunk_by_section(document_text):
    pattern = r"/n## "
    return re.split(pattern, document_text)

这种方法能生成最干净、最有意义的块,因为每个块代表一个完整的章节。不过,它只在文档结构有保证时才有效。现实生活中很多文档都是纯文本或 PDF,没有清晰的结构标记,这种方法也很难奏效。

基于语义分块

基于语义(Semantic-Based)的分块是最复杂的方法,先将文本拆分为句子,然后使用自然语言处理来判断连续句子之间的相关程度,再将相关的句子组合成块。

这种方法计算成本高,但能生成最相关的块。它需要理解单个句子的含义,实现起来比其他策略更复杂。

基于句子的分块

一种实用的折中方案是按句子分块。使用正则表达式将文本拆分为单个句子,然后将它们分组为块,并可选择添加重叠:

import re

def chunk_by_sentence(text, max_sentences_per_chunk=2, overlap_sentences=1):
    sentences = re.split(r"(?<=[.!?。!?])/s*", text)

    chunks = []
    start_idx = 0

    while start_idx < len(sentences):
        end_idx = min(start_idx + max_sentences_per_chunk, len(sentences))

        current_chunk = sentences[start_idx:end_idx]
        chunks.append(" ".join(current_chunk))

        start_idx += max_sentences_per_chunk - overlap_sentences

        if start_idx < 0:
            start_idx = 0

    return chunks

这里需要注意正则表达式需要考虑中文和英文标点符号。

我们提供的几种分块策略基本实现代码在后面会反复用到,这里你可以结合以下方法进行练习:

with open("./report_zh.md", "r") as f:
    text = f.read()

chunks = chunk_by_section(text)

[print(chunk + "/n----/n") for chunk in chunks]

你需要找一个中文的 markdown 文档,将其命名为 report_zh.md 。

在实际应用开发过程中,我们需要结合具体使用场景和文档结构来选择分块策略:

  • 基于结构:你能控制文档格式时(如公司内部报告)效果最好
  • 基于句子:适用于大多数文本文档的折中方案
  • 基于大小:最可靠的兜底方案,适用于任何内容类型,包括代码

带重叠的基于大小的分块通常是生产环境的首选,因为它简单可靠,且适用于任何文档类型。虽然可能无法给出完美的结果,但它能持续产生合理的块,不会导致流水线出错。

请记住:没有单一“最好”的分块策略。正确的方法取决于你的具体文档、使用场景,以及你在实现复杂性和块质量之间做出的权衡。

  1. 文本嵌入

将文档拆分为块之后,RAG 流水线的下一步是找到与用户问题最相关的块。这本质上是一个搜索问题——你需要浏览所有文本块,并找到与用户提问相关的那些块。

查找相关块最常用的方法是语义搜索(Semantic Search),与查找精确单词匹配的关键词搜索不同,语义搜索使用文本嵌入(Text Embedding) 来理解用户问题和每个文本块的含义与上下文。

文本嵌入是文本含义的数值表示,可以把它想象成将单词和句子转换为计算机能够理解的数学表达形式。处理流程如下:

  • 将文本输入嵌入模型
  • 模型输出一长串数字(即嵌入)
  • 每个数字的范围从 -1 到 +1
  • 这些数字表示输入文本的不同质量或特征

image.png

image.png

理解数字含义

嵌入中的每个数字本质上是输入文本某个质量的“分数”。但这里需要注意:我们并不确切知道每个数字代表什么含义。

虽然可以想象某个数字可能代表“文本的愉快程度”或“文本与娱乐活动的相关程度”,但这些只能猜个大概。每个维度的实际含义是模型在训练过程中学习到的,人类无法直接解读。

image.png

image.png

使用 VoyageAI 生成嵌入

由于 Anthropic 目前不提供嵌入生成服务,本文选择免费的 VoyageAI 嵌入模型(更多模型选择可以参考 MTEB )。开始前你需要:

  • 到 VoyageAI 官网注册账户
  • 获取 API 密钥(免费)
  • 将密钥添加到环境变量

image.png

image.png

在你的 .env 文件中添加:

VOYAGE_API_KEY="your_key_here"

代码实现

首先,安装 VoyageAI 库:

%pip install voyageai

这里需要注意,有的人会在终端去安装 voyageai ,有的人直接在 Jupyter notebook(VSCode) 安装,如果你是终端安装,默认是安装到 miniconda 的 Python 环境,如果你在运行 Jupyter notebook 时选择的 Python 运行环境不是这个,可能读不到,要解决这个问题,要么在 Jupyter 重新安装一遍,要么重新选择 miniconda 作为 Jupyter 的运行环境。

然后设置客户端并创建生成嵌入的函数:

from dotenv import load_dotenv
import voyageai

load_dotenv()
client = voyageai.Client()

def generate_embedding(text, model="voyage-3-large", input_type="query"):
    result = client.embed([text], model=model, input_type=input_type)
    return result.embeddings[0]

测试嵌入函数:

with open("./report_zh.md", "r") as f:
    text = f.read()

chunks = chunk_by_section(text)
generate_embedding(chunks[0])

当你在文本块上运行此函数时,会得到一列表示嵌入的浮点数。这个过程快速且简单——真正的挑战在于理解如何在 RAG 流水线中有效地使用这些嵌入来找到最相关的内容:

image.png

image.png

下一步就是学习如何比较嵌入以确定哪些块与用户的问题最相似,这是语义搜索过程的核心。

  1. 完整的 RAG 流程

前面我们已经介绍了 RAG、文本分块和文本嵌入的基础知识,接下来这几节就要带大家搭建一个完整的 RAG 流水线,通过实操来体验这些部分是如何协同工作来检索相关信息并生成响应的。本小节是实操前的理论基础,第一次阅读可能比较抽象,不要紧,接着往后跟着实操后再回来看就懂了。

步骤一:拆分源文本

首先,我们将源文档拆分为可管理的块。举个例子,我们使用以下两部分简单的文本:

  • 第一部分:医学研究——“今年在理解 XDR-47 方面取得了重大进展,这是一种我们以前从未见过的’bug’。”
  • 第二部分:软件工程——“该部门投入了大量精力来研究分布式系统中的各种感染向量”

步骤二:生成嵌入

接下来,我们使用嵌入模型将每个文本块转换为数值嵌入。为了更容易理解,假设我们有一个完美的嵌入模型,它总是返回恰好两个数字,并且我们知道每个数字代表什么。

image.png

image.png

在我们的假想模型中:

  • 第一个数字表示文本讨论医学领域的程度
  • 第二个数字表示文本讨论软件工程的程度

对于医学研究部分,我们可能得到 [0.97, 0.35]——非常侧重医学,不过由于“bug”这个词导致包含了少量的软件元素。对于软件工程部分,我们得到 [0.25, 0.98]——非常侧重软件,不过由于“感染向量”而带有了少量的医学色彩。

嵌入 API 通常会执行一个归一化(Normalization)步骤,将每个向量缩放到 1.0 的幅度,API 内部会自动处理这一数学计算过程。我们最终得到的是一组归一化后的向量,如 [0.944, 0.331] 和 [0.295, 0.955] 等,都位于 [-1, +1] 的区间内。将一个二维向量表示在坐标系中可以表示为:

image.png

image.png

因为每个向量的幅度(即模)均为 1 ,所以在坐标都落在单位圆上,每个点代表一个文本块。

步骤三:存储到向量数据库

从嵌入模型的到文本块的向量数据后,可以将其存储到向量数据库(Vector Database)。向量数据库是一种专门用于存储、比较和搜索像文本嵌入这样的长数字列表的数据库。在本文并不会采用真实的向量数据库,而是使用一个自定义的对象来模拟向量数据库,便于演示向量存储和查询的每一个细节。

到目前为止,所有的预处理工作就已经完成了,接下来就等待用户提交查询请求。

步骤四:处理用户查询

当用户提出问题,比如“我对公司很好奇。具体来说,软件工程部门今年做了什么?”时,我们通过相同的嵌入模型处理用户的问题。

这个查询最终可能被嵌入为 [0.1, 0.89] 类似的值 —— 医学分数低,软件工程分数高。归一化后,我们得到 [0.112, 0.993]。

步骤五:查找相似嵌入

我们将用户的查询嵌入发送到向量数据库,要求它找到最相似的已存储嵌入。数据库会返回软件工程部分,因为它与用户询问的内容最匹配。那么数据库是如何找到这一最佳匹配的呢?

向量数据库使用 余弦相似度(Cosine Similarity) 来确定哪些嵌入最相似,即通过测量两个向量之间夹角的余弦值大小来确定两个向量的相似程度:

image.png

image.png

在数学上的计算公式如下:

由于:

所以数据库认为向量 A 和向量 B 更相似。余弦相似度有以下几个要点:

  • 结果范围从 -1 到 1
  • 接近 1 的值表示高度相似
  • 接近 -1 的值表示非常不同
  • 0 表示正交(没有关系)

在我们的示例中,用户查询与软件工程块之间的余弦相似度为 0.983——非常高的相似度,与医学研究块的相似度则低得多,仅为 0.398 。

我们通常会在向量数据库文档中看到 余弦距离(Cosine Distance)。它的计算方式很简单,就是通过 (1 - 余弦相似度) 的到。使用余弦距离时:

  • 接近 0 的值表示高度相似
  • 更大的值表示较低相似度

调整后,两个向量约相似它们直接的余弦距离越小,这样更容易理解。

步骤六:创建最终提示词

最后,我们将用户的问题和找到的最相关文本块组合成一个提示词,发送给 Claude 以获得响应。

image.png

image.png

提示词可能如下所示:

回答用户关于财务文档的问题。

<用户问题>
工程师们今年修复了多少bug ?
</用户问题>

<报告>
## 第二部分:软件工程

该部门投入了大量精力来研究我们分布式系统中的各种感染向量。
</报告>

这就是完整的 RAG 流水线!系统基于语义相似度成功检索了最相关的信息,并将其作为上下文提供给 Claude 以生成准确的响应,下一节我们就动手来实现它。

  1. 实现 RAG 流程

既然我们已经从概念上理解了 RAG 流程,接下来让我们逐步实现它。我们将通过一个完整的示例来演示如何拆分文本、生成嵌入、存储到向量数据库以及执行相似度搜索。

五步 RAG 实现

我们的实现遵循前面讨论的五个步骤:

  1. 1. 按章节拆分文本

  2. 2. 为每个块生成嵌入

  3. 3. 创建向量存储并将每个嵌入添加到其中

  4. 4. 为用户的问题生成嵌入

  5. 5. 搜索存储以找到最相关的块

image.png

image.png

这张图展示了一个完整的 RAG 流水线的全部过程,展示了我们如何将用户查询转换为嵌入,并在向量数据库中搜索最相关的内容。

步骤一:拆分文本

首先,我们使用前面的 chunk_by_section 函数将文档按照章节拆分:

with open("./report_zh.md", "r") as f:
    text = f.read()

chunks = chunk_by_section(text)

测试一下:

chunks[2]

步骤二:生成嵌入

接下来,我们一次性为所有块创建嵌入:

embeddings = generate_embedding(chunks)

注意,这里需要对前面的嵌入函数进行优化,可支持批量嵌入,一方面是提升处理效率,另一方面是因为我们使用的 voyage 嵌入模型为免费版,所以对调用频率有限制,如果循环调用单个块的嵌入 API 会被限频报错:

def generate_embedding(chunks, model="voyage-3-large", input_type="query"):
    """生成文本的向量嵌入。

    Args:
        chunks: 单个字符串或字符串列表
        model: 嵌入模型名称
        input_type: 输入类型("query" 或 "document")

    Returns:
        list[list[float]] | list[float]:
            - 传入列表 → 返回二维列表 list[list[float]],每个元素是对应文本的嵌入向量
            - 传入字符串 → 返回一维列表 list[float],即该文本的嵌入向量

        这种设计是为了让调用方无需关心类型转换:
        批量生成嵌入时直接拿到列表,单独生成时直接拿到向量,避免到处写 [0] 取值。
    """
    is_list = isinstance(chunks, list)
    input = chunks if is_list else [chunks]
    result = client.embed(input, model=model, input_type=input_type)
    return result.embeddings if is_list else result.embeddings[0]

步骤三:存储到向量数据库

本文我们不使用真实的向量数据库,而是实现了一个教学演示级别的内存向量存储,方便体验其中的细节:

import math
from typing import Optional, Any, List, Dict, Tuple

class VectorIndex:
    def __init__(
        self,
        distance_metric: str = "cosine",
        embedding_fn=None,
    ):
        self.vectors: List[List[float]] = []
        self.documents: List[Dict[str, Any]] = []
        self._vector_dim: Optional[int] = None
        if distance_metric not in ["cosine", "euclidean"]:
            raise ValueError("distance_metric must be 'cosine' or 'euclidean'")
        self._distance_metric = distance_metric
        self._embedding_fn = embedding_fn

    def add_document(self, document: Dict[str, Any]):
        if not self._embedding_fn:
            raise ValueError(
                "Embedding function not provided during initialization."
            )
        if not isinstance(document, dict):
            raise TypeError("Document must be a dictionary.")
        if "content" not in document:
            raise ValueError(
                "Document dictionary must contain a 'content' key."
            )

        content = document["content"]
        if not isinstance(content, str):
            raise TypeError("Document 'content' must be a string.")

        vector = self._embedding_fn(content)
        self.add_vector(vector=vector, document=document)

    def add_documents(self, documents: List[Dict[str, Any]]):
        if not self._embedding_fn:
            raise ValueError(
                "Embedding function not provided during initialization."
            )

        if not isinstance(documents, list):
            raise TypeError("Documents must be a list of dictionaries.")

        if not documents:
            return

        contents = []
        for i, doc in enumerate(documents):
            if not isinstance(doc, dict):
                raise TypeError(f"Document at index {i} must be a dictionary.")
            if "content" not in doc:
                raise ValueError(
                    f"Document at index {i} must contain a 'content' key."
                )
            if not isinstance(doc["content"], str):
                raise TypeError(
                    f"Document 'content' at index {i} must be a string."
                )
            contents.append(doc["content"])

        vectors = self._embedding_fn(contents)

        for vector, document in zip(vectors, documents):
            self.add_vector(vector=vector, document=document)

    def search(
        self, query: Any, k: int = 1
    ) -> List[Tuple[Dict[str, Any], float]]:
        if not self.vectors:
            return []

        if isinstance(query, str):
            if not self._embedding_fn:
                raise ValueError(
                    "Embedding function not provided for string query."
                )
            query_vector = self._embedding_fn(query)
        elif isinstance(query, list) and all(
            isinstance(x, (int, float)) for x in query
        ):
            query_vector = query
        else:
            raise TypeError(
                "Query must be either a string or a list of numbers."
            )

        if self._vector_dim is None:
            return []

        if len(query_vector) != self._vector_dim:
            raise ValueError(
                f"Query vector dimension mismatch. Expected {self._vector_dim}, got {len(query_vector)}"
            )

        if k <= 0:
            raise ValueError("k must be a positive integer.")

        if self._distance_metric == "cosine":
            dist_func = self._cosine_distance
        else:
            dist_func = self._euclidean_distance

        distances = []
        for i, stored_vector in enumerate(self.vectors):
            distance = dist_func(query_vector, stored_vector)
            distances.append((distance, self.documents[i]))

        distances.sort(key=lambda item: item[0])

        return [(doc, dist) for dist, doc in distances[:k]]

    def add_vector(self, vector, document: Dict[str, Any]):
        if not isinstance(vector, list) or not all(
            isinstance(x, (int, float)) for x in vector
        ):
            raise TypeError("Vector must be a list of numbers.")
        if not isinstance(document, dict):
            raise TypeError("Document must be a dictionary.")
        if "content" not in document:
            raise ValueError(
                "Document dictionary must contain a 'content' key."
            )

        if not self.vectors:
            self._vector_dim = len(vector)
        elif len(vector) != self._vector_dim:
            raise ValueError(
                f"Inconsistent vector dimension. Expected {self._vector_dim}, got {len(vector)}"
            )

        self.vectors.append(list(vector))
        self.documents.append(document)

    def _euclidean_distance(
        self, vec1: List[float], vec2: List[float]
    ) -> float:
        if len(vec1) != len(vec2):
            raise ValueError("Vectors must have the same dimension")
        return math.sqrt(sum((p - q)  2 for p, q in zip(vec1, vec2)))

    def _dot_product(self, vec1: List[float], vec2: List[float]) -> float:
        if len(vec1) != len(vec2):
            raise ValueError("Vectors must have the same dimension")
        return sum(p * q for p, q in zip(vec1, vec2))

    def _magnitude(self, vec: List[float]) -> float:
        return math.sqrt(sum(x * x for x in vec))

    def _cosine_distance(self, vec1: List[float], vec2: List[float]) -> float:
        if len(vec1) != len(vec2):
            raise ValueError("Vectors must have the same dimension")

        mag1 = self._magnitude(vec1)
        mag2 = self._magnitude(vec2)

        if mag1 == 0 and mag2 == 0:
            return 0.0
        elif mag1 == 0 or mag2 == 0:
            return 1.0

        dot_prod = self._dot_product(vec1, vec2)
        cosine_similarity = dot_prod / (mag1 * mag2)
        cosine_similarity = max(-1.0, min(1.0, cosine_similarity))

        return 1.0 - cosine_similarity

    def __len__(self) -> int:
        return len(self.vectors)

    def __repr__(self) -> str:
        has_embed_fn = "Yes" if self._embedding_fn else "No"
        return f"VectorIndex(count={len(self)}, dim={self._vector_dim}, metric='{self._distance_metric}', has_embedding_fn='{has_embed_fn}')"

现在我们创建向量存储,并用嵌入及其关联文本填充:

store = VectorIndex()

for embedding, chunk in zip(embeddings, chunks):
    store.add_vector(embedding, {"content": chunk})

请注意,我们同时存储了嵌入和原始文本内容。这很关键,因为后续搜索时我们需要返回实际文本,而不仅仅是数值嵌入值。

步骤四:处理用户查询

当用户提问时,我们要把用户的问题也生成一个嵌入:

user_embedding = generate_embedding("软件工程系去年做了什么?")

步骤五:查找相关内容

最后,我们搜索向量存储以找到最相似的块:

results = store.search(user_embedding, 2)

for doc, distance in results:
    print(distance, "/n", doc["content"][0:200], "/n")

这个搜索返回两个最相关的块及其相似度分数(余弦距离)。

image.png

image.png

当我们运行关于软件工程部门的示例查询时,得到以下结果:

  • 第二部分:软件工程,距离为 0.71(最匹配)
  • 摘要,距离为 0.72(第二匹配)

结果显示了文档中哪些部分与用户的问题最相关,以及相似度分数。距离值越低表示相似度越高,因此第二部分与我们的查询最相关。

这个实现在基本场景下运行还不错,但在某些场景下可能表现不佳。在接下来的章节中,我们将探讨如何改进,使 RAG 系统更加健壮和准确。

关键要点是,RAG 的本质是将文本转换为数字(嵌入),高效地存储这些数字,然后在用户提问时使用数学相似度来查找相关内容。

  1. BM25 词法搜索

在构建 RAG 流水线时,你会发现仅靠语义搜索有时候并不能返回最佳结果。有时你需要精确的术语匹配,而语义搜索可能会遗漏这些。解决方案是使用一种称为 BM25 的技术,将语义搜索与词法搜索(Lexical Search)结合起来。

假设你在文档中搜索一个具体的事件 ID,如“INC-2023-Q4-011”。混合搜索策略解决方案是同时运行语义搜索和词法搜索,然后合并结果。这让你兼得两者的优势:

image.png

image.png

  • 语义搜索使用嵌入查找概念相关的内容
  • 词法搜索使用经典文本搜索查找精确的术语匹配
  • 合并结果结合两种方法以获得更好的准确性

BM25 的工作原理

BM25(Best Match 25)是 RAG 系统中用于词法搜索的流行算法。以下是它处理搜索查询的方式:

image.png

image.png

步骤一:分词

将用户的问题拆分为单个术语。例如,“a INC-2023-Q4-011”变为 ["a", "INC-2023-Q4-011"]。

步骤二:统计词频

查看每个术语在所有文档中出现的频率。常见词如“a”可能出现 5 次,而特定术语如“INC-2023-Q4-011”可能只出现 1 次。

步骤三:按重要性加权

出现频率较低的术语获得更高的重要性分数。单词“a”因为常见而获得低重要性,而“INC-2023-Q4-011”因为罕见而获得高重要性。

步骤四:查找最佳匹配

返回包含更多高权重术语实例的文档。

实现 BM25 搜索

以下是搭建基本 BM25 搜索系统的方法:

# 1. 按章节拆分文本
chunks = chunk_by_section(text)

# 2. 创建 BM25 存储并添加文档
store = BM25Index()
for chunk in chunks:
    store.add_document({"content": chunk})

# 3. 搜索存储
results = store.search("INC-2023-Q4-011 发生了什么?", 3)

# 打印结果
for doc, distance in results:
    print(distance, "/n", doc["content"][:200], "/n----/n")

其中 BM25Index 就是我们本节新增的核心对象:

# BM25 implementation
import jieba
from collections import Counter
from typing import Callable, Any, List, Dict, Tuple

class BM25Index:
    def __init__(
        self,
        k1: float = 1.5,
        b: float = 0.75,
        tokenizer: Optional[Callable[[str], List[str]]] = None,
    ):
        self.documents: List[Dict[str, Any]] = []
        self._corpus_tokens: List[List[str]] = []
        self._doc_len: List[int] = []
        self._doc_freqs: Dict[str, int] = {}
        self._avg_doc_len: float = 0.0
        self._idf: Dict[str, float] = {}
        self._index_built: bool = False

        self.k1 = k1
        self.b = b
        self._tokenizer = tokenizer if tokenizer else self._default_tokenizer_zh

    # 英文分词器
    def _default_tokenizer(self, text: str) -> List[str]:
        text = text.lower()
        tokens = re.split(r"/W+", text)
        return [token for token in tokens if token]
    
    # 中文分词器
    def _default_tokenizer_zh(self, text: str) -> List[str]:
        tokens = jieba.cut(text)
        return [t.lower() for t in tokens if re.match(r"/w+", t)]

    # 统计词频
    def _update_stats_add(self, doc_tokens: List[str]):
        self._doc_len.append(len(doc_tokens))

        seen_in_doc = set()
        for token in doc_tokens:
            if token not in seen_in_doc:
                self._doc_freqs[token] = self._doc_freqs.get(token, 0) + 1
                seen_in_doc.add(token)

        self._index_built = False

    # 计算稀有度:遍历词频列表 _doc_freqs ,使用 IDF 公式计算每个词的稀有度
    def _calculate_idf(self):
        N = len(self.documents)
        self._idf = {}
        for term, freq in self._doc_freqs.items():
            idf_score = math.log(((N - freq + 0.5) / (freq + 0.5)) + 1)
            self._idf[term] = idf_score

    def _build_index(self):
        if not self.documents:
            self._avg_doc_len = 0.0
            self._idf = {}
            self._index_built = True
            return

        self._avg_doc_len = sum(self._doc_len) / len(self.documents)
        self._calculate_idf()
        self._index_built = True

    # 分词
    def add_document(self, document: Dict[str, Any]):
        if not isinstance(document, dict):
            raise TypeError("Document must be a dictionary.")
        if "content" not in document:
            raise ValueError(
                "Document dictionary must contain a 'content' key."
            )

        # 文本内容
        content = document.get("content", "")
        if not isinstance(content, str):
            raise TypeError("Document 'content' must be a string.")

        # 分词器进行分词
        doc_tokens = self._tokenizer(content)

        self.documents.append(document)
        self._corpus_tokens.append(doc_tokens)
        # 统计词频
        self._update_stats_add(doc_tokens)

    def add_documents(self, documents: List[Dict[str, Any]]):
        if not isinstance(documents, list):
            raise TypeError("Documents must be a list of dictionaries.")

        if not documents:
            return

        for i, doc in enumerate(documents):
            if not isinstance(doc, dict):
                raise TypeError(f"Document at index {i} must be a dictionary.")
            if "content" not in doc:
                raise ValueError(
                    f"Document at index {i} must contain a 'content' key."
                )
            if not isinstance(doc["content"], str):
                raise TypeError(
                    f"Document 'content' at index {i} must be a string."
                )

            content = doc["content"]
            doc_tokens = self._tokenizer(content)

            self.documents.append(doc)
            self._corpus_tokens.append(doc_tokens)
            self._update_stats_add(doc_tokens)

        self._index_built = False
        
    # bm25 核心算法
    def _compute_bm25_score(
        self, query_tokens: List[str], doc_index: int
    ) -> float:
        score = 0.0
        # 分块中每个词出现的频次
        doc_term_counts = Counter(self._corpus_tokens[doc_index])
        # 文档总词数
        doc_length = self._doc_len[doc_index]

        for token in query_tokens:
            if token not in self._idf:
                continue

            # 词稀有度
            idf = self._idf[token]
            # 分词在当前块中的词频
            term_freq = doc_term_counts.get(token, 0)

            # k1 - 词频饱和参数:控制词频增长的收益递减
            # b - 文档长度惩罚:b=0 不惩罚长度,b=1 完全按长度归一化
            numerator = idf * term_freq * (self.k1 + 1)
            denominator = term_freq + self.k1 * (
                1 - self.b + self.b * (doc_length / self._avg_doc_len)
            )
            score += numerator / (denominator + 1e-9)

        return score

    def search(
        self,
        query_text: str,
        k: int = 1,
        score_normalization_factor: float = 0.1,
    ) -> List[Tuple[Dict[str, Any], float]]:
        if not self.documents:
            return []

        if not isinstance(query_text, str):
            raise TypeError("Query text must be a string.")

        if k <= 0:
            raise ValueError("k must be a positive integer.")

        if not self._index_built:
            self._build_index()

        if self._avg_doc_len == 0:
            return []

        # 查询语句分词
        query_tokens = self._tokenizer(query_text)
        if not query_tokens:
            return []

        # 遍历所有分块,得出每个块的 bm25 得分
        raw_scores = []
        for i in range(len(self.documents)):
            raw_score = self._compute_bm25_score(query_tokens, i)
            if raw_score > 1e-9:
                raw_scores.append((raw_score, self.documents[i]))

        # 得分排序:降序
        raw_scores.sort(key=lambda item: item[0], reverse=True)

        # 对 Top(k) 的数据块得分归一化:将 bm25 得分映射到 0-1 之间,值越小越相关
        normalized_results = []
        for raw_score, doc in raw_scores[:k]:
            normalized_score = math.exp(-score_normalization_factor * raw_score)
            normalized_results.append((doc, normalized_score))

        # 归一化得分排序:升序 --> 排序越靠前越相关!!!
        normalized_results.sort(key=lambda item: item[1])

        return normalized_results

    def __len__(self) -> int:
        return len(self.documents)

    def __repr__(self) -> str:
        return f"BM25VectorStore(count={len(self)}, k1={self.k1}, b={self.b}, index_built={self._index_built})"

整个代码比较长,我们来看一些前面讲过的核心实现。

分词器:

import jieba

def _default_tokenizer_zh(self, text: str) -> List[str]:
    tokens = jieba.cut(text)
    return [t.lower() for t in tokens if re.match(r"/W+", t)]

这里我们选择使用 jieba 作为默认的分词器,这是一个经典的专门针对中文文本的轻量级分词器,我们在分词后,还兼容处理了英文文本:将英文分词大写转小写。

统计词频:

def _update_stats_add(self, doc_tokens: List[str]):
    self._doc_len.append(len(doc_tokens))
    
    seen_in_doc = set()
    for token in doc_tokens:
        if token not in seen_in_doc:
            self._doc_freqs[token] = self._doc_freqs.get(token, 0) + 1
            seen_in_doc.add(token)
            
    self._index_built = False

将分词器分好的分词数组传入,挨个遍历累计每个词出现的频率,最后将词频保存到 _doc_freqs 字典,将出现过的词保存到 seen_in_doc 集合。

bm25 算法:

def _compute_bm25_score(self, query_tokens: List[str], doc_index: int) -> float:
    score = 0.0
    
    # 分块中每个词出现的频次
    doc_term_counts = Counter(self._corpus_tokens[doc_index])
    
    # 文档总词数
    doc_length = self._doc_len[doc_index]
      
    for token in query_tokens:
        if token not in self._idf:
        continue 
        
        # 词稀有度
        idf = self._idf[token]
        # 分词在当前块中的词频
        term_freq = doc_term_counts.get(token, 0)
          
        # k1 - 词频饱和参数:控制词频增长的收益递减
        # b - 文档长度惩罚:b=0 不惩罚长度,b=1 完全按长度归一化
        numerator = idf * term_freq * (self.k1 + 1)
        denominator = term_freq + self.k1 * (1 - self.b + self.b * (doc_length / self._avg_doc_len))
        score += numerator / (denominator + 1e-9)
      
    return score

其中,涉及两个关键参数:

k1:词频饱和参数:控制词频增长的收益递减
b:文档长度惩罚:b=0 不惩罚长度,b=1 完全按长度归一化

以及一个核心的计算 bm25 得分的公式:

其中:

执行查询:

def search(
        self,
        query_text: str,
        k: int = 1,
        score_normalization_factor: float = 0.1,
    ) -> List[Tuple[Dict[str, Any], float]]:
    # ...
    
    # 遍历所有分块,得出每个块的 bm25 得分
    raw_scores = []
    for i in range(len(self.documents)):
        raw_score = self._compute_bm25_score(query_tokens, i)
        if raw_score > 1e-9:
            raw_scores.append((raw_score, self.documents[i]))

    # 得分排序:降序
    raw_scores.sort(key=lambda item: item[0], reverse=True)

    # 对 Top(k) 的数据块得分归一化:将 bm25 得分映射到 0-1 之间,值越小越相关
    normalized_results = []
    for raw_score, doc in raw_scores[:k]:
        normalized_score = math.exp(-score_normalization_factor * raw_score)
        normalized_results.append((doc, normalized_score))

    # 归一化得分排序:升序 --> 排序越靠前越相关!!!
    normalized_results.sort(key=lambda item: item[1])

    return normalized_results

执行搜索你会得到比仅使用语义搜索好得多的结果。BM25 算法会优先处理实际包含特定搜索术语的章节,尤其是像事件 ID 这样的罕见术语。

image.png

image.png

我这里查询了最相似的 3 个文本块,你可以看到前两个文本块中都包含我们要查询的事件 ID ,所以它们的得分都是 0.2+ 或 0.3+ ,但是第三个文本块并不包含这个事件 ID ,所以它的得分都已经到 0.7+ 了,所以明显这个文本块与我们的查询没什么关系了。

注意:这里的得分和余弦距离一样,数值越小说明文本块与查询主题越相关。

综上所述,BM25 擅长精确匹配,因为它:

  • 对罕见的特定术语赋予更高权重
  • 忽略不增加搜索价值的常见词
  • 关注词频而非语义含义
  • 对技术术语、ID 和特定短语效果特别好

关键是两种搜索方法具有互补的优势:语义搜索理解上下文和含义,而词法搜索确保我们会遗漏精确的术语匹配。我们通过将此二者结合,创建了一个更健壮的搜索系统,能够有效处理概念查询和精确查找。下一步我们将学习如何合并来自两个搜索系统的结果,以创建统一的混合搜索体验。

  1. 多索引 RAG 流水线

我们已经分别实现了语义搜索(使用向量嵌入)和词法搜索(使用 BM25)的独立方案。现在需要将它们组合成一个统一的搜索流水线,以同时利用两种方法的优势。

多索引架构

我们的 VectorIndex 和 BM25Index 类共享几乎相同的 API——它们都有 add_document() 和 search() 方法。这种一致性便于让我们将二者封装为一个名为 Retriever 的新类中。

image.png

image.png

Retriever 充当协调器的角色,将用户查询转发到两个索引,收集它们的结果,然后使用一种称为倒数排名融合(Reciprocal Rank Fusion,RRF) 的技术合并结果。

倒数排名融合

合并来自不同搜索方法的结果并不是简单的拼接列表,每种方法使用不同的评分系统,因此我们需要一种方法来公平地标准化和组合它们的排名。

假设我们搜索关于“INC-2023-Q4-011”的信息,得到以下结果:

  • VectorIndex 返回:第 2 节(排名 1)、第 7 节(排名 2)、第 6 节(排名 3)
  • BM25Index 返回:第 6 节(排名 1)、第 2 节(排名 2)、第 7 节(排名 3)

image.png

image.png

我们将这些结果合并到一个表格中,显示每个文本块在两个索引中的排名,然后应用 RRF 公式:

其中 k 是一个常数(通常为 60,但为了描述更加清晰,我们使用 1),rank_i(d) 是文档 d 在第 i 个排名中的名次。

文本块语义搜索排名词法搜索排名分数最终排名
第二部分:软件工程…121.0 / (1 + 1) + 1.0 / (1 + 2) = 0.8331
第七部分:历史研究…231.0 / (1 + 2) + 1.0 / (1 + 3) = 0.5832
第六部分:产品工程…311.0 / (1 + 3) + 1.0 / (1 + 1) = 0.753

最终排名变为:第 2 节(0.833)、第 6 节(0.75)、第 7 节(0.583)。第 2 节在两个索引中都表现良好,因此排名第一,非常直观。

实现细节

首先 Retriever 既能接收 VectorIndex 也可以接收 BM25Index ,需要定义统一的接口规范:

from typing import Protocol

# 接口规范:必须要实现以下 3 个方法
class SearchIndex(Protocol):
    def add_document(self, document: Dict[str, Any]) -> None: ...

    # Added the 'add_documents' method to avoid rate limiting errors from VoyageAI
    def add_documents(self, documents: List[Dict[str, Any]]) -> None: ...

    def search(
        self, query: Any, k: int = 1
    ) -> List[Tuple[Dict[str, Any], float]]: ...

VectorIndex 和 BM25Index 都已实现了该规范,所以都可以被 Retriever 所接收:

from typing import Any, List, Dict, Tuple

class Retriever:
    def __init__(self, *indexes: SearchIndex):
        if len(indexes) == 0:
            raise ValueError("At least one index must be provided")
        self._indexes = list(indexes)

    def add_document(self, document: Dict[str, Any]):
        for index in self._indexes:
            index.add_document(document)

    def add_documents(self, documents: List[Dict[str, Any]]):
        for index in self._indexes:
            index.add_documents(documents)

    def search(
        self, query_text: str, k: int = 1, k_rrf: int = 60
    ) -> List[Tuple[Dict[str, Any], float]]:
        if not isinstance(query_text, str):
            raise TypeError("Query text must be a string.")
        if k <= 0:
            raise ValueError("k must be a positive integer.")
        if k_rrf < 0:
            raise ValueError("k_rrf must be non-negative.")

        all_results = [
            index.search(query_text, k=k * 5) for index in self._indexes
        ]

        doc_ranks = {}
        for idx, results in enumerate(all_results):
            for rank, (doc, _) in enumerate(results):
                doc_id = id(doc)  # 用对象内存地址作为文档唯一标识
                if doc_id not in doc_ranks:
                    doc_ranks[doc_id] = {
                        "doc_obj": doc,
                        "ranks": [float("inf")] * len(self._indexes),
                    }
                doc_ranks[doc_id]["ranks"][idx] = rank + 1  # 排名从 1 开始

        def calc_rrf_score(ranks: List[float]) -> float:
            return sum(1.0 / (k_rrf + r) for r in ranks if r != float("inf"))

        scored_docs: List[Tuple[Dict[str, Any], float]] = [
            (ranks["doc_obj"], calc_rrf_score(ranks["ranks"]))
            for ranks in doc_ranks.values()
        ]

        filtered_docs = [
            (doc, score) for doc, score in scored_docs if score > 0
        ]
        filtered_docs.sort(key=lambda x: x[1], reverse=True)

        return filtered_docs[:k]

其中,RRF 核心算法实现:

def calc_rrf_score(ranks: List[float]) -> float:
    return sum(1.0 / (k_rrf + r) for r in ranks if r != float("inf"))

测试混合搜索方法

调用 Retriever 的 search 方法,搜索“INC-2023-Q4-011 发生了什么?”,会返回相关的文本块和对应的 RRF 得分:

results = retriever.search("INC-2023-Q4-011 发生了什么?", 3)

for doc, distance in results:
    print(distance, "/n", doc["content"][:200], "/n----/n")

执行结果如下:

image.png

image.png

注意,这里返回的是 RRF 得分,得分越高相关度越高,与之前返回的余弦距离正好相反。

还记得之前的问题吗?搜索“INC-2023-Q4-011 发生了什么?”时,无论是单用语义搜索还是词法搜索,排名前几的答案总是有些差强人意,但是将二者结合之后得到的答案就准确多了。这证明了组合语义搜索和词法搜索可以克服单独使用任何一种方法的局限性。

这种混合搜索架构的最大优势在于其可扩展性。由于所有索引都实现了相同的 SearchIndex 协议(具有 add_documents() 和 search() 方法),你可以轻松添加新的搜索方法:

image.png

image.png

如果我们想添加基于关键词的索引、基于图的搜索或者其他专业领域的索引,只需实现相同的接口,Retriever 就会自动将其纳入融合过程。

这种模块化的方法使每个搜索实现保持专注和可测试,同时提供了一种清晰的方式来最终系统中组合它们的优势。

大模型的知识又多又杂,到底怎么学?

做这行久了,被问最多的问题就一个:大模型的知识又多又杂,我到底先学啥?翻了一堆资料,看啥都像重点,结果啥都没学进去。今天把我自己一路摸出来的顺序捋一遍,零基础、应届生、转行的都适用。跟着走,至少方向不乱、不瞎忙,学完拿得出手。

第一阶段(10天):初阶应用

这一阶段就一个目标:把大模型真正"玩明白",不是只会聊天那种,而是能调教它、能用代码把大模型和业务接起来。

  • 大模型 AI 能干什么?
  • 大模型是怎样获得「智能」的?
  • 用好 AI 的核心心法
  • 大模型应用业务架构
  • 大模型应用技术架构
  • 代码示例:向 GPT-3.5 灌入新知识
  • 提示工程的意义和核心思想
  • Prompt 典型构成
  • 指令调优方法论
  • 思维链和思维树
  • Prompt 攻击和防范
  • …
第二阶段(30天):高阶应用

这阶段正式进实战,目标很实在:做出两个能拿出手的东西——一个私有知识库,一个 agent 对话机器人。

  • 为什么要做 RAG

  • 搭建一个简单的 ChatPDF

  • 检索的基础概念

  • 什么是向量表示(Embeddings)

  • 向量数据库与向量检索

  • 基于向量检索的 RAG

  • 搭建 RAG 系统的扩展知识

  • 混合检索与 RAG-Fusion 简介

  • 向量模型本地部署

  • …

走完这 30 天,Python 或 JavaScript 背景的基本能独立做一套 RAG 应用,简历上也敢写"大模型应用开发"了。

第三阶段(约20天):动手微调

学到这,你已经开始 “造” 模型了 —— 通过微调,搞出垂直领域的专用小模型。

  • 为什么要做 RAG
  • 什么是模型
  • 什么是模型训练
  • 求解器 & 损失函数简介
  • 小实验2:手写一个简单的神经网络并训练它
  • 什么是训练/预训练/微调/轻量化微调
  • Transformer结构简介
  • 轻量化微调
  • 实验数据集的构建
  • …

恭喜你,如果学到这里,你基本可以找到一份大模型 AI相关的工作,自己也能训练 GPT 了!通过微调,训练自己的垂直大模型,能独立训练开源多模态大模型,掌握更多技术方案。

到此为止,大概2个月的时间。你从 “只会用” 变成 “能造” 的 AI 小子。那么你还想往下探索吗?

想要入局AI大模型赛道、抢占行业红利的朋友,微信扫描下方优快云官方认证二维码,即可100%免费领取全套学习资料!在这里插入图片描述

第四阶段(20天):商业闭环,部署落地

技术学完,落地才算数。这阶段带你搞明白大模型怎么真正跑起来、用起来。

  • 硬件选型
  • 带你了解全球大模型
  • 使用国产大模型服务
  • 搭建 OpenAI 代理
  • 热身:基于阿里云 PAI 部署 Stable Diffusion
  • 在本地计算机运行大模型
  • 大模型的私有化部署
  • 基于 vLLM 部署大模型
  • 案例:如何优雅地在阿里云私有部署开源大模型
  • 部署一套开源 LLM 项目
  • 内容安全
  • 互联网信息服务算法备案
  • …

想要入坑大模型的朋友,给大家准备了一份2026 年最新 AI 大模型全套学习资料:

1、大模型系统化学习路线

请添加图片描述

2、大模型学习视频教程

在这里插入图片描述

3、大模型学习书籍&电子文档

在这里插入图片描述

4、AI大模型最新行业报告

img

5、大模型项目实战&配套源码

img

6、2026大模型大厂面试真题

img

7、为什么要学习大模型?

我国在A大模型领域面临人才短缺,数量与质量均落后于发达国家。2023年,人才缺口已超百万,凸显培养不足。随着AI技术飞速发展,预计到2025年,这一缺口将急剧扩大至400万,严重制约我国AI产业的创新步伐。加强人才培养,优化教育体系,国际合作并进是破解困局、推动AI发展的关键。

在这里插入图片描述

在这里插入图片描述

8、适合谁学?
  • 传统后端 / Java / 前端开发,想转型 AI 应用
  • 大学生、应届生,想拿更好的 offer
  • 产品经理、运营,想武装职业竞争力
  • 技术负责人,想给团队落地提效

学习是反人性的,但回报是真金白银。技术会更新,赛道会切换,但只要你先动手,机会就永远站在你这边。

9、这些资料真的有用吗?

这份资料由我和鲁为民博士(北京清华大学学士和美国加州理工学院博士)共同整理,现任上海殷泊信息科技CEO,其创立的MoPaaS云平台获Forrester全球’强劲表现者’认证,服务航天科工、国家电网等1000+企业,以第一作者在IEEE Transactions发表论文50+篇,获NASA JPL火星探测系统强化学习专利等35项中美专利。本套AI大模型课程由清华大学-加州理工双料博士、吴文俊人工智能奖得主鲁为民教授领衔研发。

资料内容涵盖了从入门到进阶的各类视频教程和实战项目,无论你是小白还是有些技术基础的技术人员,这份资料都绝对能帮助你提升薪资待遇,转行大模型岗位。
在这里插入图片描述
在这里插入图片描述

想要入局AI大模型赛道、抢占行业红利的朋友,微信扫描下方优快云官方认证二维码,即可100%免费领取全套学习资料!在这里插入图片描述

好了,就写到这。路线是死的,人是活的,卡住了随时来问我。觉得有用就点个收藏,别让它进收藏夹吃灰——动起来比啥都强。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值