RAG 工程实践:从Demo到生产,你的知识库系统差在哪?

一个普遍的问题

RAG几乎是每个AI项目的标配:企业知识库、智能客服、文档问答……都要用RAG。

但大部分人做的RAG是这样的:

# Demo级别的RAG
docs = load_documents("知识库.pdf")
chunks = split_documents(docs, chunk_size=500)
embeddings = embed(chunks)
vector_store.add(chunks, embeddings)

def query(question):
    relevant_chunks = vector_store.search(question, top_k=3)
    prompt = f"根据以下信息回答问题:\n{relevant_chunks}\n\n问题:{question}"
    return llm.generate(prompt)

这个Demo能跑,但上不了生产。

为什么?因为真实场景比Demo复杂得多:

  • 文档格式混乱(PDF、Word、PPT、网页混合)
  • 知识库有上万条数据
  • 用户问题很模糊("帮我查一下那个东西")
  • 检索回来的内容不相关
  • 模型回答不准确

Demo到生产,差的是工程细节。


RAG的5个关键问题

问题1:文档解析——脏数据进,脏数据出

RAG的第一步是把文档转成文本。但真实世界的文档很脏:

PDF的问题:

  • 表格被解析成乱码
  • 图片里的文字丢失
  • 多栏排版被错误拼接
  • 页眉页脚混入正文

Word/PPT的问题:

  • 样式丢失(标题、列表、表格)
  • 嵌入的图片无法提取
  • 宏和复杂格式无法解析

网页的问题:

  • 导航栏、广告、页脚混入正文
  • 动态内容(JavaScript渲染)抓不到
  • 编码问题(乱码)

错误做法:

# 直接用pdfplumber解析,表格会变成乱码
text = pdfplumber.extract_text("document.pdf")

正确做法:

# 根据文档类型选择合适的解析器
def parse_document(file_path: str) -> str:
    if file_path.endswith(".pdf"):
        # PDF用专业解析器,支持表格识别
        return parse_pdf_with_table_detection(file_path)
    elif file_path.endswith(".docx"):
        # Word用python-docx,保留结构信息
        return parse_docx_with_structure(file_path)
    elif file_path.startswith("http"):
        # 网页用Readability算法提取正文
        return parse_webpage_with_readability(file_path)
    else:
        raise ValueError(f"不支持的格式: {file_path}")

def parse_pdf_with_table_detection(pdf_path: str) -> str:
    """PDF解析:支持表格识别"""
    import pdfplumber
    
    text_parts = []
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            # 提取表格
            tables = page.extract_tables()
            for table in tables:
                # 把表格转成Markdown格式
                table_md = convert_table_to_markdown(table)
                text_parts.append(table_md)
            
            # 提取文本(排除表格区域)
            text = page.extract_text(x_tolerance=3, y_tolerance=3)
            text_parts.append(text)
    
    return "\n\n".join(text_parts)

经验: 文档解析质量决定了RAG的上限。花时间在解析上,比调模型更有效。


问题2:文档切分——切错了,检索就废了

RAG的核心是"检索相关片段"。但如果切分策略不对,检索回来的内容是不完整的。

错误做法:

# 固定500字切分,一个句子可能被切断
chunks = split_by_fixed_size(text, chunk_size=500)

问题:

  • 一个完整的段落被切成两半
  • 表格被切断
  • 上下文丢失("这个产品"指的是什么?)

正确做法:

# 按语义切分:先按段落,再按句子,最后按字数限制
def split_by_semantic(text: str, max_chunk_size: int = 500) -> list[str]:
    """按语义切分文档"""
    
    # 第一步:按段落切分
    paragraphs = text.split("\n\n")
    
    chunks = []
    current_chunk = []
    current_size = 0
    
    for para in paragraphs:
        para_size = len(para)
        
        # 如果单个段落超过限制,按句子切分
        if para_size > max_chunk_size:
            sentences = split_by_sentences(para)
            for sent in sentences:
                if current_size + len(sent) > max_chunk_size:
                    chunks.append(" ".join(current_chunk))
                    current_chunk = [sent]
                    current_size = len(sent)
                else:
                    current_chunk.append(sent)
                    current_size += len(sent)
        else:
            if current_size + para_size > max_chunk_size:
                chunks.append(" ".join(current_chunk))
                current_chunk = [para]
                current_size = para_size
            else:
                current_chunk.append(para)
                current_size += para_size
    
    if current_chunk:
        chunks.append(" ".join(current_chunk))
    
    return chunks

