Skip to content

Latest commit

 

History

History
438 lines (289 loc) · 23.6 KB

sample-output.md

File metadata and controls

438 lines (289 loc) · 23.6 KB

RAG(检索增强生成)入门指南

RAG基础概念

问题引入

大语言模型有两大短板:知识截止日和幻觉。

知识截止日意味着模型训练完成后,世界仍在变化,它却一无所知。问ChatGPT"今天有什么新闻",它只能道歉。

幻觉更危险:遇到知识盲区,模型会编造看似合理实则虚构的回答,像不会也要硬写的学生。

RAG(检索增强生成)同时解决这两个问题——不给模型"裸答",先帮它查资料再作答。

案例类比

律师面对陌生案件,不会仅凭记忆作答。他会让助手去档案室调取相关法条和判例,材料摊开后再给出专业意见。

RAG同理:LLM是"律师",检索系统是"助手",知识库是"档案室"。记忆加资料,答案才可靠。

原理剖析

RAG核心公式:检索 + 生成 = 有据可依的回答。

LLM擅长理解和组织语言,但记不住海量事实。检索系统擅长快速定位相关内容,但不懂语义。两者天然互补。

检索阶段:将用户问题转为查询,从文档库找出最相关段落。

生成阶段:把检索结果拼入提示词,让LLM"对着资料回答",而非凭记忆发挥。

文档库可随时更新,知识截止日不再是瓶颈。有参考资料约束,幻觉被大幅抑制。

代码示例

from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate

docs = ["产品X于2024年6月15日发布。", "退货:30天内无理由退货。"]
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_texts(docs, embeddings)

query = "产品X什么时候发布的?"
retrieved = vectorstore.similarity_search(query, k=1)
context = "\n".join([d.page_content for d in retrieved])

prompt = ChatPromptTemplate.from_template(
    "仅根据以下资料回答。\n资料:{context}\n问题:{query}")
llm = ChatOpenAI(model="gpt-3.5-turbo")
response = llm.invoke(prompt.format(context=context, query=query))
print(response.content)
# 输出:产品X于2024年6月15日发布。

这17行代码走通了RAG四步:入库→提问→检索→生成。核心逻辑在于第10行的similarity_search负责"找资料",第16行的invoke负责"用资料回答",两者接力完成"检索增强"。

应用思考

典型场景是企业知识库问答。客服接入产品手册RAG,客户问"怎么退货",系统自动检索政策条款生成准确回答,而非让模型瞎编。

另一个方向是实时信息问答。新闻媒体每日将稿件入库,模型就能回答"昨天发生了什么",彻底打破知识截止日限制。

关于向量检索和Embedding的深入原理,见下一主题。

文档处理与向量嵌入

问题引入

想让AI回答公司内部PDF里的内容,不能直接把100页文档丢给大模型——上下文窗口装不下,检索精度也差。文档需要三步预处理:加载、分块、向量化。这三个环节做对了,RAG系统就成功了一半。

案例类比

这像图书馆整理一批新到的藏书:文档加载器是把各种开本、装帧的书从箱子里取出来;文本分块是按章节、段落给每本书编目切分;向量嵌入则是给每个编目条目贴一张"索书号"。读者查资料时,不用翻遍整本书,靠索书号就能精准定位到相关段落。

一、文档加载器:把不同格式"读"进来

现实文档格式五花八门——PDF、Word、HTML、Markdown。不同加载器各有擅长,选错工具可能丢数据。

PyMuPDF(fitz库):底层用C实现,解析PDF速度极快、内存占用低。适合提取纯文本段落。短板是对表格、图片中的文字束手无策,版面复杂时提取结果混乱。

Unstructured:专为RAG场景设计的重型工具。能识别版面结构——标题、正文、表格分别提取,支持扫描件OCR。代价是依赖项多(需要libreoffice、tesseract等),处理速度慢,小项目引入太重。

LangChain文档加载器:不是又一个解析器,而是统一封装层。内部调的仍是PyMuPDF或Unstructured,但调用接口一致。换文档格式只需改一行代码,非常适合快速原型和多格式项目。

选型口诀:纯文本PDF用PyMuPDF;含表格/扫描件的报告用Unstructured;快速原型和格式不确定时用LangChain统一接口起步。

二、文本分块:把长文档"切"成可检索的小段

Embedding模型对输入长度有硬限制(text-embedding-3-small 是8192 tokens),100页文档必须切分。切法直接决定检索质量。

固定长度分块:按字符数一刀切,比如每500字一块。实现最简单,但常在句子中间截断,造成语义碎片——用户搜"Retrieval-Augmented Generation",可能只命中后半截"Generation"。

