RAG(Retrieval-Augmented Generation)大概是2024到2026年落地最广的大模型应用架构了。原因很简单:它解决了大模型最致命的两个问题——知识过时和幻觉。不改模型、不微调、不碰权重,只在推理阶段加一层检索,就能让模型基于你自己的数据回答问题。这买卖太划算了。

我从2024年初开始搭RAG系统,踩了不少坑,也积累了一些经验。今天把整个流程整理出来,从文档加载到最终部署,尽量写得足够实操。

先搞清楚RAG到底在做什么

很多人对RAG的理解停留在"先搜索,再让模型回答",这个理解大方向没错,但缺了关键细节;

一个完整的RAG系统处理一个用户问题的流程是这样的:用户输入一个问题;系统把问题转换成向量(embedding);用这个向量去向量数据库中做相似度检索,找到和问题最相关的K个文档片段;把检索到的文档片段和用户问题一起组装成一个提示词(prompt);把提示词发送给大模型,大模型基于提供的文档片段生成回答。

注意关键点:大模型看到的不是完整的原始文档,而是被切分成小块的文档片段。所以分块策略直接决定了检索质量,检索质量直接决定了最终回答的质量。

项目结构

先把代码结构定下来,后面按模块讲解:

rag-system/
├── ingest.py           # 文档摄取:加载、分块、向量化
├── query.py            # 查询:检索、组装prompt、生成回答
├── config.py           # 配置
├── loaders/            # 文档加载器
│   ├── pdf_loader.py
│   ├── markdown_loader.py
│   └── web_loader.py
├── splitters/          # 分块策略
│   └── text_splitter.py
└── retrievers/         # 检索器
    └── vector_retriever.py

第一步:文档加载

文档格式千奇百怪,PDF、Word、Markdown、HTML、数据库记录、API响应……加载器要做的是把这些不同格式统一转换成纯文本,并保留元数据(来源、页码等);

from langchain_community.document_loaders import (
    PyPDFLoader,
    DirectoryLoader,
    TextLoader,
    UnstructuredMarkdownLoader
)
import os

def load_documents(doc_dir: str):
    """递归加载目录下所有支持格式的文档"""
    all_docs = []
    
    # PDF文件
    pdf_loader = DirectoryLoader(
        doc_dir, glob="**/*.pdf",
        loader_cls=PyPDFLoader, show_progress=True
    )
    all_docs.extend(pdf_loader.load())
    
    # Markdown文件
    md_loader = DirectoryLoader(
        doc_dir, glob="**/*.md",
        loader_cls=UnstructuredMarkdownLoader, show_progress=True
    )
    all_docs.extend(md_loader.load())
    
    # 纯文本文件
    txt_loader = DirectoryLoader(
        doc_dir, glob="**/*.txt",
        loader_cls=TextLoader,
        loader_kwargs={"encoding": "utf-8"}, show_progress=True
    )
    all_docs.extend(txt_loader.load())
    
    print(f"共加载 {len(all_docs)} 个文档片段")
    return all_docs

一个常见的坑是PDF加载。PyPDFLoader按页提取文本,如果PDF是扫描件(图片形式),它提取出来是空的。这种情况需要先做OCR,或者用Unstructured库的auto模式。

第二步:文本分块

分块是RAG系统中最影响效果的环节,没有之一;

块太大(比如2000字以上),检索到的块里可能只有一小部分和问题相关,其余的都是噪音,会干扰模型判断。块太小(比如100字以下),检索到的片段可能丢失了必要的上下文,模型没法理解。块之间没有重叠,关键信息如果恰好在分块边界处,会被切断;

LangChain的RecursiveCharacterTextSplitter是最常用的选择:

from langchain.text_splitter import RecursiveCharacterTextSplitter

def split_documents(documents, chunk_size=500, chunk_overlap=80):
    """将文档分割成适合检索的小块"""
    
    # 中文文本的分隔符优先级
    separators = [
        "\n\n",    # 段落分隔
        "\n",       # 换行
        "。",        # 句号
        "!",        # 感叹号
        "?",        # 问号
        ";",        # 分号
        ",",        # 逗号
        " ",         # 空格
        ""           # 字符级(兜底)
    ]
    
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
        separators=separators,
        is_separator_regex=False
    )
    
    chunks = splitter.split_documents(documents)
    print(f"分割成 {len(chunks)} 个文本块")
    print(f"平均块大小: {sum(len(c.page_content) for c in chunks) / len(chunks):.0f} 字符")
    
    return chunks

