RAG 检索增强生成:手把手教你给大模型装上“外挂大脑”

0 阅读20分钟

还在为大模型“胡言乱语”头疼?不用微调,不用烧显卡,一个被无数大厂验证过的技术方案——RAG,就能让大模型瞬间学会你公司的私有知识。今天,我们用最通俗的语言,把这项技术的每一个细节都掰开揉碎讲清楚。

01 大模型的两个“致命伤”

先问你一个问题:如果让你在完全不查资料的情况下,回答“2024年诺贝尔物理学奖颁给了谁?”你能答上来吗?

大概率不能——因为你的“知识”停留在几年前,这就是知识滞后

大模型也一样。它虽然在海量数据上训练了很久,但训练一次动辄几个月,花费几千万甚至上亿美金。训练完成之后,它的知识就“定格”了。你问它2024年发生的事情,它要么说不知道,要么就按照以前的规律瞎编。

另一个问题是知识缺失。你家公司内部的规章制度、产品文档、客户案例,大模型根本没见过。你问它“我们公司年假怎么休”,它只能跟你道歉说“我无法回答”。

最要命的是第三个问题:幻觉

幻觉就是大模型“一本正经地胡说八道”。比如你问“哈利波特第一次骑自行车是什么时候?”它可能很自信地回答“1997年,在女贞路4号。”——听起来像模像样,但全是编的。书里根本没这回事。

为什么会这样?因为大模型本质是一个“文字接龙高手”,它不知道对错,只知道“根据前面的文字,下一个字最可能是什么”。这就好比一个学霸,虽然能背很多书,但如果你问他书本上没有的内容,他就会开始“自由发挥”。

有一个真实案例:某律师用大模型准备庭审材料,大模型“引用”了好几个判例,还附上了详细的案号、法官名字。结果对方律师一查——这些判例全是编的!这位律师因此被罚款。这不是段子,是真事。

那么,有没有办法让大模型既能发挥它的语言天赋,又能保证回答有据可查?

有。这个办法就是 RAG。

02 RAG 是什么?开卷考试了解一下

RAG 的全称是 Retrieval-Augmented Generation,中文叫“检索增强生成”。名字很唬人,但本质就四个字:开卷考试

想一想,开卷考试和闭卷考试有什么区别?

  • 闭卷考试:全靠记忆。大模型现在就是闭卷模式。

  • 开卷考试:允许你翻书查资料。RAG 就是给大模型配一本“参考书”。

具体流程是这样:

  1. 你问一个问题:“我们公司年假怎么休?”

  2. RAG 系统先去你公司的“知识库”(比如员工手册、公司制度文档)里,把相关的内容搜出来。

  3. 然后把搜到的内容,连同你的问题,一起交给大模型。

  4. 大模型看着这些“参考资料”来组织答案。

这样一来,大模型的回答就有了事实依据,不再是空口白话。而且你随时可以更新知识库里的文档,大模型的知识也就跟着更新了,不需要重新训练。

RAG 的另一个优点是保护隐私。你的私有数据不需要喂给大模型训练,只用在检索那一刻临时调用。数据始终在你自己的数据库里。

典型的RAG有两个主要流程:

索引:从数据源提取数据,构建索引。

检索生成:接受用户查询并从索引中检索相关数据,然后将其传递给模型。。

索引阶段:

  1. 从各种数据源加载数据

  2. 将文档切分为小块

  3. 对文本块进行嵌入

  4. 存储嵌入向量。

检索生成阶段:

  1. 根据用户输入,使用检索器从存储中检索相关文本块

  2. 大模型使用包含问题和检索结果的提示生成回答。

接下来,我们就按这个流程,用代码一步步搭建一个 RAG 系统。代码注释会非常详细,哪怕你第一次接触也能看懂。

03 第一步:加载文档——让程序“读”懂你的文件

你的知识可能散落在各种格式的文件里:Word 文档、PDF、Excel、网页、Markdown……第一步要做的,就是把它们统统读进来,变成程序能处理的数据。

LangChain 是目前最流行的 RAG 框架,它提供了非常多的“文档加载器”。你可以把它想象成各种格式的“文件打开器”。

