Skip to content

🧱 从 0 到 1 完整 RAG 系统:本地部署、检索、引用与评测

📌 这篇不是"RAG 概念介绍"——是可交付的实战教程。目标是看完之后你能在本地跑起来一套完整 RAG 系统:读文档 → 检索 → 回答 → 带来源引用 → 能评测。


01|项目目标

做一个"公司内部知识库问答系统"。用户用自然语言提问,系统从公司文档里检索相关内容,给出带引用来源的回答。

最终效果:

  • 用户问:"我们的年假政策是什么?"
  • 系统答:"根据《员工手册 v2025》第 3.2 节,年假为每年 15 天,可累计最多 30 天。[来源: employee_handbook_2025.pdf, 第 12 页]"

02|技术栈选择

python
# requirements.txt
langchain==0.3.0
langchain-openai==0.2.0
langchain-community==0.3.0
chromadb==0.5.0         # 轻量本地向量库(零配置)
unstructured==0.15.0     # 文档解析(支持 PDF/Word/HTML)
pypdf==4.0.0
tiktoken==0.7.0
ragas==0.2.0             # RAG 评测
fastapi==0.115.0
uvicorn==0.30.0
python-dotenv==1.0.0

选择理由:

  • ChromaDB:不需要装 Docker、不需要配服务器——pip install chromadb 就能用。适合本地开发和小规模部署
  • Unstructured:能处理 PDF、Word、HTML、Markdown 等 20+ 种格式
  • LangChain:RAG 的文档加载和链式调用最简单(Agent 逻辑会用 LangGraph 但 RAG 用 LangChain)

03|项目目录结构

text
rag-system/
├── data/                  # 原始文档
│   ├── handbook.pdf
│   └── faq.docx
├── src/
│   ├── ingest.py           # 文档读取 + 清洗 + 索引
│   ├── retrieve.py         # 检索模块
│   ├── generate.py         # 生成模块
│   ├── app.py              # FastAPI 接口
│   └── evaluate.py         # 评测脚本
├── chroma_db/              # 向量库持久化目录
├── eval_set/               # 评测集
│   └── test_questions.json
├── requirements.txt
└── .env                    # OPENAI_API_KEY

04|Step 1:文档读取与清洗

python
# ingest.py
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

# 1. 加载文档
loader = DirectoryLoader("data/", glob="**/*.pdf", loader_cls=PyPDFLoader)
raw_docs = loader.load()
print(f"Loaded {len(raw_docs)} documents")

# 2. 清洗
for doc in raw_docs:
    doc.page_content = doc.page_content.replace("\n\n", "\n")      # 去多余空行
    doc.page_content = doc.page_content.replace("\t", " ")          # Tab 转空格

# 3. 语义分块(chunk_size=500, chunk_overlap=100)
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500, chunk_overlap=100,
    separators=["\n\n", "\n", "。", ".", " ", ""]
)
chunks = splitter.split_documents(raw_docs)
print(f"Split into {len(chunks)} chunks")

# 4. Embedding + 向量库
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
    chunks, embeddings,
    persist_directory="chroma_db/"
)
print("Indexing complete!")

关键说明

  • chunk_overlap=100 确保相邻 chunk 之间有重叠——防止关键信息被切在两段之间
  • separators 优先按段落(\n\n)切,再按句号切,最后按空格——保持语义完整性

05|Step 2:混合检索

python
# retrieve.py
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(persist_directory="chroma_db/", embedding_function=embeddings)

# Dense 检索(语义)
dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

# Sparse 检索(关键词 BM25)
bm25_retriever = BM25Retriever.from_documents(all_chunks)  # 需要缓存chunks
bm25_retriever.k = 10

# 混合检索 + Rerank
ensemble = EnsembleRetriever(
    retrievers=[dense_retriever, bm25_retriever],
    weights=[0.7, 0.3]  # Dense 权重更高(语义匹配为主)
)

def retrieve(query: str, top_k: int = 5):
    docs = ensemble.invoke(query)[:top_k]
    return docs

为什么需要混合检索

  • Dense(语义):用户问"怎么请假"→ 能匹配到"休假流程"(语义接近)
  • Sparse(关键词):用户问"PTO 政策"→能匹配到精确包含"PTO"的文档片段(Dense 可能把 PTO 和"请假"搞混)