递归字符文本分割器:LangChain的默认策略。按优先级依次尝试分隔符切分:双换行(段落边界) → 单换行 → 句号 → 空格。优先在自然语义边界断开,尽量保持句子完整。这是实战中最成熟的方案。

语义分块:让模型判断"话题在哪变了",在主题切换处切分。效果最优但需额外调用模型,延迟高、成本大。

实战建议:从递归分割起步(chunk_size=500字符,chunk_overlap=50字符保留上下文衔接)。检索效果不够,再考虑调参数或上语义分块。

三、嵌入模型:把文字变成可比较的数学向量

计算机不懂文字,只懂数字。Embedding模型做的事:输入一段文本,输出一串固定长度的浮点数(向量)。奥秘在于——语义相近的文本,向量在高维空间中的距离也近。

"苹果很好吃" → [0.02, -0.13, 0.47, 0.11, …] (512个浮点数) "这个水果真甜" → [0.03, -0.11, 0.45, 0.09, …] (向量很接近,余弦相似度≈0.92) "今天天气不错" → [0.31, 0.52, -0.18, -0.41, …](向量距离远,余弦相似度≈0.21)

检索时,把用户问题和所有文档块的向量做相似度计算,取Top-K结果。模型选型直接影响这个匹配的质量。

主流模型对比:

  • text-embedding-3-small:512维,$0.02/百万token,OpenAI API调用,英文效果好,性价比最高
  • text-embedding-3-large:3072维,$0.13/百万token,中英文均优秀,精度最高
  • BGE-M3:1024维,免费开源,需本地GPU部署,中文效果突出,数据不出内网
  • E5-mistral:4096维,免费开源,多语言覆盖广,但中文弱于BGE

选型建议:预算敏感/英文为主→small;中文高精度要求→large或BGE;数据安全要求内网部署→BGE本地化。

代码示例

# 步骤1:加载PDF文档
from langchain_community.document_loaders import PyMuPDFLoader
loader = PyMuPDFLoader("企业年报.pdf")
docs = loader.load()
print(f"共加载 {len(docs)} 页")  # 输出:共加载 32 页
# 步骤2:递归字符分块
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(docs)
print(f"共切出 {len(chunks)} 个块")  # 输出:共切出 86 个块
# 步骤3:生成嵌入向量
from langchain_openai import OpenAIEmbeddings
emb = OpenAIEmbeddings(model="text-embedding-3-small")
vec = emb.embed_query("什么是RAG?")
print(f"向量维度: {len(vec)}")  # 输出:向量维度: 512
print(vec[:3])  # 输出:[0.017, -0.024, 0.041](前3个分量)

应用思考

落地场景:公司有500份投标书PDF,想快速检索"近三年做过的医院信息化项目"。流程:Unstructured加载含表格的PDF → 递归分割500字/块、重叠50字 → BGE-M3本地嵌入 → 存入向量数据库。检索时输入查询"医院 信息化 2022-2024",返回最相关文档块和来源页码。

扩展思路:不只文档能走这套流程。课程视频先用Whisper转文字,客服录音先做ASR转写,然后同样走分块→嵌入→检索。文本预处理管道是通用能力。

关键提醒:分块大小和嵌入模型选型没有"银弹"。务必先用小批量数据(50条问答对)测检索命中率,再决定是调chunk_size还是换模型。关于向量数据库的选型与检索优化,见下一主题。

检索技术与相似度计算

问题引入

你在搭建一个知识库问答系统。用户输入“如何退货”,系统却返回了“会员积分规则”。排查一圈发现:不是大模型的问题,是检索环节就找错了文档。检索决定了后续一切的上限——检索错了,再强的模型也给不出好答案。

案例类比

想象你在一座百万册图书馆找一本特定书籍。你不会逐本翻阅,而是查索引卡、按索书号锁定楼层和书架。向量检索同理:先把文本变成数学坐标(向量),再用索引快速定位“最近的邻居”。索引卡是图书馆的检索技术,向量索引是我们的检索技术。

知识点一:向量数据库——存储、索引与近似搜索

向量数据库专为向量检索而生。它的核心能力就三件事:存向量、建索引、做近似最近邻搜索(ANN)。

存向量好理解——把每段文本的向量表示存下来。关键是建索引。传统数据库用B+树做精确匹配,向量数据库用HNSW图(Hierarchical Navigable Small World,一种分层近邻图算法)做相似性检索。HNSW的原理是:构建多层图结构,高层做“高速公路”快速跳转,底层做精细比较。搜索时从高层一路向下,像从高速→省道→小巷逐步逼近目的地。