3.1 加载纯文本文件(TXT)

# 安装 LangChain 社区扩展包
# 在命令行执行:pip install langchain_community
 
from langchain_community.document_loaders import TextLoader
 
# 创建一个加载器,告诉它文件在哪,用什么编码(中文用 utf-8)
loader = TextLoader(
    file_path="公司员工手册.txt",   # 你的文件路径
    encoding="utf-8"               # 中文编码
)
 
# 执行加载,得到文档对象列表
# 每个文档对象包含两部分:page_content(文字内容)和 metadata(元数据,比如文件名)
documents = loader.load()
 
# 打印看看结果
print(f"加载了 {len(documents)} 个文档")
print(f"第一个文档的内容前200字:{documents[0].page_content[:200]}")
print(f"元数据:{documents[0].metadata}")

注:load() 方法会一次性把整个文件读到内存。如果文件非常大(比如几百MB),建议用 lazy_load(),它会像“逐行读取”一样,一次只加载一部分,节省内存。

3.2 加载 CSV 表格

CSV 是表格文件,每一行是一条记录。加载时,你可以指定哪一列作为“正文内容”,哪几列作为“元数据”。

from langchain_community.document_loaders.csv_loader import CSVLoader
 
# 最简单的用法:把每一行都转成一个文档
loader = CSVLoader(file_path="客户反馈.csv")
docs = loader.load()
# 这样每一行会变成:page_content 包含整行的内容,metadata 里只有 source 文件名
 
# 进阶用法:精确控制哪些列放哪里
loader = CSVLoader(
    file_path="客户反馈.csv",
    metadata_columns=["客户ID", "反馈时间"],   # 这些列作为元数据(用来过滤、追溯)
    content_columns=["反馈内容"]               # 这一列作为正文(用来检索)
)
docs = loader.load()

3.3 加载 JSON 数据

JSON 是很常见的数据格式,但它的结构可能很复杂(嵌套对象、数组)。LangChain 用 jq 语法来精确提取你想要的部分。

# pip install jq
from langchain_community.document_loaders import JSONLoader
 
# 假设你的 JSON 长这样:
# {
#   "articles": [
#     {"title": "RAG入门", "content": "RAG是一种...", "author": "张三"},
#     {"title": "向量数据库", "content": "Milvus是...", "author": "李四"}
#   ]
# }
 
# 提取 articles 数组中的每一项,把整项作为文档内容
loader = JSONLoader(
    file_path="知识库.json",
    jq_schema=".articles[]",   # jq 语法:取出 articles 数组的每个元素
    text_content=False          # 保持原始 JSON 结构,不强制转成字符串
)
docs = loader.load()
 
# 更精细:只提取 content 字段作为正文,把 title 和 author 合并作为元数据
loader = JSONLoader(
    file_path="知识库.json",
    jq_schema="""
        .articles[] | {
            content: .content,
            metadata: {title: .title, author: .author}
        }
    """,
    text_content=True   # 把构造好的对象转成 JSON 字符串作为 page_content
)

jq 小贴士:. 代表当前节点;[] 表示遍历数组;| 是管道,把左边的结果传给右边;{key: value} 是构造新对象。学习这几个符号,就能应付大部分场景。

3.4 加载网页

有时候你需要爬取某个网页的内容作为知识来源。LangChain 集成了 BeautifulSoup 来解析 HTML。

# pip install beautifulsoup4
import bs4
from langchain_community.document_loaders import WebBaseLoader
 
# 加载百度百科的一个页面,并且只提取正文区域(class="J-lemma-content")
loader = WebBaseLoader(
    web_paths=["https://baike.baidu.com/item/RAG/12345"],  # 可以传多个网址
    bs_kwargs={
        "parse_only": bs4.SoupStrainer(class_="J-lemma-content")  # 只抓这个 class 里的内容
    }
)
docs = loader.load()

3.5 加载 PDF——最难啃的骨头

PDF 看起来简单,实际上格式非常复杂。有的 PDF 是文字版(可以直接选中文字),有的是扫描版(图片,需要 OCR 识别),还有的混合表格、图片、双栏排版。

如果你的 PDF 比较简单(纯文字,没有复杂表格),可以用最简单的 PyPDFLoader:

from langchain_community.document_loaders import PyPDFLoader
 
loader = PyPDFLoader("产品说明书.pdf")
docs = loader.load()

如果 PDF 有扫描图片或复杂排版,建议用 UnstructuredPDFLoader,它支持 OCR 识别图片中的文字,还能识别表格结构。

# pip install unstructured[local-inference]
# 还需要安装 Poppler(PDF渲染)和 Tesseract(OCR),具体安装方法网上搜一下
from langchain_community.document_loaders import UnstructuredPDFLoader
 
loader = UnstructuredPDFLoader(
    file_path="扫描版合同.pdf",
    mode="elements",           # elements 模式会按标题、段落、表格等元素切分
    strategy="hi_res",         # hi_res = 高精度解析(最慢但最准)
    infer_table_structure=True, # 尝试解析表格,结果放在 metadata 的 text_as_html 里
    languages=["chi_sim"]      # OCR 用简体中文
)
docs = loader.load()

实战经验:如果你的项目对文档解析质量要求很高(比如法律合同、学术论文),可以考虑付费服务如 MinerU、Mathpix 等,它们对公式、表格、多栏排版的识别效果远超开源方案。很多大厂的 RAG 项目,一半以上的工作量都花在了文档解析这一步。

04 第二步:文档切分——把大块肉切成适口小块

文档加载完之后,你可能得到一个很长的文档(比如一本员工手册,几十页)。你不能直接把整个手册喂给大模型,因为:

  • 大模型能接收的文本长度有限(比如 4k、8k、128k tokens)。超出的部分会被截断。

  • 即使模型能接收很长的文本,无关信息太多也会干扰它。就像让你在整本百科全书里找一个知识点,还不如只给你相关的那一页。

所以,我们需要把长文档切成一个个小“块”(Chunk),每个块大概几百到上千字。

4.1 用什么策略切?

最简单的办法是按固定字符数切,比如每 500 字切一块。但这会把一个句子拦腰切断。比如“我爱吃苹果”在“我爱”后面切了,那么“吃苹果”就失去了主语。

更好的办法是按标点符号切:优先在句号、问号、感叹号处切,如果没有,再在逗号、空格处切。这样能保证每个块都是完整的句子或段落。

LangChain 提供的 RecursiveCharacterTextSplitter 就是这种“递归切分器”。它有一个分隔符列表,按优先级从高到低尝试切分。

# pip install langchain-text-splitters
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
# 先加载一个长文档
from langchain_community.document_loaders import TextLoader
loader = TextLoader("员工手册.txt", encoding="utf-8")
doc = loader.load()
 
# 创建切分器
splitter = RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
    # 解释:先找两个换行(段落分隔),没有就找一个换行,没有就找句号,依次类推
    # 最后一个空字符串 "" 表示如果所有分隔符都找不到,就按字符硬切
 
    chunk_size=500,      # 每个块最多 500 个字符
    chunk_overlap=50,    # 相邻块重叠 50 个字符(防止边界信息丢失)
    add_start_index=True # 记录每个块在原文档中的起始位置
)
 
chunks = splitter.split_documents(doc)
print(f"原文档切成了 {len(chunks)} 个块")
print(f"第一个块的内容:{chunks[0].page_content}")

4.2 两个关键参数:chunk_size 和 chunk_overlap

  • chunk_size(块大小)

    :决定了每个块包含多少信息。

    • 太小(比如 200 字):每个块信息太少,可能找不到足够的上下文。

    • 太大(比如 2000 字):块内杂音多,而且浪费 Token(大模型按 Token 收费的)。

    • 经验值:对于中文,500~1000 字符是比较好的范围。

  • chunk_overlap(重叠大小)

    :相邻块之间重复的部分。

    • 为什么需要重叠?比如一个关键句子正好在切分边界上,如果没有重叠,它可能被切成两半,分别属于两个块,检索时哪一半都不完整。有了重叠,这个句子会完整地出现在两个块里。

    • 一般取 chunk_size 的 10%~20%。

你可以这样理解:切分就像把一长串香肠切成小段,每段长度 chunk_size,但是切的时候刀口稍微退回去一点(chunk_overlap),这样相邻两段会有一部分重合,保证接头处的信息不丢失。

