RAG 实战教程(一):RAG 工作原理与完整流程——分片、索引、召回、重排和生成

大模型真正落地到企业知识库、智能客服、文档问答、代码搜索等场景时,RAG 几乎是绕不开的一项技术。

这一篇先不急着上 LangChain、LlamaIndex 等框架,而是从底层把 RAG 的整个工作流程讲清楚:文档加载 → 分片 → Embedding → 索引 → 召回 → 重排 → Prompt 组装 → 大模型生成。

并且会用大量 Python 代码,把一个最小可运行的 RAG 系统一步一步实现出来。


在这里插入图片描述

@

目录

一、RAG 到底是什么?

RAG 全称:

Retrieval-Augmented Generation

中文通常翻译为:

检索增强生成

它的核心思想非常简单:

不要让大模型只依赖训练时学到的知识,而是在回答问题之前,先从外部知识库中检索相关资料,然后把资料连同问题一起交给大模型。

传统的大模型问答流程可能是:

用户问题
   ↓
大语言模型
   ↓
回答

而 RAG 的流程变成:

用户问题
   ↓
检索知识库
   ↓
找到相关文档
   ↓
把文档 + 用户问题交给大模型
   ↓
生成回答

例如用户问:

我们公司的退款规则是什么?

LLM 本身显然不可能知道你公司的内部规定。

RAG 会先从知识库中找到:

退款政策.md
售后服务规范.pdf
用户协议.docx

然后提取出类似:

用户购买服务后 7 日内,在未使用核心服务额度的情况下,
可以申请全额退款……

最终构造 Prompt:

请根据下面的企业知识回答用户问题。

已知资料:

用户购买服务后 7 日内,在未使用核心服务额度的情况下,
可以申请全额退款。

用户问题:

我们公司的退款规则是什么?

这样大模型就可以基于真实资料回答。


二、为什么需要 RAG?

很多人第一次接触 RAG 时会问:

我直接把所有文档塞给大模型不行吗?

理论上可以。

实际上很快就会遇到几个问题。

1. Context 有长度限制

假设你的知识库有:

10000 篇文章
5000 个 PDF
几十万页内部文档

不可能一次全部放进 Prompt。

即使模型支持:

128K
200K
1M Context

也不意味着应该全部塞进去。

因为 Context 越长:

  • Token 成本越高
  • 推理速度越慢
  • 关键信息越容易被淹没
  • 模型更容易受到无关信息干扰

所以正确做法应该是:

100 万条知识
      ↓
检索
      ↓
找到最相关的 5~20 条
      ↓
交给 LLM

这就是 Retrieval 的意义。


2. 大模型知识不是实时的

比如:

公司最新制度
产品最新价格
今天发布的公告
最新 API 文档
数据库实时数据

模型训练结束后并不会自动知道这些信息。

RAG 可以让大模型读取动态知识。

例如:

用户问题
   ↓
搜索最新 API 文档
   ↓
找到相关内容
   ↓
LLM 回答

这也是为什么 RAG 特别适合:

企业知识库
客服机器人
文档助手
产品手册助手
内部 Wiki
代码问答
论文问答
法律文档检索

三、RAG 的完整架构

一个完整 RAG 系统一般可以拆成两个阶段。

第一阶段:Indexing

也就是知识库建设阶段。

原始文档
   ↓
文档解析
   ↓
文本清洗
   ↓
Chunk 分片
   ↓
Embedding
   ↓
写入向量数据库

第二阶段:Retrieval + Generation

也就是用户真正提问时:

用户问题
   ↓
Query Embedding
   ↓
向量检索
   ↓
召回 Top K
   ↓
Rerank 重排
   ↓
选择最相关上下文
   ↓
构造 Prompt
   ↓
LLM
   ↓
最终答案

合起来就是:

                  ┌──────────────┐
                  │ 原始知识库    │
                  └──────┬───────┘
                         ↓
                    文档解析
                         ↓
                    Chunk 分片
                         ↓
                    Embedding
                         ↓
                  ┌──────────────┐
                  │ Vector Store │
                  └──────┬───────┘
                         ↑
                         │ Search
                         │
用户问题 → Embedding → Retrieval
                         ↓
                    Top K 文档
                         ↓
                      Rerank
                         ↓
                    Top N 文档
                         ↓
                 Prompt Construction
                         ↓
                        LLM
                         ↓
                      最终回答

下面我们把每一步拆开。


四、第一步:文档加载

假设我们有一个文件:

