一、被ChatGPT坑回工位的那个周五
2024年3月,我们给客户演示一个内部知识库问答系统,用的GPT-4。客户问「我们2024年的报销标准是多少」,GPT-4给出了一版2022年的数字,当场翻车。它没联网,训练数据里没有我们2024年1月更新的制度文件——这是大模型的硬伤。
我用脚趾头想都知道该上RAG了,但网上一搜全是概念科普,没有一篇能直接撸代码的。这篇东西是我花4周踩出来的,全文涉及的代码片段可以直接粘到你项目里跑。
二、三套方案:手搓、微调、RAG
搭建成本、维护成本、更新时效性、私有数据支持,四个维度对着比。
| 方案 | 搭建成本 | 维护成本 | 更新时效 | 私有数据 | 结论 |
|---|---|---|---|---|---|
| 直接LLM | 低(API调用) | 零 | 训练截止日 | 不支持 | 不可用 |
| LoRA微调 | 高(需要GPU) | 高(数据变了要重训) | 按周/月 | 支持 | 数据变化慢才值得 |
| RAG | 中(向量库+嵌入) | 低(改文档即可) | 实时 | 支持 | 知识库场景最优 |
我们的场景是制度文档问答,文档每周都在更新,RAG是唯一合理的选择。
三、整体架构
我使用的具体版本:Python 3.11.7、LangChain 0.1.0、Qdrant 1.9.0、text-embedding-3-small(OpenAI)。
核心链路:文档加载 → 文本切块 → 向量化 → 存向量库(Qdrant)→ 用户查询 → 向量检索 + 关键词检索(BM25)→ 融合排序(RRF)→ 拼接上下文 → 生成(GPT-4-turbo)。
四、完整代码实现
4.1 环境准备
# 创建虚拟环境(Python 3.11.7)
python3.11 -m venv rag_env
source rag_env/bin/activate
# 安装依赖
pip install langchain==0.1.0 \
langchain-openai==0.0.5 \
qdrant-client==1.9.0 \
fastapi==0.109.2 \
uvicorn==0.27.0 \
rank-bm25==0.2.2 \
pypdf==4.0.1 \
python-multipart==0.0.6
# 启动向量数据库(Docker方式)
docker run -d -p 6333:6333 -p 6334:6334 \
--name qdrant \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.0
4.2 初始化配置
# config.py
import os
from dotenv import load_dotenv
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_API_BASE = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
EMBEDDING_MODEL = "text-embedding-3-small"
LLM_MODEL = "gpt-4-turbo-2024-04-09"
QDRANT_HOST = "localhost"
QDRANT_PORT = 6333
COLLECTION_NAME = "company_kb"
# 切块参数
CHUNK_SIZE = 512
CHUNK_OVERLAP = 64
# 检索参数
TOP_K_VECTOR = 5
TOP_K_BM25 = 5
RRF_K = 60
4.3 文档加载与切块
切块是RAG效果好坏的关键。我用的LangChain的RecursiveCharacterTextSplitter,它按分隔符优先级递归切分,比固定长度切块更合理。
# chunker.py
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 分隔符优先级从高到低
separators = ["\n\n", "\n", "。", "!", "?", ";", " ", ""]
def load_and_chunk(file_path: str):
if file_path.endswith(".pdf"):
loader = PyPDFLoader(file_path)
else: # .txt 或 .md
loader = TextLoader(file_path, encoding="utf-8")
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=64,
separators=separators,
length_function=len,
)
chunks = splitter.split_documents(docs)
return chunks
4.4 向量化并入库
# vector_store.py
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from openai import OpenAI
client = OpenAI(api_key=OPENAI_API_KEY, base_url=OPENAI_API_BASE)
qdrant = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT)
def get_embedding(text: str, model: str = EMBEDDING_MODEL) -> list[float]:
resp = client.embeddings.create(input=text, model=model)
return resp.data[0].embedding
def create_collection():
# text-embedding-3-small 输出 1536 维
qdrant.recreate_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)
def index_documents(chunks):
create_collection()
points = []
for idx, chunk in enumerate(chunks):
vec = get_embedding(chunk.page_content)
points.append(PointStruct(
id=idx,
vector=vec,
payload={
"text": chunk.page_content,
"source": chunk.metadata.get("source", "unknown"),
"page": chunk.metadata.get("page", 0),
}
))
# 批量写入,减少API调用
if len(points) >= 64:
qdrant.upsert(collection_name=COLLECTION_NAME, points=points)
points = []
if points: # 最后一批
qdrant.upsert(collection_name=COLLECTION_NAME, points=points)
print(f"已索引 {len(chunks)} 个chunk")
4.5 混个检索:向量 + BM25 + RRF融合
只用向量检索会出现一个坑:涉及专有名词或精确代码时,向量检索往往不如传统关键词检索。采用「向量 + BM25」混合检索,用RRF算法融合排序。
# hybrid_search.py
import math
from collections import defaultdict
from rank_bm25 import BM25Okapi
import jieba
from qdrant_client import QdrantClient
qdrant = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT)
def search_vector(query: str) -> list[dict]:
vec = get_embedding(query)
results = qdrant.search(
collection_name=COLLECTION_NAME,
query_vector=vec,
limit=TOP_K_VECTOR,
score_threshold=0.35, # 低于0.35的不要
)
return [{"id": hit.id, "score": hit.score, "text": hit.payload["text"]} for hit in results]
def search_bm25(query: str, all_docs: list[str]) -> list[dict]:
# 中文分词后构建 BM25 索引
tokenized_corpus = [list(jieba.cut(doc)) for doc in all_docs]
bm25 = BM25Okapi(tokenized_corpus)
tokenized_query = list(jieba.cut(query))
scores = bm25.get_scores(tokenized_query)
results = []
for idx, score in enumerate(scores):
if score > 0:
results.append({"id": idx, "score": score, "text": all_docs[idx]})
# 按分数降序、取前5
results.sort(key=lambda x: x["score"], reverse=True)
return results[:TOP_K_BM25]
def rrf_fusion(vector_results: list[dict], bm25_results: list[dict], k: int = 60) -> list[dict]:
"""RRF: RRF(d) = Σ 1/(k + rank_i(d))"""
scores = defaultdict(float)
rank_map = {}
for rank, item in enumerate(vector_results):
scores[item["id"]] += 1.0 / (k + rank + 1)
rank_map[item["id"]] = item["text"]
for rank, item in enumerate(bm25_results):
scores[item["id"]] += 1.0 / (k + rank + 1)
rank_map[item["id"]] = item["text"]
# 融合排序结果
fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [{"id": pid, "score": score, "text": rank_map[pid]} for pid, score in fused]
4.6 完整RAG链路:检索 → 拼接上下文 → 生成
# rag_pipeline.py
from config import *
import json
SYSTEM_PROMPT = """你是企业知识库助手,回答必须基于以下资料片段。
如果资料中没有答案,明确告知「资料中未找到相关信息」,不要编造。
引用来源时要标注[文档名:页码]。"""
def build_context(fused_results: list[dict], max_chars: int = 3000) -> str:
context = ""
for item in fused_results:
text = item["text"].strip()
if len(context) + len(text) > max_chars:
continue
context += text + "\n\n"
return context
def rag_answer(query: str) -> dict:
# 1. 混合检索
vector_results = search_vector(query)
all_docs = [doc["text"] for doc in qdrant.scroll(collection_name=COLLECTION_NAME)[0]]
bm25_results = search_bm25(query, all_docs)
fused_results = rrf_fusion(vector_results, bm25_results)
if not fused_results:
return {"answer": "数据库中未找到相关资料。", "sources": []}
# 2. 拼装上下文
context = build_context(fused_results)
# 3. 调用LLM生成
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"请根据以下资料回答问题:\n\n【资料】\n{context}\n\n【问题】{query}"}
]
resp = client.chat.completions.create(
model=LLM_MODEL,
messages=messages,
temperature=0.1, # 知识库问答用低温,减少幻觉
)
answer = resp.choices[0].message.content
# 4. 返回答案 + 来源
sources = [{"text": item["text"][:100], "score": item["score"]} for item in fused_results[:3]]
return {"answer": answer, "sources": sources}
4.7 API接口:FastAPI 部署成服务
# api.py
from fastapi import FastAPI
from pydantic import BaseModel
import time
from rag_pipeline import rag_answer
app = FastAPI(title="RAG Knowledge Base API")
class QueryRequest(BaseModel):
query: str
session_id: str = "default"
class QueryResponse(BaseModel):
answer: str
sources: list[dict]
latency_ms: int
@app.post("/api/rag", response_model=QueryResponse)
def answer_question(req: QueryRequest):
t0 = time.time()
result = rag_answer(req.query)
latency = int((time.time() - t0) * 1000)
return QueryResponse(
answer=result["answer"],
sources=result["sources"],
latency_ms=latency,
)
@app.get("/health")
def health():
return {"status": "ok"}
五、效果数据:没有对比就没有发言权
测试使用20份内部制度文档(PDF),共356页、约87,000字,切成312个chunk。准备了50个常见问题,人工标注了标准答案和相关文档页码。
5.1 向量 vs 混合检索:准确率对比
| 检索方式 | 命中率(top5含正确答案) | 准确率(top1) | 平均检索耗时 |
|---|---|---|---|
| 纯向量检索 | 82%(41/50) | 58%(29/50) | 210ms |
| 纯BM25关键词 | 64%(32/50) | 36%(18/50) | 42ms |
| 混合检索(RRF) | 96%(48/50) | 78%(39/50) | 255ms |
混合检索在高频问题命中率上优势明显,top5命中率96%,比纯向量多成功7个问题。
5.2 RAG vs 裸GPT-4:回答正确率
| 方式 | 正确率 | 幻觉率 | 平均生成耗时 |
|---|---|---|---|
| 裸GPT-4 | 36%(18/50) | 44%(22/50) | 2.1s |
| GPT-4 + RAG | 86%(43/50) | 8%(4/50) | 2.9s |
加RAG后正确率提升50个百分点,幻觉率骤降至8%。额外耗时主要来自检索和上下文拼接。
5.3 切块大小对效果的影响
| Chunk大小 | 重叠词数 | 检索准确率 | 上下文token消耗 |
|---|---|---|---|
| 256 | 32 | 82% | 约600 tok |
| 512 | 64 | 96% | 约1200 tok |
| 1024 | 128 | 78% | 约2400 tok |
512是最优值。chunk太小上下文信息不完整,太大则引入噪声并烧token。
5.4 响应延迟分析(p95,50次请求)
| 环节 | 平均耗时(ms) | 占比 |
|---|---|---|
| 文档加载(首次) | — | 一次性 |
| Embedding生成 | 580 | 16% |
| 向量检索 | 82 | 2.3% |
| BM25检索 | 38 | 1.1% |
| RRF融合 | 3 | 0.1% |
| LLM生成 | 2780 | 80% |
延迟瓶颈在LLM生成。如果使用4o-mini,生成耗时降至910ms,整体延迟可以控制在2s内。
六、避坑指南(我实际踩过的5个坑)
坑1:openai-python版本兼容性问题
openai库从0.x升级到1.x后接口完全变了,很多老教程用的是openai.Completion.create(),新版本是client.chat.completions.create()。装新版库前务必查好API签名。我在初版用了openai 0.28,代码全废。上面代码基于OpenAI Python SDK v1.x。
坑2:中文文档切块踩「语义腰斩」
直接用LangChain默认的文本切分把中文一句话从中间切开,导致检索时语义缺失。解决办法是设置separators=["\n\n", "\n", "。", "!", "?", ";"],让切块边界落在句子结束后而不是单词中间。同时加64字符重叠,保持上下文连续性。
坑3:Qdrant版本升级带来的API不兼容
Qdrant 1.x版本的recreate_collection签名和0.x不同。1.9.0的写法是vectors_config=VectorParams(size=1536, distance=Distance.COSINE),而0.x版本是vector_size=1536, distance=Distance.COSINE。两个版本API不兼容,报错信息还不明显,排查了很久。
坑4:chunk数量少时,向量检索「高分低能」
当公司只有十几份文档、300来个chunk时,向量检索经常出现高相关度分数但内容错误的情况。这就是低candidate池导致的结果。解决办法是降低score_threshold到0.3,同时引入BM25兜底。
坑5:pdf文本提取质量差
PyPDFLoader对扫描版PDF完全无解——你提取出来的是图片不是文字。如果有扫描件,需要先用OCR预处理(推荐paddleocr,但要下载模型包)。
另外PyPDFLoader对嵌入的表格结构还原很差,表格内容会乱序。建议先把重要表格转成markdown格式的文本文件再入库。
七、什么时候用RAG,什么时候还是去微调?
这是每次分享都会被问到的问题,直接给你判断标准:
- 数据更新频率:按周更新的选RAG,按季度甚至按年更新的才考虑微调
- 数据量:少于5000条文本的用RAG划算,几十万条的海量数据微调更有优势
- 推理延迟:RAG在检索阶段多200ms左右延迟,微调不增加延迟
- 硬件投入:RAG部署只需一台2核4G的机器就能跑,微调最好有4张以上的A100
八、后续优化方向
- Embedding模型从text-embedding-3-small换成bge-m3或在开源模型上微调,中文场景能再提升2%-5%准确率
- 针对用户意图判断增加路由层:输入是简单事实问题走BM25,复杂逻辑问题走混合检索
- 引入重排序模型(bge-reranker-v2-m3)对检索回来的20个chunk精排,效果提升显著但会增加100ms左右延迟
代码你已经拿到了,回去跑通它,用自己的数据试一遍,上手会比看十篇概念文章都快。