05 第三步:向量嵌入——给文字“贴坐标”

计算机不认识文字,但认识数字。嵌入模型的作用就是把一段文字转换成一串数字(向量),这串数字代表了文字的“语义”。

想象一下:你把所有文档块放在一个巨大的多维空间里,意思相近的块会靠得很近。比如“苹果手机”和“iPhone”这两个词的向量距离很近;而“苹果手机”和“香蕉”的距离很远。

5.1 选择一个好用的中文嵌入模型

目前国产的 BAAI(北京智源研究院)推出的 BGE 系列模型,对中文的支持非常好,而且是开源的。

模型名

向量维度

速度

效果

推荐场景

bge-small-zh

512

良好

资源有限、快速验证

bge-base-zh

768

中等

很好

大多数场景推荐

bge-large-zh

1024

最好

追求极致准确率

bge-m3

1024

最好+多语言

文档含多种语言

向量维度:可以理解为坐标的“维度”。三维空间能区分前后左右上下,维度越高,能表达的语义细节越丰富,但计算量也越大。

5.2 用代码生成向量

# pip install sentence-transformers langchain_huggingface
from langchain_huggingface import HuggingFaceEmbeddings
 
# 加载模型(第一次运行会自动下载,大概 400MB)
# 如果你已经下载到本地,可以用 os.path.expanduser("~/models/bge-base-zh-v1.5")
embed_model = HuggingFaceEmbeddings(
    model_name="BAAI/bge-base-zh-v1.5"   # 也可以换成其他模型名
)
 
# 单个文本嵌入(用于用户的问题)
question = "年假有多少天?"
question_vector = embed_model.embed_query(question)
print(f"问题向量长度:{len(question_vector)}")  # 输出 768
print(f"向量前5个值:{question_vector[:5]}")    # 一堆小数
 
# 批量文本嵌入(用于文档块)
doc_texts = ["员工每年享有5天带薪年假", "产假为98天", "婚假3天"]
doc_vectors = embed_model.embed_documents(doc_texts)
print(f"文档块数量:{len(doc_vectors)},每个向量长度:{len(doc_vectors[0])}")

注意:embed_query 和 embed_documents 虽然底层都是生成向量,但有些模型会对查询和文档做不同的处理(比如添加特殊前缀),所以不要混用。用户的问题永远用 embed_query,文档块永远用 embed_documents。

06 第四步:向量数据库——给向量们安个家

生成了向量之后,需要把它们存起来,并且能够快速查找。这就是向量数据库干的事。

你可以把向量数据库想象成一个超级智能的“图书馆”:你给它一段文字,它立刻告诉你图书馆里哪几本书的内容和这段文字最像。

6.1 选择哪个向量数据库?

  • Chroma

    :轻量级,纯 Python,几行代码就能跑起来,适合学习和原型验证。

  • FAISS

    :Facebook 出品,检索速度极快,但不提供持久化存储(重启就丢),适合研究用途。

  • Milvus

    :功能最强大,支持分布式、标量过滤、高并发,适合生产环境。

我们这里用 Milvus Lite 版本,它可以直接在本地文件上运行,不需要搭建服务器,非常适合学习。

6.2 安装和初始化

pip install pymilvus[milvus-lite]
from pymilvus import MilvusClient
 
# 创建一个客户端,数据会保存在当前目录下的 milvus_demo.db 文件里
client = MilvusClient(uri="./milvus_demo.db")

6.3 设计数据表结构(Schema)

在 Milvus 里,表叫 Collection。我们需要告诉它每个字段叫什么、什么类型。

from pymilvus import DataType
 
# 定义表结构
def create_schema():
    schema = MilvusClient.create_schema(
        auto_id=True,           # 自动生成主键ID,不需要自己提供
        enable_dynamic_field=True  # 允许动态添加未定义的字段
    )
    # 添加字段:id(主键,整数类型)
    schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
    # 添加字段:vector(向量,768维,浮点数)
    schema.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=768)
    # 添加字段:text(原文内容,最长1024个字符)
    schema.add_field(field_name="text", datatype=DataType.VARCHAR, max_length=1024)
    # 添加字段:metadata(元数据,JSON格式,可以放任意结构)
    schema.add_field(field_name="metadata", datatype=DataType.JSON)
    return schema
 