knowledge.txt

内容:

RAG 是 Retrieval-Augmented Generation 的缩写。

RAG 的主要流程包括文档分片、Embedding、向量索引、
检索、重排和大模型生成。

向量数据库通常用来保存 Embedding 向量,
并提供相似度检索能力。

常见向量数据库包括 Milvus、Qdrant、Weaviate、
Pinecone、Chroma 和 FAISS。

Python 加载:

def load_text(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


text = load_text("knowledge.txt")

print(text)

真实项目中还可能处理:

PDF
Word
Markdown
HTML
Excel
PPT
网页
数据库
GitHub 仓库
Notion
Confluence

但无论输入是什么,第一步通常都是:

各种文件
   ↓
统一转换成文本

五、第二步:Chunk 文本分片

Chunk 是 RAG 中非常重要的一个概念。

因为通常不会把整篇文档作为一个向量,而是把文档切成很多小块。

例如原始文章:

一篇 10000 字文章

可能被拆成:

Chunk 1:500 字
Chunk 2:500 字
Chunk 3:500 字
...
Chunk 20:500 字

为什么?

假设用户问:

RAG 中为什么要进行重排?

如果整篇 10000 字作为一个向量:

整个文档只有一个 Embedding

粒度太粗。

如果切成 500 字:

Chunk 7:介绍召回
Chunk 8:介绍重排
Chunk 9:介绍 Prompt

系统就可以直接找到:

Chunk 8

六、最简单的固定长度分片

先自己实现一个简单版本。

def chunk_text(
    text: str,
    chunk_size: int = 500,
    overlap: int = 50
):
    chunks = []

    start = 0

    while start < len(text):
        end = start + chunk_size

        chunk = text[start:end]

        chunks.append(chunk)

        start += chunk_size - overlap

    return chunks

调用:

text = """
RAG 是 Retrieval-Augmented Generation 的缩写。
RAG 会在大模型生成回答之前检索外部知识库。
向量数据库用于保存文档 Embedding。
重排模型则用于重新计算文档和 Query 的相关性。
"""

chunks = chunk_text(
    text,
    chunk_size=50,
    overlap=10
)

for i, chunk in enumerate(chunks):
    print(f"Chunk {i}")
    print(chunk)
    print("-" * 50)

这里有两个关键参数:

chunk_size = 500
overlap = 50

七、为什么 Chunk 要有 Overlap?

假设一句话刚好位于两个 Chunk 之间:

Chunk 1:

RAG 系统首先对用户问题进行向量化,
然后从向量数据库中检索相关文档。为了提高

Chunk 2:

最终检索质量,系统通常还会加入 Reranker。

语义被硬生生切断。

所以经常会设置:

Chunk Size = 500
Overlap = 50

变成:

Chunk 1:

然后从向量数据库中检索相关文档。
为了提高最终检索质量,系统通常还会加入 Reranker。

Chunk 2:

为了提高最终检索质量,系统通常还会加入 Reranker。
Reranker 会进一步判断 Query 和文档之间的相关性。

这样语义连续性更好。


八、更合理的段落分片

实际项目中不要只按字符暴力切割。

可以优先按:

标题
段落
句子
Token
Markdown 层级

分割。

例如:

def split_by_paragraph(text: str):
    paragraphs = text.split("\n\n")

    return [
        p.strip()
        for p in paragraphs
        if p.strip()
    ]

进一步组合:

def semantic_chunk(
    text: str,
    max_size: int = 500
):
    paragraphs = split_by_paragraph(text)

    chunks = []

    current = ""

    for paragraph in paragraphs:

        if len(current) + len(paragraph) <= max_size:

            current += paragraph + "\n\n"

        else:

            if current:
                chunks.append(current.strip())

            current = paragraph + "\n\n"

    if current:
        chunks.append(current.strip())

    return chunks

这种方式通常比纯字符切割更自然。


九、Chunk 应该多大?

这是 RAG 最经典的问题之一。

没有绝对答案。

常见范围:

200 Token
300 Token
500 Token
800 Token
1000 Token

通常可以先从:

300~800 Token

开始测试。

如果知识本身很短:

FAQ
API 参数说明
商品介绍

可以使用较小 Chunk。

例如:

100~300 Token

如果知识本身需要完整上下文:

法律合同
论文
技术原理
复杂业务规则

Chunk 可以适当更大:

500~1500 Token

关键不是数字本身,而是:

一个 Chunk 最好能够表达一个相对完整的语义单位。


十、第三步:Embedding

分片完成之后,就需要把文字转换成向量。

例如:

RAG 使用向量数据库检索文档

经过 Embedding 模型后,可能变成:

[
    0.124,
    -0.083,
    0.992,
    0.231,
    ...
]

假设这是一个:

1536 维

向量。

于是:

文本
 ↓
Embedding Model
 ↓
Vector

在这里插入图片描述

十一、为什么文本可以变成向量?

Embedding 模型会把语义相近的文字映射到向量空间中相近的位置。

例如:

如何修改密码?

和:

忘记密码后怎么重置?

虽然文字不同,但语义非常接近。

Embedding 后:

Vector A
Vector B

在向量空间中的距离通常也会比较近。

而:

北京今天天气怎么样?

对应的向量就会离它们比较远。

因此我们可以通过:

向量距离

判断:

两个文本语义是否相似

十二、调用 Embedding API

使用 OpenAI SDK 风格接口:

pip install openai

Python:

from openai import OpenAI

client = OpenAI(
    api_key="YOUR_API_KEY"
)

response = client.embeddings.create(
    model="text-embedding-3-small",
    input="RAG 使用向量检索找到相关知识"
)

vector = response.data[0].embedding

print(len(vector))
print(vector[:10])

批量生成:

texts = [
    "RAG 使用向量数据库保存知识",
    "Embedding 可以把文本转换成向量",
    "Reranker 用于重新计算相关性",
]

response = client.embeddings.create(
    model="text-embedding-3-small",
    input=texts
)

vectors = [
    item.embedding
    for item in response.data
]

print(len(vectors))

生产环境中最好批量处理。

不要:

for chunk in chunks:
    embedding(chunk)

每个 Chunk 请求一次接口。

而应该:

batch_size = 100

for i in range(0, len(chunks), batch_size):
    batch = chunks[i:i + batch_size]

    embeddings = create_embeddings(batch)

这样效率高很多。


十三、封装 Embedding 函数

from openai import OpenAI


client = OpenAI(
    api_key="YOUR_API_KEY"
)


def embedding(texts):
    if isinstance(texts, str):
        texts = [texts]

    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=texts,
    )

    return [
        item.embedding
        for item in response.data
    ]