def split_by_sentences(text: str) -> list[str]:
    """按句子切分,保留标点"""
    import re
    # 匹配中文句子结尾
    sentences = re.split(r'(?<=[。!?.!?])\s+', text)
    return [s.strip() for s in sentences if s.strip()]

进阶:重叠切分

为了防止上下文丢失,切分时让相邻chunk有重叠:

def split_with_overlap(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    """带重叠的切分,保留上下文"""
    chunks = []
    start = 0
    
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)
        start = end - overlap  # 下一个chunk从当前chunk的末尾回退50字开始
    
    return chunks

经验: 切分策略要根据文档类型调整。表格、代码、对话记录都有特殊的切分方式。


问题3:向量化——Embedding模型选错了,检索就不准

RAG的检索依赖向量相似度。但不同的Embedding模型,效果差距很大。

错误做法:

# 随便选一个Embedding模型
embeddings = openai.embed("text-embedding-ada-002")

问题:

  • 英文模型对中文效果差
  • 通用模型对专业领域效果差
  • 没有针对你的数据微调

正确做法:

1. 选择合适的Embedding模型

# 中文场景推荐的Embedding模型
EMBEDDING_MODELS = {
    "bge-large-zh": "智源BGE,中文效果最好",
    "m3e-base": "MokaAI,中文效果好,开源",
    "text2vec-large-chinese": "shibing624,开源,中文优化",
    "openai-text-embedding-3": "OpenAI,多语言,效果好但贵"
}

def choose_embedding_model(language: str = "zh", domain: str = "general") -> str:
    """根据语言和领域选择Embedding模型"""
    if language == "zh":
        if domain == "legal":
            return "bge-large-zh"  # 法律领域测试效果好
        elif domain == "medical":
            return "m3e-base"  # 医疗领域测试效果好
        else:
            return "bge-large-zh"  # 通用中文
    else:
        return "openai-text-embedding-3"

2. 评估Embedding效果

def evaluate_embedding(test_questions: list[dict], embedding_model: str) -> dict:
    """评估Embedding模型的检索效果"""
    
    correct = 0
    total = len(test_questions)
    
    for item in test_questions:
        question = item["question"]
        expected_doc = item["expected_doc"]
        
        # 检索
        question_embedding = embed(question, model=embedding_model)
        retrieved_docs = vector_store.search(question_embedding, top_k=5)
        
        # 检查是否检索到正确文档
        if expected_doc in retrieved_docs:
            correct += 1
    
    accuracy = correct / total
    return {"accuracy": accuracy, "model": embedding_model}

经验: Embedding模型对最终效果影响很大。花时间测试不同模型,找到最适合你数据的。


问题4:检索策略——只靠向量相似度不够

RAG的检索不能只用向量相似度。因为:

  • 用户问题很模糊("那个东西怎么弄")
  • 关键词匹配有时候比语义匹配更准
  • 需要结合元数据过滤(时间、作者、文档类型)

错误做法:

# 只用向量相似度检索
results = vector_store.search(query_embedding, top_k=3)

正确做法:混合检索(Hybrid Search)