# 创建索引(索引能加速检索)
def create_index():
    index_params = MilvusClient.prepare_index_params()
    index_params.add_index(
        field_name="vector",        # 对向量字段建立索引
        index_type="AUTOINDEX",     # 自动选择最合适的索引类型
        metric_type="L2"            # 相似度计算方式:L2=欧氏距离(值越小越相似)
    )
    return index_params

metric_type 的三种选择

L2

(欧氏距离):两点之间的直线距离,越小越相似。最直观。

IP

(内积):向量点积,越大越相似(需要先对向量做归一化)。

COSINE

(余弦相似度):关注方向而非长度,1表示完全相同。文本相似度通常用这个。

为了简单,我们先用 L2。

6.4 创建表并插入数据

# 如果表已经存在,先删除(方便重复运行)
collection_name = "demo_collection"
if client.has_collection(collection_name):
    client.drop_collection(collection_name)
 
# 创建表
client.create_collection(
    collection_name=collection_name,
    schema=create_schema(),
    index_params=create_index()
)
 
# 假设我们已经有了切分好的 chunks 和对应的向量
# chunks: List[Document],每个 Document 有 page_content 和 metadata
# embeddings: List[List[float]],每个向量与 chunks 一一对应
 
# 把数据组装成 Milvus 需要的格式
data_to_insert = []
for chunk, vector in zip(chunks, embeddings):
    data_to_insert.append({
        "vector": vector,                     # 向量
        "text": chunk.page_content,           # 原文
        "metadata": chunk.metadata            # 元数据(如文件名、页码等)
    })
 
# 插入数据
insert_result = client.insert(
    collection_name=collection_name,
    data=data_to_insert
)
print(f"插入了 {insert_result['insert_count']} 条数据")

6.5 检索:根据问题找最相似的块

当用户问一个问题时,我们先把问题转成向量,然后去向量数据库里找最相似的 k 个文档块。

def search_similar(query_text, embed_model, client, top_k=3):
    # 1. 把问题转成向量
    query_vector = embed_model.embed_query(query_text)
 
    # 2. 在数据库里搜索
    results = client.search(
        collection_name="demo_collection",
        data=[query_vector],              # 要搜索的向量
        anns_field="vector",              # 在哪个向量字段里搜索
        search_params={"metric_type": "L2"},  # 使用欧氏距离
        output_fields=["text", "metadata"],   # 要返回哪些字段
        limit=top_k                       # 返回最相似的前 top_k 个
    )
 
    # results 的结构:[[{id, distance, entity}, ...]]
    # 提取出原文
    contexts = [hit["entity"]["text"] for hit in results[0]]
    return contexts
 
# 测试
query = "公司年假怎么计算?"
contexts = search_similar(query, embed_model, client)
for i, ctx in enumerate(contexts):
    print(f"结果{i+1}:\n{ctx}\n")

6.6 关于“近似最近邻”(ANN)的通俗解释

你可能会好奇:数据库里可能有几十万条向量,怎么能在几毫秒内找到最相似的?

如果老老实实把所有向量都算一遍(这叫 KNN,精确最近邻),数据量一大就慢死了。

聪明的方法是近似最近邻(ANN):事先给向量建一个“索引”,就像书的目录。查找时,不需要翻遍整本书,而是先看目录找到大概的章节,再去那几页里仔细找。

Milvus 默认用的 HNSW 算法,你可以这样理解:

  • 想象你要在一个大城市里找一个人。如果你从大街上一个人一个人地问,累死。

  • 更好的办法:先找这个人可能在哪个区(上层索引),再去那个区里找街道(中层),最后到街道上找门牌号(底层)。

  • HNSW 就是建立了一个多层地图,上层“跳”得快,下层找得准。

这个索引只需要建一次(插入数据时),之后每次检索都享受加速。

07 第五步:生成——让大模型看着参考资料写答案

最后一步,把检索到的相关文档块,连同用户的问题,一起发给大模型,让它生成答案。

