为什么选 RAG,而不是直接微调?
很多团队一开始想到"让模型学会我们的内部知识",第一反应是微调(Fine-tuning)。但微调有几个硬伤:
- 数据更新了就要重新训练,成本高、周期长
- 模型参数量有限,塞不进几千份文档
- 无法溯源,模型说错了也不知道是哪份文档的问题
RAG(Retrieval-Augmented Generation)的思路完全不同:先检索,再生成。模型不需要"记住"所有知识,只需要在生成回答时拿到相关文档片段作为上下文。文档更新了?重新向量化就好,模型不用动。
整体架构
RAG 流水线分两个阶段:
离线阶段(文档入库)
原始文档 → 文档解析 → 文本切分 → Embedding 向量化 → 写入向量数据库
在线阶段(问答查询)
用户提问 → Embedding 向量化 → 向量检索 → 取 Top-K 文档片段 → 拼入 Prompt → LLM 生成回答
架构简单,但每个环节都有坑。下面逐一拆解。
技术选型
Embedding 模型
Embedding 模型把文本转成向量,直接影响检索质量。国内有两个主流选择:
| 方案 | 模型 | 维度 | 特点 |
|---|---|---|---|
| API 方案 | 通义千问 text-embedding-v3 | 1024 / 2048 维 | 无需部署,按 token 计费 |
| 开源自部署 | BAAI/bge-large-zh-v1.5 | 1024 维 | 一次性成本,中文效果优秀 |
如果是初期验证或数据量不大,推荐直接用通义千问的 Embedding API,省去部署运维的麻烦。数据量上去后(千万级向量),再考虑迁移到自部署方案。
顺带一提,如果你的 RAG 系统同时调用多家厂商的 Embedding 和 Chat 模型,可以考虑用笔者开发的 TheRouter 来统一管理——一个 API Key 即可同时调用 embedding 和 chat 模型,切换供应商时也不需要改业务代码。
向量数据库
| 数据库 | 适用场景 | 特点 |
|---|---|---|
| ChromaDB | 本地开发、小规模(百万级以内) | 纯 Python,零配置,内嵌运行 |
| Qdrant | 生产环境,中等规模 | Rust 实现,性能好,有 REST API |
| Milvus | 大规模(亿级向量) | 分布式,功能最全,运维成本高 |
本文用 ChromaDB 演示(可以直接跑,不用装任何外部服务),生产建议换 Qdrant。
生成模型
知识库问答对模型的要求是:能够严格基于给定上下文回答,而不是自由发挥。推荐:
- DeepSeek-Chat:推理能力强,指令遵循好,价格实惠
- Qwen-Max:通义系,和 Embedding 同一套 API,账单管理方便
完整代码实现
先安装依赖:
pip install langchain langchain-text-splitters chromadb openai python-dotenv fastapi uvicorn pypdf
第一步:文档加载与切分
文档切分是 RAG 质量的关键。切太小,单个片段语义不完整;切太大,噪声多、超出上下文窗口。
# doc_loader.py
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader, DirectoryLoader
import os
def load_documents(doc_dir: str) -> list:
"""加载目录下所有 PDF 文档"""
loader = DirectoryLoader(
doc_dir,
glob="**/*.pdf",
loader_cls=PyPDFLoader,
show_progress=True
)
documents = loader.load()
print(f"加载了 {len(documents)} 个文档页面")
return documents
def split_documents(documents: list, chunk_size: int = 512, chunk_overlap: int = 64) -> list:
"""
文本切分
chunk_size: 每个片段的字符数,建议 256-1024
chunk_overlap: 相邻片段重叠字符数,建议 chunk_size 的 10-15%
"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""],
length_function=len,
)
chunks = splitter.split_documents(documents)
print(f"切分为 {len(chunks)} 个文本块")
return chunks
separators 参数很重要:中文文档优先按段落(\n\n)切,其次按句号切,尽量保证每个 chunk 语义完整。
第二步:调用 Embedding API 向量化
# embedder.py
from openai import OpenAI
import os
# 通义千问 Embedding API(兼容 OpenAI SDK 格式)
client = OpenAI(
api_key=os.environ["DASHSCOPE_API_KEY"],
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
def get_embeddings(texts: list[str], model: str = "text-embedding-v3") -> list[list[float]]:
"""
批量获取文本的 Embedding 向量
每次最多 25 条(API 限制),自动分批处理
"""
all_embeddings = []
batch_size = 25
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
response = client.embeddings.create(
model=model,
input=batch,
dimensions=1024 # text-embedding-v3 支持指定维度
)
batch_embeddings = [item.embedding for item in response.data]
all_embeddings.extend(batch_embeddings)
print(f"已处理 {min(i + batch_size, len(texts))}/{len(texts)} 条")
return all_embeddings
第三步:存入 ChromaDB
# vector_store.py
import chromadb
from chromadb.config import Settings
import hashlib
def init_collection(persist_dir: str = "./chroma_db", collection_name: str = "knowledge_base"):
"""初始化 ChromaDB,数据持久化到本地目录"""
client = chromadb.PersistentClient(
path=persist_dir,
settings=Settings(anonymized_telemetry=False)
)
collection = client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"} # 余弦相似度
)
return collection
def ingest_documents(chunks: list, embeddings: list[list[float]], collection):
"""将文档块和向量写入向量数据库"""
texts = [chunk.page_content for chunk in chunks]
metadatas = [
{
"source": chunk.metadata.get("source", "unknown"),
"page": chunk.metadata.get("page", 0),
}
for chunk in chunks
]
# 用内容哈希作为 ID,避免重复入库
ids = [hashlib.md5(text.encode()).hexdigest() for text in texts]
# ChromaDB 批量写入(每批最多 5000 条)
batch_size = 500
for i in range(0, len(texts), batch_size):
collection.upsert(
ids=ids[i:i + batch_size],
documents=texts[i:i + batch_size],
embeddings=embeddings[i:i + batch_size],
metadatas=metadatas[i:i + batch_size],
)
print(f"成功写入 {len(texts)} 条向量")
注意用 upsert 而不是 add,这样重复运行脚本不会报错(文档更新时只需重跑入库流程)。
第四步:检索 + LLM 生成
# rag_pipeline.py
from openai import OpenAI
from embedder import get_embeddings
import os
llm_client = OpenAI(
api_key=os.environ["DEEPSEEK_API_KEY"],
base_url="https://api.deepseek.com/v1"
)
def retrieve(query: str, collection, top_k: int = 5) -> list[dict]:
"""向量检索,返回最相关的 top_k 个文档片段"""
query_embedding = get_embeddings([query])[0]
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
include=["documents", "metadatas", "distances"]
)
chunks = []
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
chunks.append({
"content": doc,
"source": meta.get("source", ""),
"page": meta.get("page", 0),
"score": 1 - dist # 余弦距离转相似度
})
return chunks
def generate_answer(query: str, context_chunks: list[dict]) -> dict:
"""基于检索到的上下文生成回答"""
# 过滤低相似度片段(阈值可调)
filtered = [c for c in context_chunks if c["score"] > 0.5]
if not filtered:
return {"answer": "在知识库中未找到相关信息。", "sources": []}
context = "\n\n---\n\n".join([c["content"] for c in filtered])
sources = list({c["source"] for c in filtered})
prompt = f"""你是企业知识库助手。请严格根据以下参考资料回答用户问题。
如果参考资料中没有相关信息,请明确说明"知识库中暂无此信息",不要编造内容。
参考资料:
{context}
用户问题:{query}
请给出清晰、准确的回答:"""
response = llm_client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": prompt}],
temperature=0.1, # 知识库问答用低温度,减少幻觉
max_tokens=1024,
)
return {
"answer": response.choices[0].message.content,
"sources": sources
}
temperature=0.1 是关键——知识库问答需要模型"忠实于原文",不要创意发挥。
第五步:FastAPI 封装成服务
# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from vector_store import init_collection
from rag_pipeline import retrieve, generate_answer
app = FastAPI(title="企业知识库 API")
collection = init_collection()
class QueryRequest(BaseModel):
question: str
top_k: int = 5
class QueryResponse(BaseModel):
answer: str
sources: list[str]
chunks: list[dict]
@app.post("/query", response_model=QueryResponse)
async def query_knowledge_base(req: QueryRequest):
if not req.question.strip():
raise HTTPException(status_code=400, detail="问题不能为空")
chunks = retrieve(req.question, collection, top_k=req.top_k)
result = generate_answer(req.question, chunks)
return QueryResponse(
answer=result["answer"],
sources=result["sources"],
chunks=chunks
)
@app.get("/health")
async def health():
return {"status": "ok", "doc_count": collection.count()}
启动服务:
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
优化技巧
Chunk 大小调优
没有万能的 chunk size,要根据文档类型来:
- 技术文档、规章制度:512-768 字符。段落结构清晰,大块切分语义完整
- FAQ 问答对:128-256 字符。每个问答本身就是独立语义单元
- 长篇报告:768-1024 字符。需要更多上下文才能理解
实践上,先用 512 跑基线,再用一组标注好的查询-答案对做 Recall@5 评估,调整到最优值。
Retrieval Top-K 设置
top_k 越大,召回率越高,但引入噪声也越多,Prompt 越长(成本越高)。建议:
- 初始设
top_k=5,相似度阈值0.5 - 如果出现"答非所问",降低阈值或减少 top_k
- 如果出现"回答不完整",提高 top_k 或降低阈值
Reranking(重排序)
向量检索的缺点是语义粗粒度,可以在检索后加一个 Reranker 做精排:
# 使用 bge-reranker 对检索结果重排序(开源模型,本地运行)
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
def rerank(query: str, chunks: list[dict], top_n: int = 3) -> list[dict]:
pairs = [[query, c["content"]] for c in chunks]
scores = reranker.compute_score(pairs)
for chunk, score in zip(chunks, scores):
chunk["rerank_score"] = score
return sorted(chunks, key=lambda x: x["rerank_score"], reverse=True)[:top_n]
实测加入 Reranker 后,回答准确率能提升 10-20%。
成本估算
以一个中等规模企业知识库为例(1000 份 PDF,约 50 万字):
入库阶段(一次性)
- Embedding:50 万 tokens × ¥0.0007/千 token ≈ ¥0.35
- 基本可以忽略不计
查询阶段(每次查询)
- Embedding(问题向量化):约 50 tokens,< ¥0.0001
- LLM 生成(含 5 个 chunk 上下文):约 2000 tokens,约 ¥0.004(DeepSeek)
按日均 500 次查询估算,每月 LLM 成本约 ¥60。相比人工查阅文档节省的时间,这个成本完全值得。
小结
RAG 知识库的核心链路并不复杂,难点在于:
- 文档解析质量:PDF 表格、图片内容往往提取不好,需要针对性处理
- Chunk 粒度:直接影响检索质量,需要反复调试
- Prompt 工程:要明确告诉模型"只基于给定资料回答",否则模型会"发挥"
代码已覆盖完整链路,可以直接拿去跑。文档目录准备好,DASHSCOPE_API_KEY 配上,几分钟就能跑起来一个可用的知识库原型。
欢迎在评论区交流 RAG 落地中遇到的问题,特别是中文文档处理的坑。
作者:TheRouter 开发者,专注 AI 模型路由网关。项目主页:therouter.ai