还在为大模型“胡言乱语”头疼?不用微调,不用烧显卡,一个被无数大厂验证过的技术方案——RAG,就能让大模型瞬间学会你公司的私有知识。今天,我们用最通俗的语言,把这项技术的每一个细节都掰开揉碎讲清楚。
01 大模型的两个“致命伤”
先问你一个问题:如果让你在完全不查资料的情况下,回答“2024年诺贝尔物理学奖颁给了谁?”你能答上来吗?
大概率不能——因为你的“知识”停留在几年前,这就是知识滞后。
大模型也一样。它虽然在海量数据上训练了很久,但训练一次动辄几个月,花费几千万甚至上亿美金。训练完成之后,它的知识就“定格”了。你问它2024年发生的事情,它要么说不知道,要么就按照以前的规律瞎编。
另一个问题是知识缺失。你家公司内部的规章制度、产品文档、客户案例,大模型根本没见过。你问它“我们公司年假怎么休”,它只能跟你道歉说“我无法回答”。
最要命的是第三个问题:幻觉。
幻觉就是大模型“一本正经地胡说八道”。比如你问“哈利波特第一次骑自行车是什么时候?”它可能很自信地回答“1997年,在女贞路4号。”——听起来像模像样,但全是编的。书里根本没这回事。
为什么会这样?因为大模型本质是一个“文字接龙高手”,它不知道对错,只知道“根据前面的文字,下一个字最可能是什么”。这就好比一个学霸,虽然能背很多书,但如果你问他书本上没有的内容,他就会开始“自由发挥”。
有一个真实案例:某律师用大模型准备庭审材料,大模型“引用”了好几个判例,还附上了详细的案号、法官名字。结果对方律师一查——这些判例全是编的!这位律师因此被罚款。这不是段子,是真事。
那么,有没有办法让大模型既能发挥它的语言天赋,又能保证回答有据可查?
有。这个办法就是 RAG。
02 RAG 是什么?开卷考试了解一下
RAG 的全称是 Retrieval-Augmented Generation,中文叫“检索增强生成”。名字很唬人,但本质就四个字:开卷考试。
想一想,开卷考试和闭卷考试有什么区别?
-
闭卷考试:全靠记忆。大模型现在就是闭卷模式。
-
开卷考试:允许你翻书查资料。RAG 就是给大模型配一本“参考书”。
具体流程是这样:
-
你问一个问题:“我们公司年假怎么休?”
-
RAG 系统先去你公司的“知识库”(比如员工手册、公司制度文档)里,把相关的内容搜出来。
-
然后把搜到的内容,连同你的问题,一起交给大模型。
-
大模型看着这些“参考资料”来组织答案。
这样一来,大模型的回答就有了事实依据,不再是空口白话。而且你随时可以更新知识库里的文档,大模型的知识也就跟着更新了,不需要重新训练。
RAG 的另一个优点是保护隐私。你的私有数据不需要喂给大模型训练,只用在检索那一刻临时调用。数据始终在你自己的数据库里。
典型的RAG有两个主要流程:
索引:从数据源提取数据,构建索引。
检索生成:接受用户查询并从索引中检索相关数据,然后将其传递给模型。。
索引阶段:
-
从各种数据源加载数据
-
将文档切分为小块
-
对文本块进行嵌入
-
存储嵌入向量。
检索生成阶段:
-
根据用户输入,使用检索器从存储中检索相关文本块
-
大模型使用包含问题和检索结果的提示生成回答。
接下来,我们就按这个流程,用代码一步步搭建一个 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)
提示词就是你对大模型说的话。一个好的提示词要包含三部分:
-
角色设定
:你是一个专业的HR助手...
-
参考资料
:以下是从员工手册中检索到的内容...
-
用户问题
:请根据参考资料回答...
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 的优缺点和适用场景
优点
-
知识实时更新
:只需要往数据库里加新文档,不需要重新训练模型。
-
可溯源
:答案有据可查,可以告诉用户“这是根据第X页的文档得出的”。
-
降低幻觉
:大模型有参考资料,胡说八道的概率大大降低。
-
保护隐私
:私有数据留存在你自己的数据库里,不用交给模型厂商。
缺点
-
响应延迟
:每次问答都要做检索+生成两步,比直接调用大模型慢一些。
-
消耗更多 Token
:把检索到的上下文也塞进提示词里,Token 用量会增加。
-
检索效果决定上限
:如果没检索到相关的资料,大模型再强也没用。
什么时候该用 RAG?
✅ 适合 RAG:
-
知识频繁更新(公司制度、产品文档、新闻资讯)
-
领域知识不在大模型训练数据中(企业内部资料、私有数据库)
-
需要答案有据可查(法律、医疗、金融)
-
不想承担微调模型的高昂成本
❌ 不适合 RAG:
-
任务本身不需要外部知识(比如写一首诗、做数学计算)
-
实时性要求极高(毫秒级响应)
-
文档量极小,且基本不变(那直接提示词里写死就行了)
最后的话
RAG 并不是什么高深莫测的技术,它只是巧妙地利用了“检索”这个传统技术,给大模型装上了一双可以翻书的“手”。很多人一开始觉得大模型很神奇,但当你理解了它的局限,再配合 RAG,你会发现:一个可控、可靠、可更新的 AI 系统,并不需要几十亿的投入,只需要你按照这篇文章的步骤,一步步搭建起来。
如果你的团队也想落地一个智能问答机器人,建议从最简单的 Chroma + 本地小模型开始,跑通整个流程。等有了实际需求和数据量,再平滑迁移到 Milvus + 商业大模型。一步一个脚印,你会发现 RAG 远比想象中简单。