使用:

vectors = embedding([
    "什么是 RAG?",
    "什么是 Embedding?"
])

print(len(vectors))

十四、第四步:向量索引

现在我们已经有:

Chunk 1 → Vector 1
Chunk 2 → Vector 2
Chunk 3 → Vector 3

需要把它们保存下来。

一般数据结构至少包含:

{
  "id": "chunk-001",
  "content": "RAG 是一种检索增强生成技术",
  "embedding": [0.1, 0.2, 0.3],
  "metadata": {
    "source": "rag.md",
    "page": 1
  }
}

生产环境一般会使用:

FAISS
Milvus
Qdrant
Weaviate
Pinecone
Chroma
Elasticsearch
PostgreSQL + pgvector

十五、先不用向量数据库,自己实现一个

为了真正理解原理,我们先用 Python List。

documents = []

for i, chunk in enumerate(chunks):

    vector = embedding(chunk)[0]

    documents.append({
        "id": i,
        "content": chunk,
        "embedding": vector,
    })

现在内存中就是:

[
    {
        "id": 0,
        "content": "...",
        "embedding": [...]
    },
    {
        "id": 1,
        "content": "...",
        "embedding": [...]
    }
]

实际上这已经是最原始的:

Vector Store

了。


十六、第五步:计算相似度

用户现在问:

RAG 为什么需要重排?

先把 Query 变成向量:

query = "RAG 为什么需要重排?"

query_vector = embedding(query)[0]

然后计算:

Query Vector

与所有:

Document Vector

之间的相似度。

最常见的是:

Cosine Similarity

余弦相似度。

公式:

cos(A, B) = A · B / (||A|| × ||B||)

十七、Python 实现余弦相似度

import numpy as np


def cosine_similarity(a, b):

    a = np.array(a)
    b = np.array(b)

    return np.dot(a, b) / (
        np.linalg.norm(a)
        * np.linalg.norm(b)
    )

测试:

a = [1, 0, 0]
b = [0.9, 0.1, 0]

score = cosine_similarity(a, b)

print(score)

越接近:

1

通常说明越相似。


十八、完整向量召回