def hybrid_search(query: str, top_k: int = 5) -> list[dict]:
    """混合检索:向量检索 + BM25关键词检索"""
    
    # 1. 向量检索(语义相似度)
    query_embedding = embed(query)
    vector_results = vector_store.search(query_embedding, top_k=top_k * 2)
    
    # 2. BM25检索(关键词匹配)
    bm25_results = bm25_index.search(query, top_k=top_k * 2)
    
    # 3. 融合排序(Reciprocal Rank Fusion)
    fused_scores = {}
    
    for rank, doc in enumerate(vector_results):
        doc_id = doc["id"]
        fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1.0 / (rank + 1)
    
    for rank, doc in enumerate(bm25_results):
        doc_id = doc["id"]
        fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1.0 / (rank + 1)
    
    # 4. 按融合分数排序
    sorted_docs = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
    
    return [get_doc(doc_id) for doc_id, score in sorted_docs[:top_k]]

进阶:元数据过滤

def search_with_metadata(query: str, filters: dict, top_k: int = 5) -> list[dict]:
    """带元数据过滤的检索"""
    
    query_embedding = embed(query)
    
    # 向量数据库支持元数据过滤
    results = vector_store.search(
        embedding=query_embedding,
        filter={
            "document_type": filters.get("doc_type"),  # 只检索PDF
            "date": {"$gte": "2024-01-01"},  # 只检索2024年后的文档
            "author": filters.get("author")  # 只检索特定作者
        },
        top_k=top_k
    )
    
    return results

经验: 混合检索比纯向量检索效果好很多,尤其是用户问题很模糊的时候。


问题5:生成质量——检索到了,但模型回答不准

RAG的最后一步是"根据检索到的内容生成回答"。但模型可能:

  • 忽略检索到的内容,自己瞎编
  • 检索到的内容太多,模型处理不过来
  • 检索到的内容互相矛盾

错误做法:

# 直接把检索结果拼到prompt里
prompt = f"根据以下信息回答问题:\n{retrieved_chunks}\n\n问题:{question}"

正确做法:

1. 压缩检索结果

def compress_context(chunks: list[str], max_tokens: int = 2000) -> str:
    """压缩检索结果,避免超过模型上下文限制"""
    
    # 按相关性排序(第一个最相关)
    # 逐步添加chunk,直到达到token限制
    compressed = []
    current_tokens = 0
    
    for chunk in chunks:
        chunk_tokens = count_tokens(chunk)
        if current_tokens + chunk_tokens > max_tokens:
            break
        compressed.append(chunk)
        current_tokens += chunk_tokens
    
    return "\n\n".join(compressed)

2. 引用来源

def generate_with_citation(question: str, chunks: list[dict]) -> str:
    """生成回答时引用来源"""
    
    # 给每个chunk编号
    context_with_ids = "\n\n".join(
        f"[来源{i+1}] {chunk['content']}"
        for i, chunk in enumerate(chunks)
    )
    
    prompt = f"""根据以下信息回答问题。如果信息不足以回答,请说"信息不足"。
回答时请引用来源编号,如[来源1]。

信息:
{context_with_ids}

问题:{question}

回答:"""
    
    response = llm.generate(prompt)
    return response

3. 处理矛盾信息

def handle_contradiction(question: str, chunks: list[dict]) -> str:
    """处理矛盾信息"""
    
    # 先让模型判断是否有矛盾
    check_prompt = f"""以下信息是否有矛盾?如果有,指出矛盾点。

信息:
{chunks}

是否有矛盾?"""
    
    contradiction_check = llm.generate(check_prompt)
    
    if "有矛盾" in contradiction_check:
        # 有矛盾,让模型说明
        prompt = f"""以下信息有矛盾,请指出不同观点:

{chunks}

问题:{question}

请列出不同观点,并说明来源。"""
    else:
        # 无矛盾,正常回答
        prompt = f"""根据以下信息回答问题:

{chunks}

问题:{question}"""
    
    return llm.generate(prompt)

经验: 生成质量的关键是prompt设计。明确告诉模型"根据检索到的内容回答",并要求引用来源。


生产级RAG系统架构

把上面的优化整合起来,一个生产级RAG系统的架构:

[文档接入][文档解析](PDF/Word/网页,保留结构)
    ↓
[语义切分](按段落/句子,带重叠)
    ↓
[向量化](选择合适的Embedding模型)
    ↓