ANN中的“近似”是关键取舍:牺牲微小的精度(比如99.9%准确率降到99%),换来百倍的检索速度。10亿条向量中找最近邻,暴力计算要几分钟,ANN只需几毫秒。这就是向量数据库能上生产的原因。

主流向量数据库怎么选?看场景:

  • Chroma:轻量开源,Python一行启动,适合原型和demo
  • Milvus:分布式架构,支持十亿级向量,适合生产环境
  • Pinecone:全托管云服务,零运维,适合不想管基础设施的团队
  • Qdrant:Rust编写,过滤性能突出,适合带元数据筛选的场景
  • Weaviate:内置向量化模块,支持混合搜索(向量+关键词)
import chromadb
client = chromadb.Client()
collection = client.create_collection("help_center")

# 存储:文档及其向量
collection.add(
    documents=["退货需在7天内申请,附订单号",
               "会员积分可通过签到获取"],
    ids=["d1", "d2"]
)

# 检索:找最相关的文档
result = collection.query(
    query_texts=["怎么退货"],
    n_results=1
)
print(result["documents"][0][0])
# 输出:退货需在7天内申请,附订单号

落地建议:原型阶段用Chroma快速验证,上生产时根据数据量级和运维能力选择Milvus或Pinecone。关于各库的深入对比见后续文档。

知识点二:余弦相似度——为什么它是默认选择

向量检索的本质是计算“谁和我最像”。度量“像不像”有很多方法,余弦相似度是最通用的标准:

$$\cos(A,B) = \frac{A \cdot B}{|A| \times |B|}$$

公式拆开看:分子是两向量点积(对应位置相乘再求和,反映方向一致性),分母是两向量长度的乘积(做归一化)。结果落在[-1, 1]:1是完全同向,0是正交无关,-1是完全反向。

为什么不用欧氏距离? 看一个例子:“退货政策”和“退货流程”的文本向量,方向几乎一致,但如果“退货政策”那篇文档更长、词更多,向量长度就更大。欧氏距离会因长度差异判它们不近,余弦只看方向——这正是我们要的:语义方向一致的内容。

类比:两个人面朝同一方向,一个近一个远。余弦判断“是否看向同一目标”,欧氏距离判断“站得有多近”。在语义检索中,“看向哪里”才是关键。

import numpy as np

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

query = np.array([0.2, 0.8, 0.1])
doc_ok = np.array([0.3, 0.7, 0.2])   # 方向接近
doc_no = np.array([0.9, 0.0, 0.3])   # 方向偏离

print(f"相关文档: {cosine_sim(query, doc_ok):.3f}")
# 输出:相关文档: 0.977
print(f"无关文档: {cosine_sim(query, doc_no):.3f}")
# 输出:无关文档: 0.474

0.977远高于0.474,精准区分了相关与无关。余弦相似度默认嵌入所有主流向量数据库,无需额外配置。

知识点三:检索重排序——用Cross-Encoder精排

向量检索返回Top-K结果,但排序质量有提升空间。原因在于:向量检索用的是双塔模型——查询和文档分别编码再算相似度,两边的编码过程独立,缺乏深层交互。

Cross-Encoder(交叉编码器) 改变了策略:把查询和文档拼接成一对,一起送入模型做完整推理。模型看到查询的同时也逐字阅读文档,捕捉“这个词在查询中出现、在文档中如何呼应”这类细粒度语义。代价是慢——不能对全库做,只能对初筛后的Top-K候选集逐对打分。

类比:向量检索是HR快速扫简历(10秒一份筛出50人),Cross-Encoder是面试官逐人深聊(每人15分钟后重新排名)。前者的效率+后者的精度,就是RAG检索的最佳实践。

from sentence_transformers import CrossEncoder

candidates = ["退货需7天内申请,附订单号",
              "会员积分规则说明",
              "退货需保持商品完好"]

reranker = CrossEncoder("BAAI/bge-reranker-base")
pairs = [["怎么退货", doc] for doc in candidates]
scores = reranker.predict(pairs)

# 按分数降序排列
ranked = sorted(zip(candidates, scores),
                key=lambda x: x[1], reverse=True)
for i, (doc, score) in enumerate(ranked):
    print(f"排名{i+1} [{score:.3f}]: {doc[:20]}")
# 输出:
# 排名1 [0.891]: 退货需7天内申请,附订单号
# 排名2 [0.734]: 退货需保持商品完好
# 排名3 [0.012]: 会员积分规则说明

