# 从零搭建RAG系统:我用7天做了个企业知识库,踩了所有这些坑

0 阅读7分钟

从零搭建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-31536⭐⭐⭐⭐⭐⭐⭐⭐高(API费用)
BGE-large-zh-v1.51024⭐⭐⭐⭐⭐⭐⭐⭐⭐
m3e-base768⭐⭐⭐⭐⭐⭐⭐
GTE-large-zh1024⭐⭐⭐⭐⭐⭐⭐⭐
bge-m31024⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

最终选择: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

对比

特性MilvusQdrantChroma
性能⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
部署复杂度
混合检索
元数据过滤
社区活跃度

最终选择: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/页并行处理
Embedding200ms/chunk50ms/batchBatch推理
向量检索150ms20msHNSW索引
LLM生成3s1.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界面
  • 详细部署文档

如果有问题,评论区留言,我会回复。

觉得有用的话,点个⭐️支持下~