7.1 构建提示词(Prompt)

提示词就是你对大模型说的话。一个好的提示词要包含三部分:

  1. 角色设定

    :你是一个专业的HR助手...

  2. 参考资料

    :以下是从员工手册中检索到的内容...

  3. 用户问题

    :请根据参考资料回答...

from langchain_core.prompts import ChatPromptTemplate
 
template = ChatPromptTemplate.from_messages([
    ("system", 
     "你是一个专业的HR助手。请根据下面的【参考资料】回答用户的问题。"
     "如果参考资料里没有相关信息,请如实说‘资料中没有提到’,不要编造答案。"
     "\n\n【参考资料】\n{context}"
    ),
    ("human", "{question}")
])

7.2 调用大模型

你可以使用 OpenAI、国产模型(如智谱、通义千问),或者本地部署的模型。这里以 OpenAI 兼容接口为例:

# pip install langchain-openai
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(
    model="gpt-3.5-turbo",   # 或 "gpt-4"
    temperature=0,           # 温度越低,回答越确定(不随意发挥)
    api_key="你的API密钥",    # 也可以从环境变量读取
    base_url="https://api.openai.com/v1"  # 如果是其他厂商,换成对应地址
)

7.3 把检索和生成串起来

LangChain 提供了 RunnablePassthrough 和 RunnableLambda 来构建处理链。

from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
 
# 定义一个函数:检索上下文
def retrieve_context(question):
    return search_similar(question, embed_model, client, top_k=3)
 
# 构建链
rag_chain = (
    {
        "question": RunnablePassthrough(),           # 原样传递问题
        "context": RunnableLambda(retrieve_context)  # 调用检索函数
    }
    | template            # 填入提示词模板
    | llm                 # 调用大模型
    | StrOutputParser()   # 提取输出文本
)
 
# 执行
question = "新员工的年假是怎么计算的?"
answer = rag_chain.invoke(question)
print(answer)

7.4 流式输出(像 ChatGPT 那样一个字一个字出来)

如果你想实现打字机效果,可以用 stream 方法:

for chunk in rag_chain.stream("试用期员工有年假吗?"):
    print(chunk, end="", flush=True)

08 总结:RAG 的优缺点和适用场景

优点

  1. 知识实时更新

    :只需要往数据库里加新文档,不需要重新训练模型。

  2. 可溯源

    :答案有据可查,可以告诉用户“这是根据第X页的文档得出的”。

  3. 降低幻觉

    :大模型有参考资料,胡说八道的概率大大降低。

  4. 保护隐私

    :私有数据留存在你自己的数据库里,不用交给模型厂商。

缺点

  1. 响应延迟

    :每次问答都要做检索+生成两步,比直接调用大模型慢一些。

  2. 消耗更多 Token

    :把检索到的上下文也塞进提示词里,Token 用量会增加。

  3. 检索效果决定上限

    :如果没检索到相关的资料,大模型再强也没用。

什么时候该用 RAG?

✅ 适合 RAG

  • 知识频繁更新(公司制度、产品文档、新闻资讯)

  • 领域知识不在大模型训练数据中(企业内部资料、私有数据库)

  • 需要答案有据可查(法律、医疗、金融)

  • 不想承担微调模型的高昂成本

❌ 不适合 RAG

  • 任务本身不需要外部知识(比如写一首诗、做数学计算)

  • 实时性要求极高(毫秒级响应)

  • 文档量极小,且基本不变(那直接提示词里写死就行了)

最后的话

RAG 并不是什么高深莫测的技术,它只是巧妙地利用了“检索”这个传统技术,给大模型装上了一双可以翻书的“手”。很多人一开始觉得大模型很神奇,但当你理解了它的局限,再配合 RAG,你会发现:一个可控、可靠、可更新的 AI 系统,并不需要几十亿的投入,只需要你按照这篇文章的步骤,一步步搭建起来。

如果你的团队也想落地一个智能问答机器人,建议从最简单的 Chroma + 本地小模型开始,跑通整个流程。等有了实际需求和数据量,再平滑迁移到 Milvus + 商业大模型。一步一个脚印,你会发现 RAG 远比想象中简单。