初筛可能把三条都排进Top-K,重排序后将真正相关的两条提到前面,无关的“积分规则”被压到末位。实际应用中,重排序通常带来10-20%的Top-3命中率提升,而成本极低——只对10到50条候选做推理。

小结

检索技术三件套环环相扣:向量数据库负责“找得到”(存储与快速定位),余弦相似度负责“比得准”(衡量语义方向),重排序负责“排得精”(精修最终顺序)。三者组合的质量,决定了RAG系统输出的天花板。选型建议:先跑通Chroma+余弦+开源Reranker的链路,再根据业务需求逐步替换组件。

增强生成与上下文管理

你搭的RAG系统检索召回了5篇相关文档。但怎么把它们塞进大模型的提示词?文档太长超出窗口怎么办?生成的回答用户能信吗?这三个问题,是RAG落地的"最后一公里"。


一、Prompt模板设计与变量注入

检索到的文档是"原料",大模型是"厨师"。把原料和用户问题一起递给厨师,标准做法就是Prompt模板+变量注入。

打个比方:你每天给不同客户发催款邮件,不会每次都重写全文,而是准备一个模板——"尊敬的{客户名},您的订单{订单号}已逾期…"。运行时把变量填进去即可。RAG系统完全一样:模板里留一个{context}占位符,检索结果自动填入。

# {context}是占位符,运行时动态替换
template = """基于以下参考资料回答问题:
{context}

用户问题:{query}
请基于资料回答,资料未提及时如实说明。"""

docs = [
    "【文档1】报销需填写《费用报销单》,附发票原件。",
    "【文档2】金额超5000元需部门总监审批。",
    "【文档3】每月25日为报销截止日。"
]

context = "\n\n".join(docs)
query = "报销5000元需要谁审批?"

prompt = template.format(context=context, query=query)
# 结果:context位置被三篇文档替换,query位置被问题替换

三个工程细节值得注意。第一,{context}放在系统指令和用户问题之间最合适——模型先"读资料"再作答,效果最好。第二,可加入行为约束如"资料未提及时如实说明"——这叫"接地性"约束,防止模型凭空编造。第三,模板本身也消耗token,通常预留200-500 tokens给模板结构,其余空间分配给context。


二、上下文窗口管理

现实很骨感:检索可能返回几十篇文档,拼接后轻松超过模型窗口(GPT-3.5是4096 tokens)。超出部分被直接截断,后面的文档等于白召回了。

类比:你搬家只有一个固定大小的箱子。贵重物品优先放,放不下的暂存。上下文窗口管理就是决定"哪些文档进箱子"。

最实用的方案是token计数+贪心填充:用tiktoken逐篇计算文档token数,按相关性排序后依次放入,超出阈值就停止。

import tiktoken
enc = tiktoken.get_encoding("cl100k_base")

def fill_context(docs, max_tokens=2000):
    result, used = [], 0
    for doc in docs:
        cost = len(enc.encode(doc))
        if used + cost > max_tokens:
            break          # 装不下了
        result.append(doc)
        used += cost
    return "\n\n".join(result)

docs = ["文档A" * 500, "文档B" * 300, "文档C" * 400]
ctx = fill_context(docs, max_tokens=800)
print(f"使用token: {len(enc.encode(ctx))}")
# 输出:使用token: 约800(文档C被截断在外)

更高级的策略是滑动窗口:多轮对话时,保留最近几轮问答和最新检索结果,旧内容滑出窗口。这模拟人的"短期记忆"——只保留当前最相关的信息,在客服、对话式搜索等场景尤其有用。

落地建议:对于4096窗口的模型,给context留3000 tokens是安全值。如果文档很长,可先用小模型做摘要压缩再注入,相当于把"全文"变成"精要",进一步提升窗口利用率。


三、引用溯源实现

用户看了回答可能会问:"这个结论从哪来的?"如果回答不能追溯到原始文档,信任度大打折扣。引用溯源就是给每句话标注出处。

实现思路直接:构建context时给每篇文档编号,prompt中明确要求引用时标注编号,结尾列出引用来源。

docs = [
    {"id": 1, "title": "报销制度v3.2", "text": "超5000元需总监审批。"},
    {"id": 2, "title": "财务流程手册", "text": "截止日为每月25日。"}
]

parts = [f"[文档{d['id']}]《{d['title']}》:{d['text']}" for d in docs]
context = "\n\n".join(parts)