06|Step 3:Prompt 组装与生成

python
# generate.py
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = ChatPromptTemplate.from_messages([
    ("system", """你是公司内部知识库助手。请只根据以下参考资料回答问题。

如果参考资料不足以回答问题,请诚实地说"根据现有资料无法回答这个问题"——不要编造。

参考资料:
{context}"""),
    ("user", "{question}")
])

def generate_answer(question: str, retrieved_docs: list):
    # 拼装上下文 + 引用来源
    context = ""
    sources = []
    for i, doc in enumerate(retrieved_docs):
        context += f"[{i+1}] {doc.page_content}\n\n"
        sources.append(doc.metadata.get("source", "Unknown"))

    chain = prompt | llm
    response = chain.invoke({"context": context, "question": question})

    return {
        "answer": response.content,
        "sources": sources
    }

07|Step 4:拒答机制

python
def should_answer(retrieved_docs, threshold=0.7):
    """用检索分数判断是否应该回答"""
    # ChromaDB 的 similarity_search_with_score 返回 (doc, score)
    # score 越小越相似(L2 距离)
    if not retrieved_docs:
        return False
    # 如果第一个文档的相似度 < 0.7(阈值可调),拒答
    avg_score = sum(d.metadata.get("score", 0) for d in retrieved_docs) / len(retrieved_docs)
    return avg_score < threshold

为什么需要拒答:Agent 检索不到相关文档时,如果没有拒答机制,LLM 会"自由发挥"——编造一个看起来像真的回答。拒答 = 保护用户免受幻觉。


08|Step 5:FastAPI 接口

python
# app.py
from fastapi import FastAPI
from pydantic import BaseModel
from retrieve import retrieve
from generate import generate_answer

app = FastAPI()

class Query(BaseModel):
    question: str

@app.post("/ask")
async def ask(query: Query):
    docs = retrieve(query.question)
    if not docs:
        return {"answer": "未找到相关信息。", "sources": []}
    result = generate_answer(query.question, docs)
    return {"answer": result["answer"], "sources": result["sources"][:5]}

# 启动: uvicorn app:app --host 0.0.0.0 --port 8000

09|Step 6:RAGAS 评测

python
# evaluate.py
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
from datasets import Dataset

# 准备评测数据
eval_data = Dataset.from_dict({
    "question": ["年假是多少天?", "谁负责审批请假?"],
    "answer": [
        "根据《员工手册》第3.2节,年假为每年15天。",   # Agent 的输出
        "请假由直属经理审批。"
    ],
    "contexts": [
        ["年假为每年15天,可累计..."],
        ["请假由直属经理审批..."]
    ],
    "ground_truth": [
        "年假15天",
        "直属经理审批"
    ]
})

# 跑评测
result = evaluate(eval_data, metrics=[faithfulness, answer_relevancy, context_precision])
print(result)
# 期望值: faithfulness > 0.85, answer_relevancy > 0.80

10|错误排查表

症状可能原因排查方向
检索结果不相关Embedding 模型与文档领域不匹配换个 Embedding 模型试试(bge-m3 对中文更好)
回答编造数据检索没找到相关文档,LLM 自由发挥了加拒答机制(检查检索分数)
Chunk 内容不完整Chunk 太小(< 300 字)调大 chunk_size 到 800-1000
中文回答英文System Prompt 里没指定语言加一条 "请用中文回答"
向量库越来越大Chunking 太细导致 10 万+个向量用 PCA 降维或换更大规模的向量库

11|从 Demo 到生产需要补什么

当前版本能做的事情:

  • ✅ 本地跑通 RAG 全流程
  • ✅ 混合检索 + 拒答
  • ✅ API 接口 + 评测

上生产需要补的:

  • 增量索引(文档更新时不用全量重建向量库)
  • 权限隔离(不同用户看到不同知识库)
  • 全链路 trace + Dashboard
  • 缓存层(高频问题缓存)
  • Docker 化 + CI/CD

📌 RAG 不是"搭起来就完了"——是"搭起来之后开始持续优化检索质量和生成质量的过程"。评测集是你在这个过程中的唯一导航工具。


🍋 本文为 AI Agent 学习路线 · 完整 RAG 项目实战。© 2026 AI小柠檬。

© 2026 🍋AI小柠檬 · 内容原创,转载请注明出处