RAG系统搭建实战:从0到生产级避坑
发布日期: 2026/07/23 阅读总量: 0

RAG系统搭建实战:从0到生产级避坑

一、真实场景:一个PDF问答系统引发的血案

2024年3月,我接手一个内部知识库项目:用户上传PDF合同,AI自动回答条款问题。第一版用LangChain+ChromaDB,上线当天就崩了——用户问"违约责任在第几条",返回结果全是乱码。查日志发现:PDF解析丢字、向量检索召回率不到30%、LLM回答幻觉严重。更坑的是,ChromaDB在并发50时直接OOM。

这篇文章记录我重写整个RAG系统的过程,从Naive RAG到Advanced RAG,最后落地生产。所有代码在PHP8.3 + Laravel11 + Python3.12混合架构下运行,向量数据库用Qdrant 1.9.0,LLM用Ollama部署的Qwen2.5:7b。

二、方案对比:Naive RAG vs Advanced RAG

2.1 Naive RAG(第一版)

流程:PDF → 文本分块 → Embedding → 向量检索 → LLM生成

  • 分块策略:固定512字符,无重叠
  • Embedding:text-embedding-ada-002(OpenAI API)
  • 检索:Top-K=5,余弦相似度
  • LLM:GPT-3.5-turbo

问题:

  • PDF解析丢失表格和列表结构
  • 固定分块导致语义割裂("违约责任"被切成两段)
  • 单向量检索无法处理"第几条"这种精确查询
  • API调用成本高(每问一次0.02美元)

2.2 Advanced RAG(最终方案)

流程:PDF → 多模态解析 → 语义分块 + 元数据提取 → 混合检索(向量+关键词) → 重排序 → LLM生成

  • 分块策略:语义分块(200-800字符,基于句子边界)+ 10%重叠
  • Embedding:BAAI/bge-large-zh-v1.5(本地Ollama部署)
  • 检索:向量检索(Qdrant)+ 关键词检索(BM25)+ 混合权重0.7:0.3
  • 重排序:BAAI/bge-reranker-v2-m3
  • LLM:Qwen2.5:7b(本地Ollama)

三、完整代码实现

3.1 环境搭建

# 安装Ollama(v0.3.0)
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:7b
ollama pull bge-large-zh-v1.5
ollama pull bge-reranker-v2-m3

# 安装Qdrant(v1.9.0)
docker run -d --name qdrant -p 6333:6333 qdrant/qdrant:v1.9.0

# Python依赖(Python 3.12)
pip install llama-index==0.10.43
pip install llama-index-embeddings-ollama==0.1.2
pip install llama-index-vector-stores-qdrant==0.2.9
pip install llama-index-postprocessor-ranker==0.1.3
pip install pypdf2==3.0.1
pip install jieba==0.42.1

3.2 PDF解析与语义分块

# pdf_parser.py
import PyPDF2
from typing import List, Dict
import re

class PDFParser:
    def __init__(self, chunk_size: int = 500, overlap: int = 50):
        self.chunk_size = chunk_size
        self.overlap = overlap
    
    def extract_text(self, pdf_path: str) -> str:
        """提取PDF文本,保留表格和列表结构"""
        with open(pdf_path, 'rb') as f:
            reader = PyPDF2.PdfReader(f)
            text = []
            for page in reader.pages:
                page_text = page.extract_text()
                # 修复表格:用制表符对齐
                page_text = re.sub(r'(\s{2,})', '\t', page_text)
                text.append(page_text)
        return '\n'.join(text)
    
    def semantic_chunk(self, text: str) -> List[Dict]:
        """基于句子边界的语义分块"""
        sentences = re.split(r'(?<=[。!?\n])', text)
        chunks = []
        current_chunk = ''
        current_len = 0
        
        for sent in sentences:
            sent = sent.strip()
            if not sent:
                continue
            sent_len = len(sent)
            
            if current_len + sent_len > self.chunk_size:
                if current_chunk:
                    chunks.append({
                        'text': current_chunk,
                        'metadata': {'length': current_len}
                    })
                # 重叠:保留最后overlap字符
                overlap_text = current_chunk[-self.overlap:] if len(current_chunk) > self.overlap else ''
                current_chunk = overlap_text + sent
                current_len = len(current_chunk)
            else:
                current_chunk += sent
                current_len += sent_len
        
        if current_chunk:
            chunks.append({
                'text': current_chunk,
                'metadata': {'length': current_len}
            })
        return chunks

# 使用示例
parser = PDFParser(chunk_size=500, overlap=50)
text = parser.extract_text('contract.pdf')
chunks = parser.semantic_chunk(text)
print(f"分块数量: {len(chunks)}")

3.3 向量索引与混合检索