chunk_size和chunk_overlap需要根据你的文档类型调整。技术文档信息密度高,chunk_size可以设小一些(300-500),chunk_overlap设50-100。文章/报告类内容逻辑连贯性强,chunk_size建议600-1000,chunk_overlap设100-200。

第三步:向量化和存储

把文本块转换成向量,存入向量数据库。这里用Chroma做演示,它是最轻量的选择,适合快速原型:

from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma

def create_vectorstore(chunks, persist_dir="./chroma_db"):
    """创建向量数据库"""
    
    # 选用中文优化的嵌入模型
    embeddings = HuggingFaceEmbeddings(
        model_name="BAAI/bge-small-zh-v1.5",
        model_kwargs={"device": "cpu"},
        encode_kwargs={
            "normalize_embeddings": True,
            "batch_size": 64
        }
    )
    
    # 批量创建向量并存储
    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=persist_dir,
        collection_metadata={"hnsw:space": "cosine"}
    )
    
    print(f"向量数据库已持久化到 {persist_dir}")
    print(f"共 {vectorstore._collection.count()} 条向量")
    
    return vectorstore

嵌入模型的选择很重要。中文场景推荐BAAI的bge-small-zh-v1.5或bge-large-zh-v1.5。small版本只有120MB,速度快效果够用;large版本400MB,效果更好但速度慢一些。

第四步:检索和生成

最后把检索和生成串起来:

from langchain_community.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

def build_qa_chain(vectorstore):
    """构建RAG问答链"""
    
    llm = ChatOpenAI(
        base_url="http://localhost:11434/v1",
        api_key="ollama",
        model="deepseek-r1:7b",
        temperature=0.1,     # 低温度减少随机性
        max_tokens=2048
    )
    
    # 自定义提示词——这是效果的关键
    prompt = PromptTemplate(
        template="""你是一个严谨的技术文档问答助手。请严格基于以下参考文档回答用户的问题。

## 参考文档
{context}

## 用户问题
{question}

## 回答规则
1. 只根据参考文档的内容回答,绝对不要编造信息
2. 如果参考文档中没有相关信息,直接说"根据现有文档,我无法回答这个问题"
3. 引用文档中的具体段落来支撑你的回答
4. 如果文档信息不足以完整回答,说明哪些部分有依据、哪些部分文档未提及""",
        input_variables=["context", "question"]
    )
    
    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",
        retriever=vectorstore.as_retriever(
            search_type="similarity",
            search_kwargs={"k": 4}     # 检索最相关的4个片段
        ),
        chain_type_kwargs={"prompt": prompt},
        return_source_documents=True,
        verbose=False
    )
    
    return qa_chain

prompt的写法直接影响回答质量。要点是:明确要求模型只基于文档回答(减少幻觉);要求引用原文(方便用户验证);给模型"说不知道"的权利(不要逼模型编造)。

完整调用

# 摄取阶段(一次性)
documents = load_documents("./docs")
chunks = split_documents(documents, chunk_size=500, chunk_overlap=80)
vectorstore = create_vectorstore(chunks)

# 查询阶段(日常使用)
qa_chain = build_qa_chain(vectorstore)
result = qa_chain({"query": "我们的退款政策是什么?"})

print(result["result"])
print("\n--- 引用来源 ---")
for doc in result["source_documents"]:
    print(f"文件: {doc.metadata.get('source', '未知')}")
    print(f"内容: {doc.page_content[:150]}...")

效果优化方向

基础版本搭好后,可以从几个方向优化。混合检索方面,把向量检索和关键词检索(BM25)结合,召回率会明显提升。查询改写方面,用户的问题可能表达不清楚,先用模型改写成更适合检索的形式。重排序方面,检索到的候选文档用Cross-Encoder重新打分排序,比单纯的向量相似度更准。

写在最后

RAG系统不复杂,但细节决定效果。分块策略和提示词设计是两个最容易出效果的优化点,建议优先在这两处下功夫。别急着上花哨的框架,先把最基础的流程跑通、跑好。