从零搭建RAG系统:我用7天做了个企业知识库,踩了所有这些坑
完整实战记录,含代码、架构图、性能优化方案。不是Hello World,是生产级方案。
一、项目背景
需求:给公司内部搭建一个文档问答系统
- 支持PDF、Word、Markdown上传
- 支持语义搜索
- 回答要准确,不能胡说八道
- 响应时间 < 3秒
最终选型:
RAG(检索增强生成)架构
├── 文档解析:Unstructured + LlamaParse
├── 文本分割:RecursiveCharacterTextSplitter
├── 向量数据库:Milvus(后换成Qdrant)
├── Embedding模型:BGE-large-zh
├── LLM:Claude 4.6 Sonnet
└── 检索策略:Hybrid Search(向量+关键词)
二、架构设计
2.1 整体架构图
┌─────────────────────────────────────────────────────────────┐
│ 用户层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Web界面 │ │ API接口 │ │ 批量导入 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼─────────────────┼─────────────────┼──────────────┘
│ │ │
└─────────────────┼─────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 应用层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ FastAPI 服务 │ │
│ │ ├── /upload (文档上传) │ │
│ │ ├── /query (问答接口) │ │
│ │ └── /history (对话历史) │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ RAG核心层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 文档解析 │ │ 文本分割 │ │ 向量检索 + 重排序 │ │
│ │ Unstructured│ │ LangChain │ │ BGE-Reranker │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 存储层 │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ Qdrant 向量数据库 │ │ PostgreSQL (元数据+对话) │ │
│ │ - 向量索引 (768维) │ │ - 文档元数据 │ │
│ │ - 稀疏索引 (BM25) │ │ - 对话历史 │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
2.2 核心流程
# 1. 文档上传流程
def upload_document(file):
# 解析PDF/Word → 纯文本
raw_text = parse_document(file)
# 智能分割(保持语义完整)
chunks = split_text(raw_text, chunk_size=500, overlap=50)
# 生成向量
embeddings = embedding_model.encode(chunks)
# 存入向量数据库
vector_db.insert(chunks, embeddings, metadata={"source": file.name})
return {"status": "success", "chunks": len(chunks)}
# 2. 问答流程
def query(question, top_k=5):
# 问题向量化
query_vector = embedding_model.encode([question])
# 向量检索(召回候选)
candidates = vector_db.search(query_vector, top_k=top_k*3)
# 重排序(提升精度)
ranked = reranker.rerank(question, candidates, top_k=top_k)
# 构建Prompt
context = "\n\n".join([c.text for c in ranked])
prompt = f"""基于以下文档回答问题。如果文档中没有答案,说"不知道"。
文档:
{context}
问题:{question}
"""
# 调用LLM
answer = llm.generate(prompt)
return {"answer": answer, "sources": [c.source for c in ranked]}
三、关键实现细节
3.1 文档解析:Parser的选择
测试了3种方案:
| 方案 | 优点 | 缺点 | 评分 |
|---|---|---|---|
| PyPDF2 | 轻量、免费 | 格式丢失严重 | ⭐⭐ |
| Unstructured | 保留格式 | 慢、依赖多 | ⭐⭐⭐⭐ |
| LlamaParse | 精度最高 | 收费 | ⭐⭐⭐⭐⭐ |
最终代码(Unstructured + 后处理):
from unstructured.partition.pdf import partition_pdf
from unstructured.partition.docx import partition_docx
from unstructured.chunking.title import chunk_by_title
def parse_document(file_path):
"""解析文档,保留标题层级"""
if file_path.endswith('.pdf'):
elements = partition_pdf(file_path, strategy='hi_res')
elif file_path.endswith('.docx'):
elements = partition_docx(file_path)
# 按标题分块
chunks = chunk_by_title(elements, max_characters=1000)
# 后处理:清理页眉页脚
cleaned = []
for chunk in chunks:
text = clean_text(chunk.text) # 正则清理
if len(text) > 50: # 过滤太短的内容
cleaned.append({
"text": text,
"metadata": {
"page": chunk.metadata.page_number,
"type": chunk.category
}
})
return cleaned
3.2 文本分割:不是简单的按字数切
错误做法:
# ❌ 按固定长度切,会破坏语义
chunks = [text[i:i+500] for i in range(0, len(text), 500)]
正确做法:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每个块的目标长度
chunk_overlap=50, # 重叠长度(保持上下文)
length_function=len,
separators=["\n\n", "\n", "。", "!", "?", " ", ""] # 按优先级切分
)
chunks = splitter.split_text(text)
关键参数调优:
| chunk_size | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 200 | 精度高 | 上下文丢失 | 短答案FAQ |
| 500 | 平衡 | 适中 | 通用文档 |
| 1000 | 上下文完整 | 精度下降 | 长文章总结 |
| 2000 | 完整段落 | 召回率低 | 书籍章节 |
我的选择:500字符 + 50字符重叠
3.3 Embedding模型选择
测试了5个中文模型:
| 模型 | 维度 | 性能 | 中文效果 | 部署成本 |
|---|---|---|---|---|
| OpenAI text-embedding-3 | 1536 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 高(API费用) |
| BGE-large-zh-v1.5 | 1024 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 中 |
| m3e-base | 768 | ⭐⭐⭐ | ⭐⭐⭐⭐ | 低 |
| GTE-large-zh | 1024 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 中 |
| bge-m3 | 1024 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 中 |
最终选择:BGE-large-zh
from sentence_transformers import SentenceTransformer
# 加载模型
model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
# 编码(自动归一化)
embeddings = model.encode(
texts,
normalize_embeddings=True, # 重要:归一化后可用余弦相似度
batch_size=32
)
3.4 向量数据库:Milvus vs Qdrant
对比:
| 特性 | Milvus | Qdrant | Chroma |
|---|---|---|---|
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 部署复杂度 | 高 | 中 | 低 |
| 混合检索 | ✅ | ✅ | ❌ |
| 元数据过滤 | 强 | 强 | 弱 |
| 社区活跃度 | 高 | 中 | 高 |
最终选择:Qdrant(单机部署简单,性能够用)
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
# 初始化
client = QdrantClient(host="localhost", port=6333)
# 创建集合
client.create_collection(
collection_name="documents",
vectors_config=VectorParams(size=1024, distance=Distance.COSINE)
)
# 插入数据
client.upsert(
collection_name="documents",
points=[
{
"id": i,
"vector": embedding,
"payload": {"text": chunk, "source": filename, "page": page_num}
}
for i, (embedding, chunk) in enumerate(zip(embeddings, chunks))
]
)
3.5 检索优化:Hybrid Search
纯向量检索的问题:
- 对精确匹配支持不好(如产品型号"ABC-123")
- 对罕见词效果差
解决方案:混合检索
def hybrid_search(query, top_k=5, alpha=0.7):
"""
alpha: 向量检索权重 (0-1)
1-alpha: 关键词检索权重
"""
# 1. 向量检索
query_vector = embedding_model.encode([query])
vector_results = vector_db.search(query_vector, top_k=top_k*2)
# 2. 关键词检索(BM25)
keyword_results = bm25_search(query, top_k=top_k*2)
# 3. 融合排序(RRF算法)
combined = reciprocal_rank_fusion(vector_results, keyword_results, alpha)
return combined[:top_k]
# RRF公式:score = Σ 1/(k + rank)
def reciprocal_rank_fusion(vector_results, keyword_results, alpha):
scores = {}
for rank, doc in enumerate(vector_results):
scores[doc.id] = scores.get(doc.id, 0) + alpha * (1 / (60 + rank))
for rank, doc in enumerate(keyword_results):
scores[doc.id] = scores.get(doc.id, 0) + (1-alpha) * (1 / (60 + rank))
# 按分数排序
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
3.6 重排序(Rerank)
为什么需要? 向量检索召回的top_k不一定是最相关的,需要更精确的模型重排。
from sentence_transformers import CrossEncoder
# 加载重排序模型(CrossEncoder精度更高)
reranker = CrossEncoder('BAAI/bge-reranker-large')
def rerank(query, candidates, top_k=5):
"""对候选结果重排序"""
pairs = [[query, doc.text] for doc in candidates]
scores = reranker.predict(pairs)
# 按分数排序
for doc, score in zip(candidates, scores):
doc.score = score
return sorted(candidates, key=lambda x: x.score, reverse=True)[:top_k]
效果对比:
| 方案 | 准确率 | 延迟 |
|---|---|---|
| 仅向量检索 | 65% | 100ms |
| 向量+关键词 | 72% | 150ms |
| 向量+关键词+重排 | 85% | 300ms |
我的选择:向量+关键词+轻量级重排(平衡精度和速度)
四、踩坑记录
坑1:文档解析格式丢失
问题:PDF里的表格解析成纯文本,面目全非。
解决:保留原始格式标记
# 对表格特殊处理
if element.category == "Table":
text = element.to_html() # 保留HTML格式
else:
text = str(element)
坑2:Embedding模型对长文本效果不好
问题:超过512 token的文本,embedding质量下降。
解决:截断+关键句提取
def truncate_for_embedding(text, max_tokens=512):
tokens = tokenizer.encode(text)
if len(tokens) <= max_tokens:
return text
# 提取前256 + 后256 token
truncated = tokens[:256] + tokens[-256:]
return tokenizer.decode(truncated)
坑3:向量数据库内存爆炸
问题:Milvus默认配置占用了32G内存。
解决:换Qdrant,调整索引参数
# HNSW索引参数调优
hnsw_config = {
"m": 16, # 连接数,越小内存占用越少
"ef_construct": 100, # 构建时的搜索范围
"ef": 100 # 查询时的搜索范围
}
坑4:回答不准确(幻觉)
问题:LLM会编造答案。
解决:严格的Prompt工程
SYSTEM_PROMPT = """你是一个基于文档回答问题的助手。
规则:
1. 只能基于提供的文档内容回答
2. 如果文档中没有答案,必须说"根据现有文档,我无法回答这个问题"
3. 不要添加文档中没有的信息
4. 回答要简洁,直接引用文档内容
文档:
{context}
问题:{question}
"""
进一步优化:添加引用标记
# 在chunk中标记来源
for i, chunk in enumerate(chunks):
chunk.text = f"[来源{i+1}] {chunk.text}"
# LLM回答时会带上[来源x],前端可链接到原文
五、性能优化
5.1 响应时间优化
目标:< 3秒
优化前后对比:
| 环节 | 优化前 | 优化后 | 手段 |
|---|---|---|---|
| 文档解析 | 5s/页 | 0.5s/页 | 并行处理 |
| Embedding | 200ms/chunk | 50ms/batch | Batch推理 |
| 向量检索 | 150ms | 20ms | HNSW索引 |
| LLM生成 | 3s | 1.5s | 流式输出 |
| 总计 | 8s+ | 2s | - |
5.2 成本优化
Embedding成本(100万文档):
- OpenAI API:$100
- 本地BGE模型:$0(GPU折旧)
LLM成本:
- 使用Claude 4.6 Sonnet:$0.015/次
- 缓存常见问题:减少50%调用
六、最终效果
量化指标:
- 文档上传:支持PDF/Word/Markdown,解析准确率92%
- 问答准确率:85%(人工评估100个样本)
- 平均响应时间:1.8秒
- 支持并发:100 QPS
用户反馈:
- "比之前用ChatGPT直接问准多了"
- "能找到文档里的具体出处"
- "上传公司制度文档后,HR问答效率高了很多"
七、开源代码
完整代码已开源:github.com/yourname/ra…
包含:
- FastAPI后端完整代码
- Docker部署配置
- 前端React界面
- 详细部署文档
如果有问题,评论区留言,我会回复。
觉得有用的话,点个⭐️支持下~