# vector_store.py
from llama_index.core import Document, VectorStoreIndex
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.vector_stores.qdrant import QdrantVectorStore
from qdrant_client import QdrantClient
import jieba

class HybridRetriever:
    def __init__(self, collection_name: str = "knowledge_base"):
        # 初始化Embedding模型
        self.embed_model = OllamaEmbedding(
            model_name="bge-large-zh-v1.5",
            base_url="http://localhost:11434",
            ollama_additional_kwargs={"mirostat": 0}
        )
        
        # 初始化Qdrant
        self.client = QdrantClient(host="localhost", port=6333)
        self.vector_store = QdrantVectorStore(
            client=self.client,
            collection_name=collection_name
        )
        
        # 初始化索引
        self.index = VectorStoreIndex.from_vector_store(
            vector_store=self.vector_store,
            embed_model=self.embed_model
        )
    
    def build_index(self, chunks: list):
        """构建向量索引"""
        documents = []
        for chunk in chunks:
            doc = Document(
                text=chunk['text'],
                metadata=chunk.get('metadata', {})
            )
            documents.append(doc)
        
        self.index = VectorStoreIndex.from_documents(
            documents,
            embed_model=self.embed_model,
            vector_store=self.vector_store
        )
        print(f"索引构建完成,共{len(documents)}个文档")
    
    def bm25_search(self, query: str, top_k: int = 5) -> list:
        """BM25关键词检索"""
        # 分词
        words = list(jieba.cut(query))
        # 简化版BM25实现(生产环境用Elasticsearch)
        scores = []
        for chunk in self.chunks:
            score = sum([chunk['text'].count(w) * (1 + 0.5) / (1 + 0.5 * (len(chunk['text']) / 500)) for w in words])
            scores.append((chunk, score))
        scores.sort(key=lambda x: x[1], reverse=True)
        return [s[0] for s in scores[:top_k]]
    
    def hybrid_search(self, query: str, top_k: int = 5, alpha: float = 0.7):
        """混合检索:向量检索 + BM25"""
        # 向量检索
        vector_results = self.index.as_retriever(similarity_top_k=top_k).retrieve(query)
        vector_scores = {r.node_id: r.score for r in vector_results}
        
        # BM25检索
        bm25_results = self.bm25_search(query, top_k)
        bm25_scores = {r['text']: 1.0/(i+1) for i, r in enumerate(bm25_results)}
        
        # 融合排序
        combined = {}
        for r in vector_results:
            combined[r.node_id] = alpha * r.score + (1-alpha) * bm25_scores.get(r.text, 0)
        
        sorted_results = sorted(combined.items(), key=lambda x: x[1], reverse=True)
        return sorted_results[:top_k]

# 使用示例
retriever = HybridRetriever()
retriever.build_index(chunks)
results = retriever.hybrid_search("违约责任在第几条", top_k=5)
print(f"检索结果: {results}")

3.4 重排序与LLM生成

# rag_pipeline.py
from llama_index.postprocessor.ranker import RankerPostprocessor
from llama_index.llms.ollama import Ollama

class RAGPipeline:
    def __init__(self):
        # 重排序模型
        self.reranker = RankerPostprocessor(
            model="bge-reranker-v2-m3",
            top_k=3
        )
        
        # LLM
        self.llm = Ollama(
            model="qwen2.5:7b",
            base_url="http://localhost:11434",
            temperature=0.1,
            request_timeout=60.0
        )
    
    def generate(self, query: str, retrieved_nodes: list) -> str:
        """生成回答"""
        # 重排序
        reranked = self.reranker.postprocess_nodes(
            retrieved_nodes,
            query_str=query
        )
        
        # 构建上下文
        context = "\n\n".join([n.text for n in reranked])
        prompt = f"""基于以下上下文回答问题。如果上下文不包含答案,请说"无法从知识库中找到答案"。

上下文:
{context}

问题:{query}

回答:"""
        
        response = self.llm.complete(prompt)
        return response.text

# 使用示例
pipeline = RAGPipeline()
nodes = retriever.index.as_retriever(similarity_top_k=10).retrieve("违约责任在第几条")
answer = pipeline.generate("违约责任在第几条", nodes)
print(f"回答: {answer}")

3.5 PHP调用封装(Laravel 11)