def retrieve(query, documents, top_k=5):

    query_vector = embedding(query)[0]

    results = []

    for doc in documents:

        score = cosine_similarity(
            query_vector,
            doc["embedding"]
        )

        results.append({
            "content": doc["content"],
            "score": score
        })

    results.sort(
        key=lambda x: x["score"],
        reverse=True
    )

    return results[:top_k]

调用:

results = retrieve(
    query="RAG 为什么需要 Reranker?",
    documents=documents,
    top_k=5
)

for item in results:

    print(item["score"])
    print(item["content"])
    print("=" * 80)

这就是最基础的:

Vector Search

整个过程:

Query
 ↓
Query Embedding
 ↓
与所有 Document Embedding 计算距离
 ↓
排序
 ↓
Top K

十九、什么叫召回?

Retrieval 中文通常叫:

检索

或者:

召回

假设整个知识库有:

100000 个 Chunk

第一阶段从中找出:

20 个可能相关 Chunk

这 20 个就是:

Recall Results

也就是召回结果。

此时目标通常不是:

一定把最好的排第一。

而是:

尽量不要漏掉真正相关的内容。

因此第一阶段通常可以:

Top 10
Top 20
Top 50

多取一点。

之后再使用 Reranker。


在这里插入图片描述

二十、为什么只做向量检索还不够?

Embedding 非常强,但并不是完美的。

例如用户问:

GPT-5 API 价格是多少?

可能召回:

GPT API 使用教程
GPT-4 API 定价
GPT-5 模型介绍
API Token 计算规则
GPT-5 API 价格说明

这些内容在向量空间中都很接近。

但真正最需要的显然是:

GPT-5 API 价格说明

所以需要第二层排序。

也就是:

Rerank

二十一、第六步:Reranker 重排

完整流程变成:

Query
 ↓
Vector Search
 ↓
召回 Top 20
 ↓
Reranker
 ↓
重新排序
 ↓
Top 5

Embedding 检索通常是:

Bi-Encoder

Query 和 Document 分别编码:

Query → Vector
Document → Vector

计算向量距离。

优点:

而 Reranker 通常直接同时判断:

Query + Document

例如:

Query:
RAG 中为什么需要重排?

Document:
重排模型会进一步判断初步召回结果和用户问题之间的相关性……

Reranker 输出:

0.97

另一个:

Query:
RAG 中为什么需要重排?

Document:
Embedding 是将文本转换为高维向量的技术……

可能只有:

0.35

所以重新排序。


二十二、一个最简单的 LLM Rerank

甚至可以让大模型自己打分。

例如:

def rerank_prompt(query, document):

    return f"""
请判断下面的文档是否能够回答用户问题。

用户问题:

{query}

候选文档:

{document}

请返回 0~10 的相关性评分。

只返回数字。
"""

然后:

from openai import OpenAI

client = OpenAI()


def rerank_with_llm(query, docs):

    results = []

    for doc in docs:

        prompt = rerank_prompt(
            query,
            doc["content"]
        )

        response = client.chat.completions.create(
            model="gpt-5",
            messages=[
                {
                    "role": "user",
                    "content": prompt
                }
            ]
        )

        score = float(
            response.choices[0]
            .message.content.strip()
        )

        results.append({
            **doc,
            "rerank_score": score
        })

    results.sort(
        key=lambda x: x["rerank_score"],
        reverse=True
    )

    return results

当然,这种方式:

成本高
速度慢

生产环境更常见的是专门的:

Cross Encoder
Reranker Model

二十三、为什么 Retrieval Top K 和最终 Context Top K 不一样?

一个经典配置是:

Retrieve Top 20
Rerank Top 5

流程:

100000 个 Chunk
      ↓
向量数据库
      ↓
20 个候选
      ↓
Reranker
      ↓
5 个最相关
      ↓
LLM

为什么不是直接向量检索 Top 5?

因为:

Vector Search

的排序可能不够精准。

先取 20 个,是为了保证:

Recall

然后让更精确但更慢的 Reranker 从 20 个里面挑 5 个。

这是一种非常经典的:

粗排 + 精排

架构。


二十四、第七步:组装 Context

假设 Rerank 后得到:

results = [
    {
        "content": "RAG 首先通过向量检索得到候选知识..."
    },
    {
        "content": "Reranker 会进一步计算 Query 和文档..."
    },
    {
        "content": "最终只将最相关内容传递给大模型..."
    }
]

可以组装:

def build_context(results):

    contents = []

    for i, item in enumerate(results):

        contents.append(
            f"[资料 {i + 1}]\n"
            f"{item['content']}"
        )

    return "\n\n".join(contents)

最终得到:

[资料 1]

RAG 首先通过向量检索得到候选知识……

[资料 2]

Reranker 会进一步计算 Query 和文档……

[资料 3]

最终只将最相关内容传递给大模型……

二十五、第八步:构造 Prompt

一个基础 RAG Prompt:

def build_prompt(query, context):

    return f"""
你是一个知识库问答助手。

请严格根据提供的资料回答用户问题。

如果资料中没有答案,请直接回答:

“根据当前知识库无法确定。”

不要编造不存在的信息。

======== 知识库资料 ========

{context}

======== 用户问题 ========

{query}

======== 回答 ========
"""

这里很重要的一句话:

如果资料中没有答案,不要编造。

因为 RAG 并不能彻底解决幻觉。

需要通过 Prompt 对生成行为进行约束。


二十六、第九步:调用 LLM 生成答案

from openai import OpenAI

client = OpenAI(
    api_key="YOUR_API_KEY"
)


def generate_answer(query, context):

    prompt = build_prompt(
        query,
        context
    )

    response = client.chat.completions.create(
        model="gpt-5",
        messages=[
            {
                "role": "user",
                "content": prompt
            }
        ]
    )

    return (
        response
        .choices[0]
        .message.content
    )

最终:

query = "RAG 为什么需要重排?"

retrieval_results = retrieve(
    query,
    documents,
    top_k=20
)

rerank_results = rerank_with_llm(
    query,
    retrieval_results
)[:5]

context = build_context(
    rerank_results
)

answer = generate_answer(
    query,
    context
)

print(answer)

至此,一个最基础的 RAG 就已经跑起来了。


二十七、把整个 RAG 串起来

我们可以进一步封装:

class SimpleRAG:

    def __init__(self):
        self.documents = []

    def add_documents(self, texts):

        vectors = embedding(texts)

        for i, (text, vector) in enumerate(
            zip(texts, vectors)
        ):
            self.documents.append({
                "id": len(self.documents),
                "content": text,
                "embedding": vector
            })

    def search(self, query, top_k=10):

        query_vector = embedding(query)[0]

        results = []

        for doc in self.documents:

            score = cosine_similarity(
                query_vector,
                doc["embedding"]
            )

            results.append({
                "content": doc["content"],
                "score": score
            })

        results.sort(
            key=lambda x: x["score"],
            reverse=True
        )

        return results[:top_k]

    def ask(self, query):

        docs = self.search(
            query,
            top_k=10
        )

        context = build_context(
            docs[:5]
        )

        return generate_answer(
            query,
            context
        )

使用:

rag = SimpleRAG()

rag.add_documents([
    "RAG 是 Retrieval-Augmented Generation 的缩写。",
    "Embedding 可以将文本映射到高维向量空间。",
    "向量数据库用于存储 Embedding,并执行相似度搜索。",
    "Reranker 可以对初次召回结果进行更加精确的排序。",
    "RAG 最终会将检索到的知识和用户问题一起交给大模型。",
])

answer = rag.ask(
    "Reranker 在 RAG 中有什么作用?"
)

print(answer)

二十八、一个完整的最小 RAG Demo

下面直接给出一个相对完整的版本。

import numpy as np
from openai import OpenAI


client = OpenAI(
    api_key="YOUR_API_KEY"
)


# =========================
# Embedding
# =========================

def create_embeddings(texts):

    if isinstance(texts, str):
        texts = [texts]

    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=texts
    )

    return [
        item.embedding
        for item in response.data
    ]


# =========================
# Cosine Similarity
# =========================

def cosine_similarity(a, b):

    a = np.array(a)
    b = np.array(b)

    return np.dot(a, b) / (
        np.linalg.norm(a)
        * np.linalg.norm(b)
    )


# =========================
# Chunk
# =========================

def chunk_text(
    text,
    chunk_size=500,
    overlap=50
):

    chunks = []

    start = 0

    while start < len(text):

        end = start + chunk_size

        chunk = text[start:end]

        chunks.append(chunk)

        start += chunk_size - overlap

    return chunks


# =========================
# Vector Store
# =========================

