从零搞懂 RAG:让大模型不再“胡言乱语“
从零搞懂 RAG:让大模型不再"胡言乱语"
当大模型开始"编造事实"的时候,我决定给它配一本"参考书"
一、大模型为啥会胡说八道?我踩过的坑
刚开始用大模型的时候,我单纯地以为"问啥答啥,应该挺准的"。直到有一天我问了一个关于我所在公司内部流程的问题,模型信誓旦旦地给我编了一套完整的操作步骤——每一步看起来都很合理,但全错。
这时候我才意识到大模型的几个致命伤:
知识滞后:大模型的训练数据是有截止日期的。你问它"昨天发生的新闻",它肯定不知道。就像你让一个 2025 年的人预测 2026 年的房价,他只能靠猜。
领域知识缺失:如果你问的是某个公司内部的操作手册、某个小众领域的专业知识,模型根本没学过这些数据。它只能"硬答",答出来的东西往往是错的。
幻觉:这是最要命的问题。大模型在不确定答案的时候,会编造看起来合理但实际上错误的内容。而且它编得很自信,非专业人士根本分辨不出来。
不可追溯:你永远不知道模型的回答是从哪来的。它说是"根据资料",但你找不到原文在哪。
尤其在金融、医疗这些领域,一次错误的判断可能就是致命的。但大模型可不管这些,它还是会努力给你一个"听起来很靠谱"的答案。
那有没有办法让大模型"有据可查"呢?这就是 RAG 要做的事。
二、RAG 到底是啥?一句话说清楚
RAG = 给大模型配一本参考书
RAG 全称是 Retrieval-Augmented Generation(检索增强生成)。简单说就是:在让大模型回答问题之前,先从外部资料库里找到相关的内容,把这些内容连问题一起喂给模型,让它"看资料"回答问题。
就像考试的时候,以前你只能凭记忆作答,现在允许你开卷——带一堆参考书进去。RAG 就是那个帮你翻书找答案的过程。
RAG 的完整流程(分成两步走)
整个 RAG 分两个阶段:
第一阶段(离线,提前准备好) :把文档加载进来 → 切成小段 → 转成向量 → 存进向量数据库。这个阶段只需要做一次,后面查的时候直接用。
第二阶段(在线,每次用户提问时发生) :用户提问 → 把问题也转成向量 → 去向量库里找最相似的内容 → 把问题 + 找到的内容一起喂给模型 → 模型根据资料生成回答。
说白了就是:先搜再答,让模型有的放矢。
三、文档加载:把各种文件塞进系统
企业里的文档散落在各种格式里:Word、PDF、Markdown、网页、数据库……RAG 的第一步就是把它们统一读进来,转成 LangChain 能处理的 Document 对象。
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
loader = UnstructuredWordDocumentLoader("sample.docx", mode="elements")
docs = loader.load()
不同格式的"友好程度"完全不一样:
- 纯文本(.txt) :一堆字堆在一起,机器很难判断哪是标题哪是正文
- Markdown(.md) :用
#、-这些符号明确标出结构,机器很好认 - Word(.docx) :内部是 XML,能读出段落,但"是不是标题"很不统一——有人用内置样式,有人直接改字号大小,解析起来很头疼
- 数据库:结构最清晰,列名、类型都固定,机器最喜欢
PDF 是最难搞的。不同来源的 PDF 格式五花八门:扫描版要 OCR、电子版要解析、双栏布局要按顺序读、表格和公式要单独处理……
我用的是 MinerU 来处理复杂 PDF。它用两阶段策略:先在整个页面上"扫一眼"找出标题、正文、表格的位置,再对每个区域做精细识别。用 VLM(视觉语言模型)辅助识别,准确率比传统方式高不少。
四、文档切分:不切没法用
加载完文档之后,面临的第一个问题是:整篇文档太长了,不能直接塞给模型。
模型的上下文窗口有限(GPT-4o 是 128K token,但很多小模型只有 8K),而且一整篇文档里 80% 的内容跟当前问题无关,检索的时候噪声太大。
所以需要把文档切成小块,每一块叫一个 Chunk(文本块)。
切分要平衡三个东西:
- 块不能太大,否则检索噪声多
- 块不能太小,否则语义不完整(比如一句话被切成两半)
- 要在自然的地方切(段落边界、句子边界),不能强行截断
LangChain 提供了多种切分策略,我用的最多的是 RecursiveCharacterTextSplitter(递归字符切分器) 。它的思路很巧妙:
先定义一堆分隔符的优先级列表:["\n\n", "\n", "。", ",", " "],然后从最高优先级开始切。如果切出来的块还是太大,就降级用下一级分隔符继续切。这样能最大程度保持语义完整。
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每块最大长度
chunk_overlap=50, # 相邻块重叠 50 个字符
separators=["\n\n", "\n", "。", ",", " "]
)
chunks = splitter.split_documents(docs)
chunk_overlap(重叠)很重要。因为切分会把连续的一段话切开,可能刚好把关键信息切在边界上。重叠保证了边界附近的信息至少出现在两个块里,不会被遗漏。
五、文本嵌入:让计算机"读懂"文字
切分完之后,每个块还是一段中文文字,计算机没法直接比较"哪两段话意思更接近"。需要一个步骤把文字转成计算机能计算的数字——这就是嵌入(Embedding)。
嵌入模型会把一段文本映射成一个高维向量(比如 768 维或 1024 维的浮点数数组)。语义相近的文本,在向量空间里距离更近;语义无关的,距离更远。
我用的是 BGE-M3 这个模型,它有个特别厉害的地方:一次编码,同时生成稠密向量和稀疏向量。
稠密向量:捕捉整体语义。比如"AI"和"人工智能"意思相近,它们的稠密向量就离得近。适合语义匹配。
稀疏向量:记录关键词及其权重。比如"民法典第236条"这种精确匹配,稠密向量可能给我一堆民法相关内容,但稀疏向量能精准找到包含"236条"的文档。适合关键词匹配。
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel("bge-m3")
result = model.encode(
["标量字段用来存储元数据"],
return_dense=True,
return_sparse=True
)
# result["dense_vecs"] 稠密向量
# result["lexical_weights"] 稀疏向量
六、向量存储:数据存哪儿?
向量生成之后要存起来,而且得支持"根据语义相似度快速查找"。这就是向量数据库干的事。
传统数据库(MySQL)擅长精确查询,查"等于多少"、"大于多少"非常快。但查"跟这个向量最像的 3 个"就抓瞎了——它只能全表扫描,一个个算距离,数据量一大就慢得要死。
向量数据库专门解决这个问题,通过 ANN(近似最近邻)索引,能在亿级数据里毫秒级返回结果。
我用的是 Milvus,因为它支持稠密向量和稀疏向量的混合检索,而且生产环境用得最多,生态成熟。
from pymilvus import MilvusClient
client = MilvusClient(uri="http://localhost:19530")
理解 Milvus 的几个核心概念就行:
- Collection:相当于 MySQL 里的表
- Schema:定义表有哪些字段(主键、稠密向量、稀疏向量、原始文本、元数据)
- Index:建在向量字段上的索引,用来加速检索。稠密向量用 HNSW,稀疏向量用倒排索引
- Metric Type:怎么算"相似"。稠密向量用 COSINE 或 IP,稀疏向量用 IP
关于 HNSW,我理解它就像查地图:先看全国地图(顶层,节点少)确定大致方向,再看城市地图(中间层)缩小范围,最后在街道地图(底层,包含所有数据)上精确查找。每一步都跳过大量无关数据,所以快。
七、检索:怎么找到最相关的内容
存好数据之后,核心操作就是"搜索"。用户提问,去库里找最相关的文档块。
稠密向量检索(语义匹配)
results = client.search(
collection_name="demo",
data=[dense_vec], # 用户问题的稠密向量
anns_field="vector",
limit=3, # 返回 Top 3
output_fields=["text"]
)
用户问"什么是大语言模型",系统会找到语义最相近的 3 个文档块返回。
稀疏向量检索(关键词匹配)
results = client.search(
collection_name="demo",
data=[sparse_vec], # 用户问题的稀疏向量
anns_field="sparse_vector",
limit=3,
output_fields=["text"]
)
用户搜"民法典第236条",系统会精准命中包含这个关键词的文档。
混合检索 + RRF 重排序(推荐)
各跑一路,然后把结果合并排序:
from pymilvus import AnnSearchRequest, RRFRanker
dense_req = AnnSearchRequest(data=[dense_vec], anns_field="vector", ...)
sparse_req = AnnSearchRequest(data=[sparse_vec], anns_field="sparse_vector", ...)
results = client.hybrid_search(
reqs=[dense_req, sparse_req],
ranker=RRFRanker(k=60), # RRF 重排序
limit=3
)
RRF(倒数排名融合)的原理很简单:同样的文档在两个检索结果里排名越高,最终得分越高。这样可以综合语义和关键词的优势。
八、生成:把找到的资料交给模型
检索到相关文档块之后,最后一步就是把检索结果拼成上下文,和用户问题一起喂给大模型。
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini")
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个有帮助的AI助手。请根据以下上下文回答问题:\n{context}"),
("human", "{question}")
])
# 检索到的文档块拼成上下文
context = "\n\n---\n\n".join([hit["entity"]["text"] for hit in results])
response = llm.invoke(prompt.format_messages(
context=context,
question="什么是大语言模型?"
))
print(response.content)
完整的 RAG 链用 LCEL 写会更优雅:
rag_chain = {
"context": retriever | format_docs,
"question": RunnablePassthrough()
} | prompt | llm | StrOutputParser()
result = rag_chain.invoke("什么是大语言模型?")
九、完整端到端案例(代码)
把前面所有的环节串起来:
from langchain_openai import ChatOpenAI
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# 1. 加载文档
loader = TextLoader("sample.txt")
docs = loader.load()
# 2. 切分文档
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
splits = splitter.split_documents(docs)
# 3. 嵌入并存入向量库(这里用 Chroma,轻量级开发用)
vectorstore = Chroma.from_documents(documents=splits, embedding=OpenAIEmbeddings())
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
# 4. 定义提示词模板
prompt = ChatPromptTemplate.from_messages([
("system", "请根据以下上下文回答问题:\n{context}"),
("human", "{question}")
])
llm = ChatOpenAI(model="gpt-4o-mini")
def format_docs(docs):
return "\n\n---\n\n".join([doc.page_content for doc in docs])
# 5. 构建 RAG 链
rag_chain = {
"context": retriever | format_docs,
"question": RunnablePassthrough()
} | prompt | llm | StrOutputParser()
# 6. 执行
result = rag_chain.invoke("什么是LangChain?")
print(result)
十、进阶优化方向
跑通基础版 RAG 之后,如果想提升效果,可以从这几个方向优化:
减小 chunk_size:500-800 字符通常比 1000+ 更精准。块越小,检索命中越精确。
混合检索:稠密 + 稀疏向量混合,语义匹配和关键词匹配都覆盖。
RRF 重排序:把多路召回的结果融合排序,提升精准度。
查询重写:用户的问题可能口语化严重,先让 LLM 把它重写成更适合检索的形式。
元数据过滤:在检索时附加过滤条件(只查某份文档、某年之后的内容),缩小范围。
多路召回:用不同的检索策略(不同模型、不同切分粒度)并行查,然后合并排序。
十一、总结
RAG 的核心就六个环节:加载 → 切分 → 嵌入 → 存储 → 检索 → 生成。
让大模型"开卷考试"——给它配一本参考书,答案有据可查,幻觉大幅减少。朗朗上口的一句话总结:RAG = 检索 + 生成,让模型先查后答。
下一步我准备学 Agents(智能代理) ,让模型能自主调用工具完成任务。到时候再来分享。
更多推荐
所有评论(0)