[存储](向量数据库 + 元数据)
    ↓
[查询][混合检索](向量 + BM25 + 元数据过滤)
    ↓
[结果压缩](控制token数量)
    ↓
[生成](带引用,处理矛盾)
    ↓
[回答]

完整代码:

class ProductionRAG:
    """生产级RAG系统"""
    
    def __init__(self, embedding_model: str = "bge-large-zh"):
        self.embedding_model = embedding_model
        self.vector_store = VectorStore()
        self.bm25_index = BM25Index()
        self.llm = LLMClient()
    
    def ingest(self, file_path: str):
        """文档接入"""
        # 1. 解析文档
        text = parse_document(file_path)
        
        # 2. 语义切分
        chunks = split_by_semantic(text, max_chunk_size=500)
        
        # 3. 向量化
        embeddings = [embed(chunk, model=self.embedding_model) for chunk in chunks]
        
        # 4. 存储
        for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
            doc_id = f"{file_path}_{i}"
            self.vector_store.add(doc_id, chunk, embedding, metadata={
                "source": file_path,
                "chunk_index": i
            })
            self.bm25_index.add(doc_id, chunk)
    
    def query(self, question: str, filters: dict = None) -> str:
        """查询"""
        # 1. 混合检索
        results = self.hybrid_search(question, filters, top_k=5)
        
        # 2. 压缩上下文
        context = self.compress_context(results, max_tokens=2000)
        
        # 3. 生成回答(带引用)
        answer = self.generate_with_citation(question, results)
        
        return answer
    
    def hybrid_search(self, query: str, filters: dict = None, top_k: int = 5) -> list[dict]:
        """混合检索"""
        # 向量检索
        query_embedding = embed(query, model=self.embedding_model)
        vector_results = self.vector_store.search(query_embedding, filter=filters, top_k=top_k * 2)
        
        # BM25检索
        bm25_results = self.bm25_index.search(query, top_k=top_k * 2)
        
        # 融合排序
        fused = self.reciprocal_rank_fusion(vector_results, bm25_results)
        
        return fused[:top_k]
    
    def reciprocal_rank_fusion(self, *result_lists, k=60):
        """RRF融合排序"""
        scores = {}
        for results in result_lists:
            for rank, doc in enumerate(results):
                doc_id = doc["id"]
                scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (rank + k)
        
        sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return [self.get_doc(doc_id) for doc_id, score in sorted_docs]
    
    def compress_context(self, chunks: list[dict], max_tokens: int = 2000) -> str:
        """压缩上下文"""
        compressed = []
        current_tokens = 0
        
        for chunk in chunks:
            chunk_tokens = count_tokens(chunk["content"])
            if current_tokens + chunk_tokens > max_tokens:
                break
            compressed.append(chunk["content"])
            current_tokens += chunk_tokens
        
        return "\n\n".join(compressed)
    
    def generate_with_citation(self, question: str, chunks: list[dict]) -> str:
        """带引用的生成"""
        context_with_ids = "\n\n".join(
            f"[来源{i+1}] {chunk['content']}"
            for i, chunk in enumerate(chunks)
        )
        
        prompt = f"""根据以下信息回答问题。如果信息不足,请说"信息不足"。
回答时请引用来源编号,如[来源1]。

信息:
{context_with_ids}

问题:{question}

回答:"""
        
        return self.llm.generate(prompt)

总结

RAG从Demo到生产,差的是工程细节

  1. 文档解析:根据格式选择解析器,保留结构信息
  2. 文档切分:按语义切分,带重叠,保留上下文
  3. 向量化:选择合适的Embedding模型,评估效果
  4. 检索策略:混合检索(向量+BM25+元数据过滤)
  5. 生成质量:压缩上下文,带引用,处理矛盾

Demo级别的RAG只是"切块→向量化→检索→生成"。生产级RAG要在每个环节做优化。

RAG的效果,80%取决于工程细节,20%取决于模型。

花时间打磨工程细节,比换更大的模型更有效。