class VectorStore:

    def __init__(self):

        self.documents = []

    def add(self, texts):

        vectors = create_embeddings(texts)

        for text, vector in zip(
            texts,
            vectors
        ):

            self.documents.append({
                "content": text,
                "embedding": vector
            })

    def search(
        self,
        query,
        top_k=10
    ):

        query_vector = (
            create_embeddings(query)[0]
        )

        results = []

        for doc in self.documents:

            score = cosine_similarity(
                query_vector,
                doc["embedding"]
            )

            results.append({
                "content": doc["content"],
                "score": score
            })

        results.sort(
            key=lambda item: item["score"],
            reverse=True
        )

        return results[:top_k]


# =========================
# Context
# =========================

def build_context(results):

    return "\n\n".join([
        f"[资料 {i + 1}]\n"
        f"{item['content']}"
        for i, item in enumerate(results)
    ])


# =========================
# Generation
# =========================

def generate(query, context):

    prompt = f"""
你是一个专业的知识库问答助手。

请严格根据下面提供的知识库资料回答问题。

要求:

1. 不允许编造资料中不存在的信息。
2. 如果知识库中没有答案,请明确说明。
3. 优先使用知识库中的事实。
4. 回答尽量清晰、准确。

知识库:

{context}

用户问题:

{query}
"""

    response = client.chat.completions.create(
        model="gpt-5",
        messages=[
            {
                "role": "user",
                "content": prompt
            }
        ]
    )

    return (
        response
        .choices[0]
        .message.content
    )


# =========================
# Main
# =========================

if __name__ == "__main__":

    with open(
        "knowledge.txt",
        "r",
        encoding="utf-8"
    ) as f:
        text = f.read()

    chunks = chunk_text(
        text,
        chunk_size=500,
        overlap=50
    )

    print(
        f"Chunk 数量: {len(chunks)}"
    )

    vector_store = VectorStore()

    vector_store.add(chunks)

    query = input(
        "请输入你的问题:"
    )

    results = vector_store.search(
        query,
        top_k=5
    )

    print("\n召回结果:")

    for result in results:

        print(
            f"{result['score']:.4f}"
        )

        print(
            result["content"][:200]
        )

        print("-" * 50)

    context = build_context(
        results
    )

    answer = generate(
        query,
        context
    )

    print("\n最终回答:")

    print(answer)

安装依赖:

pip install openai numpy

准备:

knowledge.txt

然后:

python rag.py

输入:

RAG 为什么要进行重排?

就可以完成一次完整的:

知识库 → Chunk → Embedding → Search → Context → LLM

流程。


二十九、生产级 RAG 和这个 Demo 差在哪里?

我们上面的系统虽然可以运行,但是距离生产系统还有很大差距。

生产级架构通常是:

                     ┌─────────────────┐
                     │ Document Loader │
                     └────────┬────────┘
                              ↓
                       Parser / OCR
                              ↓
                       Text Cleaning
                              ↓
                         Chunking
                              ↓
                        Embedding
                              ↓
                    ┌─────────────────┐
                    │ Vector Database │
                    └─────────────────┘


用户 Query
    ↓
Query Rewrite
    ↓
Embedding
    ↓
Hybrid Search
    ├── Vector Search
    └── BM25 Search
           ↓
        Fusion
           ↓
      Top 20~100
           ↓
       Reranker
           ↓
         Top 5
           ↓
Context Compression
           ↓
Prompt Construction
           ↓
           LLM
           ↓
       Citation
           ↓
       Final Answer

所以真正做好一个 RAG,需要优化很多环节。


三十、向量检索并不是唯一的检索方式

很多入门教程会让人产生一个误解:

RAG = Vector Database

其实并不是。

RAG 的核心是:

Retrieval

至于怎么检索,可以有很多方案。

例如:

Vector Search
BM25
关键词检索
全文搜索
SQL
Graph Search
Web Search
API Search

甚至:

SELECT *
FROM products
WHERE price < 1000

也可以属于 RAG 系统的数据获取过程。


三十一、Hybrid Search

现在很多生产 RAG 会使用:

Hybrid Search

也就是:

Vector Search
      +
BM25 Search

原因是向量搜索擅长:

语义

BM25 擅长:

关键词

例如用户搜索:

ERR_CONNECTION_RESET

对于这种精确错误码:

BM25

通常会比 Embedding 更靠谱。

而:

浏览器访问网页时连接突然断开是什么原因?

这种自然语言查询:

Vector Search

通常更有优势。

所以:

Hybrid Search

会同时召回两路结果。


三十二、一个简单 Hybrid Search 思路

假设:

vector_results = vector_search(query)
bm25_results = bm25_search(query)

可以做简单的加权:

final_score = (
    0.7 * vector_score
    + 0.3 * bm25_score
)

比如:

def hybrid_score(
    vector_score,
    bm25_score,
    alpha=0.7
):

    return (
        alpha * vector_score
        + (1 - alpha) * bm25_score
    )

但真实系统经常使用:

RRF

也就是:

Reciprocal Rank Fusion

三十三、RRF 是什么?

公式:

score(d) =
Σ 1 / (k + rank(d))

例如:

Vector Search:

A
B
C

BM25:

B
D
A

A 和 B 在两种检索器里面都有不错的排名。

最终 RRF 会自然提高它们的分数。

Python:

def reciprocal_rank_fusion(
    result_lists,
    k=60
):

    scores = {}

    for results in result_lists:

        for rank, doc_id in enumerate(
            results,
            start=1
        ):

            score = 1 / (
                k + rank
            )

            scores[doc_id] = (
                scores.get(doc_id, 0)
                + score
            )

    return sorted(
        scores.items(),
        key=lambda x: x[1],
        reverse=True
    )

三十四、RAG 最容易出问题的地方,其实不是 LLM

很多人做 RAG,一发现回答不好,就开始换:

GPT
Claude
Gemini
DeepSeek
Qwen

但很多情况下问题根本不在模型。

而是在:

Retrieval

举个例子。

用户问:

公司员工差旅住宿标准是多少?

正确资料是:

一线城市住宿标准最高 600 元/晚。

但系统检索出来的是:

出租车报销标准
飞机票报销规则
餐饮补贴规则

那么哪怕后面换成再强的模型,也不可能凭空得到:

600 元

所以 RAG 调优时要先判断:

是 Retrieval 错了?

还是 Generation 错了?

这两个是完全不同的问题。


三十五、如何判断是哪一步出了问题?

可以把整个流程打印出来。

query = "公司差旅住宿标准是多少?"

results = vector_store.search(
    query,
    top_k=10
)

for result in results:

    print(result["score"])

    print(result["content"])

    print("=" * 100)

如果正确答案根本没有进入:

Top 10

说明问题在:

Retrieval

可能要优化:

Chunk
Embedding
Hybrid Search
Query Rewrite
Metadata Filtering

如果正确答案已经在:

Top 3

但是模型还是回答错:

Generation

才需要检查:

Prompt
Context
LLM

三十六、Metadata 非常重要

真实文档不能只保存:

content
embedding

一般还应该保存:

{
  "id": "doc-001-chunk-003",
  "content": "...",
  "source": "员工手册.pdf",
  "page": 16,
  "title": "差旅管理制度",
  "department": "财务部",
  "created_at": "2026-08-01",
  "version": "v3",
  "embedding": []
}

这样以后可以做:

Metadata Filter

例如:

只搜索财务部文档

或者:

只搜索 2026 年以后发布的资料

甚至:

只允许用户访问自己部门的文档

这是企业 RAG 中非常重要的一部分。


三十七、一个更完整的数据结构

可以定义:

from dataclasses import dataclass
from typing import List, Optional


@dataclass
class Chunk:

    id: str

    content: str

    source: str

    page: Optional[int]

    title: Optional[str]

    embedding: Optional[List[float]]

例如:

chunk = Chunk(
    id="handbook-16-01",
    content="一线城市住宿标准最高为 600 元/晚。",
    source="员工手册.pdf",
    page=16,
    title="差旅管理制度",
    embedding=None
)

三十八、给答案附带引用

RAG 最好的实践之一是:

Answer + Citation

例如:

根据公司的差旅管理制度,一线城市住宿标准最高为
600 元/晚。

来源:《员工手册》P16

因此 Context 里最好把来源一起交给模型。

def build_context(results):

    blocks = []

    for i, item in enumerate(results):

        source = item.get(
            "source",
            "unknown"
        )

        page = item.get(
            "page",
            "unknown"
        )

        block = f"""
[资料 {i + 1}]

来源:
{source}

页码:
{page}

内容:
{item["content"]}
"""

        blocks.append(block)

    return "\n\n".join(blocks)

然后 Prompt:

回答时请注明引用的是哪一个资料编号。

模型就可以生成:

根据资料 [1],一线城市住宿费用最高为 600 元/晚。

三十九、RAG 的几个核心评价指标

如果以后真正做 RAG 系统,不能只靠:

“感觉回答还不错”

进行评估。

至少要关注几个指标。

Recall@K

例如:

Recall@10

意思是:

正确答案所在的文档有没有被召回到前 10 个结果里。


Precision

召回的结果里有多少是真的相关。

比如:

Top 10

里面:

3 个相关
7 个垃圾

Precision 就比较差。


MRR

Mean Reciprocal Rank。

关注:

第一个正确结果出现在哪里

例如正确文档排名:

第 1 位

显然比:

第 10 位

好。


在这里插入图片描述

四十、RAG 的核心其实是信息压缩

如果从更抽象的角度理解 RAG:

整个系统实际上是在做:

海量知识
   ↓
检索
   ↓
少量候选知识
   ↓
重排
   ↓
更少、更准确的知识
   ↓
LLM

例如:

100 GB 文档
     ↓
1000000 Chunk
     ↓
Vector Search
     ↓
Top 50
     ↓
Reranker
     ↓
Top 5
     ↓
大约 3000 Token
     ↓
LLM

所以 RAG 本质上可以理解为:

从一个巨大的知识空间中,压缩出和当前问题最相关的少量信息,再交给大模型进行推理和生成。


四十一、RAG 的几个关键调优点

一个 RAG 最终效果好不好,主要取决于:

Document Parsing
       ↓
Chunk Strategy
       ↓
Embedding Model
       ↓
Retrieval
       ↓
Hybrid Search
       ↓
Reranker
       ↓
Context Construction
       ↓
Prompt
       ↓
LLM

任何一个环节出问题,都可能影响最终结果。

可以总结成:

Garbage In
   ↓
Garbage Retrieval
   ↓
Garbage Context
   ↓
Garbage Answer

所以不能只盯着最后的大模型。


四十二、完整流程总结

到这里,RAG 的整个核心流程其实已经非常清楚。

1. 文档加载

PDF
Markdown
Word
HTML
数据库

转换成文本。

2. Chunk

Document
   ↓
Chunk 1
Chunk 2
Chunk 3

3. Embedding

Chunk
 ↓
Embedding Model
 ↓
Vector

4. Index

Vector
 ↓
Vector Database

5. Retrieval

Query
 ↓
Embedding
 ↓
Vector Search
 ↓
Top K

6. Rerank

Top K
 ↓
Reranker
 ↓
Top N

7. Context

Top N Document
 ↓
Prompt Context

8. Generation

Context
+
Question
 ↓
LLM
 ↓
Answer

最终完整链路就是:

Document
   ↓
Chunk
   ↓
Embedding
   ↓
Vector Store
   ↓
─────────────────────────
   ↓
User Query
   ↓
Query Embedding
   ↓
Retrieval
   ↓
Top K
   ↓
Reranker
   ↓
Top N
   ↓
Context
   ↓
Prompt
   ↓
LLM
   ↓
Answer

四十三、最后

很多人第一次学习 RAG,会急着安装:

LangChain
LlamaIndex
Dify
FastGPT
RAGFlow

然后几行代码把知识库跑起来。

这当然没有问题。

但如果以后遇到:

为什么搜不到?

为什么搜出来不相关?

为什么知识明明存在,模型还是回答错?

为什么 Chunk 越切效果反而越差?

为什么换了更强的 LLM 还是没效果?

为什么需要 BM25?

为什么需要 Reranker?

最终还是需要回到底层。

真正理解下面这一条链路:

分片
 ↓
Embedding
 ↓
索引
 ↓
召回
 ↓
重排
 ↓
Context
 ↓
生成

基本就理解了 RAG 的骨架。

而实际生产级 RAG,就是不断围绕这几个步骤进行工程优化。

下一篇,我们可以正式开始搭建一个真正可使用的 RAG 项目:

Python
+
Embedding
+
Qdrant / Milvus
+
Reranker
+
大语言模型

把本文中的内存版 Vector Store 替换成真正的向量数据库,并实现一个可以持续导入 Markdown、PDF 文档的知识库问答系统。


RAG 实战教程系列

第一篇:RAG 工作原理与完整流程
第二篇:Embedding 与向量数据库实战
第三篇:使用 Qdrant / Milvus 搭建知识库
第四篇:Hybrid Search 与 BM25
第五篇:Reranker 重排模型实战
第六篇:Query Rewrite 与 Multi-Query
第七篇:PDF / Markdown 企业知识库实战
第八篇:从 Demo 到生产级 RAG 系统

posted @ 2026-08-23 10:35  JavaPub  阅读(26)  评论(0)    收藏  举报