prompt = f"""参考资料:
{context}

问题:报销6000元找谁审批?截止日是哪天?
要求:引用时标注"据[文档X]所述",结尾列出引用来源。"""
# 模型输出:
# 据[文档1]所述,报销6000元需总监审批。据[文档2]所述,
# 截止日为每月25日。
# 引用来源:[文档1]《报销制度v3.2》 [文档2]《财务流程手册》

关键工程技巧:编号放在文档内容前面([文档1]前置),模型更容易学会引用——放在后面经常被忽略。另外,Few-shot示例比Zero-shot指令效果好得多:在prompt中给1-2个"带引用的回答样例",模型能更稳定地输出规范格式。


四、应用思考

模板注入、窗口管理、引用溯源——三者构成了RAG"最后一公里"的闭环。在此基础上,动态模板选择值得探索:根据用户意图切换模板——问政策走严谨模板,问操作走分步骤模板,闲聊走轻松模板。另外,可以用小模型做引用验证——校验回答中的文档编号是否真实存在、引用内容是否被篡改,相当于给RAG加一道"事实核查"防线。关于更复杂的多轮对话上下文策略,见下一主题。

系统评估与架构决策

问题引入

你上线了一个RAG系统,测试时感觉"还行",但用户反馈答案牛头不对马嘴。没有量化标准,你无法判断改了检索策略后系统是变好了还是变坏了。评估体系缺失,优化就纯靠猜。

核心知识点一:RAG评估四维指标

评估围绕四个维度,各回答一个具体问题:

忠实度(Faithfulness):答案是否严格基于检索上下文?"爱丽丝周一去了公园"——若上下文只提到"去了公园"没提"周一",那"周一"就是臆测。度量方式:将答案拆成原子声明,逐条验证是否有上下文支撑。

答案相关性(Answer Relevance):答案是否直接回应了问题?用户问"退货政策",答案大谈公司历史——内容再正确,相关性为零。度量方式:用答案反推"它最适合回答什么问题",再与原始问题做语义相似度比对。

上下文精度(Context Precision):有用文档是否排在检索结果前列?精度衡量信噪比——前几条就该是关键信息,不用翻到第8条才找到答案。

上下文召回率(Context Recall):标准答案所需的信息,检索环节找全了吗?召回率低意味着关键信息压根没进来,后续生成再强也无济于事。召回率是底线,忠实度是门面。

# 四维度速览 + 评估优先序
metrics = {
    "context_recall":    "信息找全没?——底线指标",
    "context_precision": "排序对不对?——效率指标",
    "faithfulness":      "答案有无臆测?——用户第一感知",
    "answer_relevance":  "是否答非所问?——体验收口"
}
# 评估流程:先测召回率→再验精度→最后查忠实度+相关性

核心知识点二:Agent式RAG架构

传统RAG是"一次性"的:检索→生成→结束。Agent式RAG把控制权交给大模型本身,让它像研究员一样"边查边想"。

工作流:Agent接到问题→自主判断"需要检索吗"→生成检索query→拿到结果→评估"够了吗"→不够就换角度再查→信息充分后才生成答案。这称为迭代式检索。

形象类比:传统模式像快递——下单(提问),仓库发货(检索),一次交付。Agent模式像侦探——不断发现新线索,调整调查方向,拼出完整图景。

适用场景:复杂多跳推理,如"A公司CEO和B公司CEO是大学同学吗"——需要分步检索、交叉验证。代价是推理轮次增加,延迟更高。

# Agent式RAG决策循环(伪代码,≤3轮)
def agent_rag(question):
    ctx = []
    for _ in range(3):
        q = llm.decide_query(question, ctx)
        if q == "STOP": break
        ctx.extend(retrieve(q))
    return llm.generate(question, ctx)
# 核心:模型自主决定"查什么、何时停"

核心知识点三:RAG vs 微调——场景化决策

选RAG:知识频繁更新(内部Wiki每日变动)、需引用来源(合规审计可溯源)、知识体量远超模型窗口。

选微调:风格需固化(客服话术统一)、领域术语偏好强(医疗/法律)、已有大量高质量标注数据。

二者不互斥:先微调让模型习得领域表达习惯,再叠加RAG注入动态知识——"微调+RAG"混合方案是很多生产系统的标配。

关于微调的具体实践和成本考量,见后续"模型微调实战"主题。

应用思考

  • 指标怎么选? 召回率是底线——信息找不到后面全白搭。忠实度是门面——用户首先感知答案是否"胡编"。初期先盯这两个,成熟后再补精度和相关性。
  • Agent架构是否必要? 若90%问题只需单次检索,强行Agent化只增延迟和成本。先统计用户问题复杂度分布,用数据驱动架构决策。