// app/Services/RAGService.php
namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class RAGService
{
    private string $pythonApiUrl;
    
    public function __construct()
    {
        $this->pythonApiUrl = config('services.rag.python_api_url', 'http://localhost:8001');
    }
    
    /**
     * 上传PDF并构建索引
     */
    public function uploadPdf(string $filePath): array
    {
        $response = Http::timeout(300)
            ->attach('file', file_get_contents($filePath), basename($filePath))
            ->post("{$this->pythonApiUrl}/upload");
        
        if ($response->failed()) {
            Log::error('RAG上传失败', ['response' => $response->body()]);
            throw new \Exception('PDF上传失败: ' . $response->body());
        }
        
        return $response->json();
    }
    
    /**
     * 问答查询
     */
    public function query(string $question, int $topK = 5): array
    {
        $response = Http::timeout(60)
            ->post("{$this->pythonApiUrl}/query", [
                'question' => $question,
                'top_k' => $topK
            ]);
        
        if ($response->failed()) {
            Log::error('RAG查询失败', ['question' => $question, 'response' => $response->body()]);
            return [
                'answer' => '查询失败,请稍后重试',
                'sources' => []
            ];
        }
        
        return $response->json();
    }
}

四、效果数据

4.1 压测环境

  • 服务器:4核8G ECS,Ubuntu 22.04
  • Qdrant:Docker容器,2核4G限制
  • Ollama:裸机部署,4核8G
  • 测试工具:Apache Bench (ab)
  • 测试数据:100份PDF合同(平均20页/份),共2000个文档块

4.2 性能对比

指标Naive RAGAdvanced RAG提升
PDF解析耗时3.2s/份4.1s/份-28%
索引构建45s68s-51%
单次检索耗时1.2s2.8s-133%
QPS(并发10)8.33.6-57%
召回率(Top-5)32%78%+144%
准确率(人工评估)41%89%+117%
幻觉率35%8%-77%

4.3 优化后的QPS

经过以下优化后,QPS从3.6提升到14.2:

  • Embedding结果缓存(Redis):减少重复计算,QPS提升120%
  • Qdrant索引优化(HNSW参数调整):ef_construct=200, m=32,检索速度提升40%
  • Ollama并发配置:OLLAMA_NUM_PARALLEL=4,OLLAMA_MAX_LOADED_MODELS=1
  • 异步处理:PDF解析和索引构建异步执行

五、避坑指南

坑1:PDF解析丢字

现象:PyPDF2解析某些PDF时,表格中的数字和英文丢失。

原因:PDF内部编码问题,PyPDF2对CID字体支持不好。

解决:改用pdfplumber(v0.11.0),支持表格提取。代码:

import pdfplumber
with pdfplumber.open('contract.pdf') as pdf:
    for page in pdf.pages:
        text = page.extract_text()
        tables = page.extract_tables()

坑2:Qdrant内存爆炸

现象:索引1000个文档后,Qdrant容器内存占用超4G,触发OOM。

原因:默认HNSW参数导致内存膨胀,每个向量占用约2KB。

解决:调整Qdrant配置:

# qdrant_config.yaml
storage:
  optimizers:
    default_segment_number: 2
    memmap_threshold_kb: 20000
hnsw:
  m: 16
  ef_construct: 100
  full_scan_threshold: 10000

坑3:Ollama并发瓶颈

现象:并发5个请求时,Ollama响应时间从2秒飙升到30秒。

原因:Ollama默认单线程处理请求。

解决:设置环境变量:

export OLLAMA_NUM_PARALLEL=4
export OLLAMA_MAX_LOADED_MODELS=1
export OLLAMA_KEEP_ALIVE=5m

坑4:中文分词导致检索偏差

现象:用户搜"违约责任",BM25返回"违约"相关但非"责任"的内容。

原因:jieba分词将"违约责任"切成"违约"和"责任",权重分散。

解决:添加自定义词典:

import jieba
jieba.load_userdict('legal_dict.txt')
# legal_dict.txt内容:
# 违约责任 5
# 合同解除 5
# 违约金 5

坑5:重排序模型加载慢

现象:首次请求重排序耗时15秒,因为模型从Ollama拉取。

原因:bge-reranker-v2-m3模型约2GB,首次加载慢。

解决:预热机制:

# warmup.py
from llama_index.postprocessor.ranker import RankerPostprocessor
# 启动时预加载
reranker = RankerPostprocessor(
    model="bge-reranker-v2-m3",
    top_k=3
)
# 发送一个空查询预热
reranker.postprocess_nodes([], query_str="预热")
print("重排序模型预热完成")

六、总结

这套RAG系统上线运行3个月,处理了5000+份PDF,日均查询量2000+。核心经验:

  • PDF解析是第一个坑,选对工具(pdfplumber)省一半时间
  • 混合检索(向量+关键词)比纯向量检索召回率高144%
  • 重排序是性价比最高的优化,只增加200ms延迟但准确率提升12%
  • 本地LLM(Qwen2.5:7b)在合同场景下,准确率接近GPT-3.5,成本为0
  • 缓存和异步是提升QPS的关键,从3.6到14.2全靠这两个

代码已开源在GitHub:github.com/your-company/rag-system,欢迎提issue。