RAG 核心技术实战:文档解析、分块策略与检索 Pipeline

RAG 核心技术实战:文档解析、分块策略与检索 Pipeline

上一篇讲了我从零搭建企业 RAG 知识库的经历,提到文档处理"比想象中难 10 倍"。这篇就把这句话拆开讲清楚——从文档解析到分块策略到 Embedding 选型到向量库接入,每个环节的代码、实验数据和踩坑记录全部摆出来。

系列第一篇:《一套生产级 RAG 知识库:从 Demo 到生产架构的完整复盘》

看完这篇,你会得到:一份可直接运行的 DocumentParser 代码、4 种分块策略的实测对比数据和选型决策树、BGE-M3 的接入代码与参数配置,以及一套端到端的检索生成 Pipeline。


为什么 RAG 检索效果差?很多问题其实出在数据预处理

很多人搭 RAG 系统的路径是这样的:装 LangChain,调 OpenAI API,跑通 Demo,然后上线发现检索效果一塌糊涂。于是开始调 LLM 的 prompt,换更贵的模型,加更长的上下文窗口。

把 RAG 效果差的锅甩给 LLM,是团队犯过的最贵的错误。大部分优化精力应该花在检索之前的数据预处理上,而不是 LLM 的 prompt 上。

RAG 的全链路可以拆成五个环节,每个环节都可能成为瓶颈:

  1. 文档解析:把 PDF、Word、Excel 转成纯文本。扫描件 OCR 识别错误、表格结构丢失、排版混乱,这些都会污染下游所有环节。
  2. 分块策略:把长文本切成片段。切得太碎丢上下文,切得太粗检索不精确。这是影响检索质量最大的变量之一。
  3. Embedding:把文本变成向量。模型选错了,中文检索效果会受到严重影响。
  4. 向量存储与检索:向量怎么存、索引怎么建、检索参数怎么调。
  5. 生成:把检索到的片段喂给 LLM,生成最终答案。

大部分人的注意力集中在第 5 个环节(换更贵的 LLM、写更长的 prompt),但根据实际项目经验,前三个环节决定了 RAG 系统的质量下限。LLM 再强,喂进去的上下文是垃圾,出来的也只能是垃圾。

这篇文章按工程顺序展开:先讲文档解析(保证源头数据干净),再讲分块策略(解析完了才能切分),然后 Embedding 选型(切完了才能向量化),接着向量库接入,最后把所有环节串成完整 Pipeline。


多格式文档解析实战

文档解析是 RAG 的第一道关。PDF、Word、Excel、Markdown,每种格式都有自己的坑。解析这一步出了问题,后面的分块和检索全是无用功。

PDF 解析

PDF 是企业文档最多的格式,也是最难解析的。分两种情况:

文字版 PDF(可以直接复制文字的):用 PyMuPDF(fitz)提取文本和排版信息。

import fitz  # PyMuPDF

def parse_pdf_text(file_path: str) -> str:
    """解析文字版 PDF"""
    doc = fitz.open(file_path)
    text_parts = []
    
    for page_num, page in enumerate(doc):
        text = page.get_text("text")
        text_parts.append(f"--- 第 {page_num + 1} 页 ---\n{text}")
    
    doc.close()
    return "\n\n".join(text_parts)

PyMuPDF 速度快,效果好,但表格提取是弱项。表格内容会被拆成零散的文本行,列对应关系全丢了。

需要保留表格结构时,用 pdfplumber 做补充:

import pdfplumber

def parse_pdf_with_tables(file_path: str):
    """解析 PDF,保留表格结构"""
    text_parts = []
    
    with pdfplumber.open(file_path) as pdf:
        for page_num, page in enumerate(pdf.pages):
            text = page.extract_text() or ""
            
            tables = page.extract_tables()
            for i, table in enumerate(tables):
                text += f"\n\n[表格 {i+1}]\n"
                text += table_to_markdown(table)
            
            text_parts.append(f"--- 第 {page_num + 1} 页 ---\n{text}")
    
    return "\n\n".join(text_parts)

def table_to_markdown(table: list) -> str:
    """将表格数据转为 Markdown 格式"""
    if not table:
        return ""
    
    lines = []
    lines.append("| " + " | ".join(str(cell or "") for cell in table[0]) + " |")
    lines.append("| " + " | ".join("---" for _ in table[0]) + " |")
    for row in table[1:]:
        lines.append("| " + " | ".join(str(cell or "") for cell in row) + " |")
    
    return "\n".join(lines)

扫描件 PDF(纯图片,不能复制文字):必须先 OCR。中文 OCR 用 PaddleOCR 效果最好。

from paddleocr import PaddleOCR

ocr = PaddleOCR(use_angle_cls=True, lang='ch')

def parse_scanned_pdf(file_path: str) -> str:
    """解析扫描件 PDF,先转图片再 OCR"""
    doc = fitz.open(file_path)
    text_parts = []
    
    for page_num, page in enumerate(doc):
        pix = page.get_pixmap(dpi=300)
        img_path = f"/tmp/page_{page_num}.png"
        pix.save(img_path)
        
        result = ocr.ocr(img_path, cls=True)
        
        page_text = []
        for line in result[0]:
            page_text.append(line[1][0])
        
        text_parts.append(f"--- 第 {page_num + 1} 页 ---\n" + "\n".join(page_text))
    
    doc.close()
    return "\n\n".join(text_parts)

OCR 的准确率直接决定了检索质量。中文 OCR 对印刷体效果不错,但手写体和低质量扫描件错误率很高。关键数字(金额、日期)识别错了,检索就找不到。

Word 解析

Word 文档用 python-docx 解析。重点是保留标题层级,后面分块要用。

from docx import Document

def parse_docx(file_path: str) -> str:
    """解析 Word 文档,保留标题层级"""
    doc = Document(file_path)
    text_parts = []
    
    for para in doc.paragraphs:
        style_name = para.style.name if para.style else ""
        
        if style_name.startswith("Heading"):
            level = int(style_name.split()[-1]) if style_name[-1].isdigit() else 1
            prefix = "#" * level
            text_parts.append(f"{prefix} {para.text}")
        else:
            text_parts.append(para.text)
    
    for table in doc.tables:
        text_parts.append(table_to_markdown(
            [[cell.text for cell in row.cells] for row in table.rows]
        ))
    
    return "\n\n".join(text_parts)

python-docx 能拿到段落样式(Heading 1、Heading 2、Normal 等),这对后面 Document-based Chunking 很有用。标题层级信息保留下来,分块时就能按章节切分。

Excel 解析

Excel 用 pandas 解析,每个 sheet 转成 Markdown 表格。

import pandas as pd

def parse_excel(file_path: str) -> str:
    """解析 Excel,每个 sheet 转成 Markdown 表格"""
    xls = pd.ExcelFile(file_path)
    text_parts = []
    
    for sheet_name in xls.sheet_names:
        df = pd.read_excel(xls, sheet_name=sheet_name)
        text_parts.append(f"## {sheet_name}\n")
        text_parts.append(df.to_markdown(index=False))
    
    return "\n\n".join(text_parts)

Excel 解析的坑在于:有些 Excel 文件里一个 sheet 几万行,转成文本后非常长。不能整 sheet 作为一个 chunk,要按行数分块。但分块时要注意保留表头,否则每个 chunk 都不知道列名是什么。

def parse_excel_chunked(file_path: str, rows_per_chunk: int = 50) -> list:
    """解析 Excel,按行数分块,每块保留表头"""
    xls = pd.ExcelFile(file_path)
    chunks = []
    
    for sheet_name in xls.sheet_names:
        df = pd.read_excel(xls, sheet_name=sheet_name)
        
        for i in range(0, len(df), rows_per_chunk):
            chunk_df = df.iloc[i:i + rows_per_chunk]
            chunk_text = f"## {sheet_name} (第 {i+1}-{min(i+rows_per_chunk, len(df))} 行)\n"
            chunk_text += chunk_df.to_markdown(index=False)
            chunks.append(chunk_text)
    
    return chunks

Markdown 解析

Markdown 最简单,它本身就是纯文本格式。但也有一个坑:代码块和表格需要特殊处理。

import re

def parse_markdown(file_path: str):
    """解析 Markdown,保留代码块完整性"""
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
    
    # 提取代码块,用占位符替换,防止代码块被分块时切断
    code_blocks = []
    def replace_code_block(match):
        code_blocks.append(match.group(0))
        return f"[[CODE_BLOCK_{len(code_blocks) - 1}]]"
    
    content = re.sub(
        r'```[\s\S]*?```',
        replace_code_block,
        content
    )
    
    return content, code_blocks

代码块被替换成占位符,分块时不会被切断。但别忘了在分块完成后、入库之前把代码块还原回去,否则检索到的内容里会带着 [[CODE_BLOCK_0]] 这样的占位符:

def restore_code_blocks(chunk_text: str, code_blocks: list) -> str:
    """将占位符还原为原始代码块"""
    for i, block in enumerate(code_blocks):
        chunk_text = chunk_text.replace(f"[[CODE_BLOCK_{i}]]", block)
    return chunk_text

# 使用:分块后、入库前对每个 chunk 做还原
chunks = [restore_code_blocks(c, code_blocks) for c in chunks]

这套占位/还原的逻辑不仅适用于代码块,表格、图片链接等需要保持完整性的内容块都可以用同样的方式处理。

统一解析接口

把上面四种格式统一成一个接口:

from pathlib import Path
from typing import Tuple
import fitz  # PyMuPDF
import pdfplumber
from docx import Document
import pandas as pd

class DocumentParser:
    """多格式文档统一解析器"""
    
    SUPPORTED_FORMATS = ['.pdf', '.docx', '.xlsx', '.md', '.txt']
    
    def parse(self, file_path: str) -> Tuple[str, dict]:
        """解析文档,返回 (文本内容, 元数据)"""
        ext = Path(file_path).suffix.lower()
        
        if ext == '.pdf':
            return self._parse_pdf(file_path)
        elif ext == '.docx':
            return self._parse_docx(file_path)
        elif ext == '.xlsx':
            return self._parse_excel(file_path)
        elif ext in ('.md', '.txt'):
            return self._parse_text(file_path)
        else:
            raise ValueError(f"不支持的格式: {ext}")
    
    def _parse_pdf(self, file_path: str) -> Tuple[str, dict]:
        """解析 PDF,按页判断是否需要 OCR"""
        doc = fitz.open(file_path)
        text_parts = []
        scanned_pages = 0
        total_pages = len(doc)
        
        # 第一轮:逐页提取文本,统计是否需要 OCR
        page_texts = []
        needs_ocr = []
        for page_num, page in enumerate(doc):
            page_text = page.get_text("text").strip()
            line_count = len([l for l in page_text.split('\n') if l.strip()])
            
            if line_count < 3:
                needs_ocr.append(True)
                page_texts.append(None)  # 标记需要 OCR
                scanned_pages += 1
            else:
                needs_ocr.append(False)
                page_texts.append(page_text)
        
        doc.close()
        
        # 第二轮:用 pdfplumber 一次性提取所有表格(按页缓存)
        page_tables = {}
        try:
            with pdfplumber.open(file_path) as pdf:
                for page_num, pdf_page in enumerate(pdf.pages):
                    if page_num < total_pages and not needs_ocr[page_num]:
                        tables = pdf_page.extract_tables()
                        if tables:
                            page_tables[page_num] = tables
        except Exception:
            pass
        
        # 第三轮:组装最终文本
        for page_num in range(total_pages):
            if needs_ocr[page_num]:
                # OCR 处理
                doc_tmp = fitz.open(file_path)
                pix = doc_tmp[page_num].get_pixmap(dpi=300)
                img_path = f"/tmp/page_{page_num}.png"
                pix.save(img_path)
                doc_tmp.close()
                
                result = ocr.ocr(img_path, cls=True)
                ocr_text = "\n".join(line[1][0] for line in result[0]) if result[0] else ""
                text_parts.append(f"--- 第 {page_num + 1} 页 ---\n{ocr_text}")
            else:
                page_text = page_texts[page_num]
                # 附加表格
                if page_num in page_tables:
                    for i, table in enumerate(page_tables[page_num]):
                        page_text += f"\n\n[表格 {i+1}]\n" + table_to_markdown(table)
                text_parts.append(f"--- 第 {page_num + 1} 页 ---\n{page_text}")
        
        is_scanned = scanned_pages > total_pages / 2
        return "\n\n".join(text_parts), {
            "format": "pdf",
            "scanned": is_scanned,
            "pages": total_pages
        }
    
    def _parse_docx(self, file_path: str) -> Tuple[str, dict]:
        text = parse_docx(file_path)
        return text, {"format": "docx"}
    
    def _parse_excel(self, file_path: str) -> Tuple[str, dict]:
        text = parse_excel(file_path)
        return text, {"format": "excel"}
    
    def _parse_text(self, file_path: str) -> Tuple[str, dict]:
        with open(file_path, 'r', encoding='utf-8') as f:
            text = f.read()
        return text, {"format": Path(file_path).suffix[1:]}


# 使用示例
parser = DocumentParser()
text, meta = parser.parse("合同.pdf")
print(f"格式: {meta['format']}, 扫描件: {meta.get('scanned', False)}")

按页判断是关键改进。之前的做法是整篇文档提取完文本后看总长度,如果第一页是封面(没文字),总长度也会很小,就会错误地对整篇文档走 OCR。改成逐页判断后,封面页走 OCR,正文页走文字提取,各取所需。判断逻辑用行数而非字符数,因为有些扫描页 OCR 后也能提取到少量乱码字符,但行数极少。


分块策略--4 种方法实测对比

文档解析完了,接下来把长文本切成片段。分块(Chunking)是 RAG 系统里投入产出比最高的优化点。改一个分块参数,检索准确率会有明显波动。但很多人用 LangChain 默认参数就上线了,从来没认真想过分块策略对不对。

为什么必须分块

两个原因。第一,Embedding 模型有输入长度限制。BGE-M3 最大 8192 tokens,一篇 50 页的合同远超这个长度。对于绝大多数长文档(合同、报告、手册等),必须切分;短文本如 FAQ 问答对、短信记录等,本身就在模型输入长度限制内,可以不切分直接入库。第二,LLM 的上下文窗口有限。即使 GPT-4 支持 128K 上下文,塞进去 50 页文档,注意力也会稀释,关键信息可能被忽略。检索的目的是精准找到最相关的几个片段,而不是把整篇文档都喂进去。

分块的核心矛盾是:切得太碎,单个片段丢失上下文,检索到了也看不懂;切得太粗,一个片段里混了多个话题,检索精度下降。

方法一:Fixed-size Chunking(固定大小切分)

最简单的方案。按固定字符数切分,加一个 overlap 重叠区。

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separator="\n"
)

chunks = splitter.split_text(long_text)

优点是实现简单,速度极快,不依赖任何模型。缺点是粗暴。"经济补偿金按劳动者在本单位工作的年限,每满一年支付一个月工资的标准向劳动者支付。六个月以上不满一年的,按一年计算;不满六个月的,向劳动者支付半个月工资的经济补偿。"这段话如果在第 499 个字符处被切断,后半句就跑到下一个 chunk 里了。检索到前半句时,"半个月工资的经济补偿"这个关键信息丢失。

overlap 能缓解一部分问题,但只是把切断点附近的内容重复一遍,不能解决跨片段语义断裂。

适用场景: 文档格式统一、段落短小、对精度要求不高的快速原型。

方法二:Recursive Chunking(递归切分)

LangChain 的默认策略。按分隔符优先级递归切分:先按段落(\n\n),段落太大再按行(\n),行还太大再按空格,最后按字符。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
)

chunks = splitter.split_text(long_text)

注意我在 separators 里加了中文标点符号(。!?;,)。LangChain 默认的 separators 是英文的,对中文文档效果不好。加上中文句号和逗号后,切分会优先在中文句子边界发生,大幅减少句子被从中间切断的情况。

递归切分比固定大小切分智能,因为它尽量在自然边界处切分。但它仍然不知道文档的语义结构--它不知道哪里是章节边界,哪里是条款边界,只是按分隔符的优先级尝试。

适用场景: 大部分通用场景的默认选择。混合格式文档、网页内容、技术文档。

方法三:Semantic Chunking(语义切分)

按语义相似度切分。思路是:计算相邻句子的 Embedding 相似度,相似度高说明在讲同一个话题,放在一起;相似度低说明话题切换了,在这里切一刀。

from langchain_experimental.text_splitter import SemanticChunker
# 需要安装:pip install langchain-experimental
from FlagEmbedding import BGEM3FlagModel

# 自定义 Embedding 函数,适配 SemanticChunker
# 你也可以替换为任何你选择的 Embedding 模型,保持与项目选型一致
class BGE3Embeddings:
    """自定义 Embedding 类,适配 SemanticChunker 的接口约定。
    embed_documents 和 embed_query 是 LangChain BaseEmbeddings 的接口方法名,
    SemanticChunker 内部会按此约定调用。
    """
    def __init__(self):
        # 若在 GPU 环境可开启 FP16 加速;CPU 环境去掉 use_fp16
        self.model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True)
    
    def embed_documents(self, texts):
        vecs = self.model.encode(texts, return_dense=True)['dense_vecs']
        return [v.tolist() for v in vecs]
    
    def embed_query(self, text):
        return self.model.encode([text], return_dense=True)['dense_vecs'][0].tolist()

embedding_fn = BGE3Embeddings()
splitter = SemanticChunker(
    embedding_fn,  # 传入自定义 Embedding 函数
    breakpoint_threshold_type="percentile",  # 用百分位数确定切分阈值
    breakpoint_threshold_amount=95,           # 相似度差异超过 95 百分位时切分
)

chunks = splitter.split_text(long_text)

语义切分的好处是切分点更"聪明"。它知道"第一部分讲赔偿标准"和"第二部分讲竞业限制"是两个话题,会在中间切分,而不是在某个固定字符数处切。

代价是速度慢。每个句子都要算 Embedding,100 页文档可能要几分钟。而且效果依赖 Embedding 模型质量,模型不行切出来的结果更差。

还有个问题是切出来的 chunk 大小不可控。有时候一个话题讲了很多页,切出来的 chunk 远超 Embedding 模型的输入长度,还得再用 Recursive Chunking 切一次。有时候两个句子就被切成一个 chunk,信息量太少。

适用场景: 长文档、话题变化明显的内容(如技术报告、调研文档)。需要 GPU 资源做批量 Embedding。

方法四:Document-based Chunking(按文档结构切分)

按文档自身的层级结构切分。法律文书有"条->款->项"的层级,合同有"章->节->条"的层级,技术文档有"章节->小节->段落"的层级。在每个层级边界处切分,每个 chunk 带上父级标题路径。

import re
from dataclasses import dataclass
from typing import List

@dataclass
class Chunk:
    content: str
    metadata: dict

class LegalDocumentChunker:
    """按法律文档层级结构切分"""
    
    CHAPTER_PATTERN = re.compile(r'^第[一二三四五六七八九十百千]+章\s')
    SECTION_PATTERN = re.compile(r'^第[一二三四五六七八九十百千]+节\s')
    ARTICLE_PATTERN = re.compile(r'^第[一二三四五六七八九十百千]+条\s')
    ITEM_PATTERN = re.compile(r'^([一二三四五六七八九十]+)')
    
    def split(self, document: str) -> List[Chunk]:
        lines = document.split('\n')
        chunks = []
        current_path = []
        current_content = []
        
        for line in lines:
            stripped = line.strip()
            
            if self.CHAPTER_PATTERN.match(stripped):
                if current_content:
                    chunks.append(self._make_chunk(current_content, current_path))
                    current_content = []
                current_path = [stripped]
            elif self.SECTION_PATTERN.match(stripped):
                if current_content:
                    chunks.append(self._make_chunk(current_content, current_path))
                    current_content = []
                if current_path:
                    current_path = current_path[:1] + [stripped]
                else:
                    current_path = [stripped]
            elif self.ARTICLE_PATTERN.match(stripped):
                if current_content:
                    chunks.append(self._make_chunk(current_content, current_path))
                    current_content = []
                current_content.append(line)
            else:
                current_content.append(line)
        
        if current_content:
            chunks.append(self._make_chunk(current_content, current_path))
        
        return chunks
    
    def _make_chunk(self, content_lines: list, path: list) -> Chunk:
        return Chunk(
            content='\n'.join(content_lines),
            metadata={
                "context": " > ".join(path) if path else "root",
                "level": len(path)
            }
        )

这种切分方式对结构化文档效果最好。用户问"第三十八条第二款是什么",检索时能精确定位到"第三章 > 第一节 > 第三十八条"这个路径下的内容,而不是一堆语义相近但位置无关的片段。

每个 chunk 的 metadata 里存了层级路径,检索时可以把路径作为过滤条件,或者在生成时把路径作为上下文提供给 LLM:"以下内容出自《第三章 违约责任 > 第一节 一般规定 > 第三十八条》"。

代价是实现复杂,每种文档格式都要写对应的解析器。而且非结构化文档(如纯文本邮件、聊天记录)用不了这种方法。

适用场景: 法律文书、合同、规章制度、技术手册等有明确层级结构的文档。

四种策略实测对比

我在 200 份企业合同文档上跑了四种策略,用 50 个测试问题评估 Recall@5(前 5 条结果里有没有正确答案)。

测试集构建方式:从真实企业合同问答日志中抽取 30 条(覆盖条款查询、概念解释、对比分析三类),另构造 20 条边界 case(含跨章节引用、否定句式、多义词)。每条问题人工标注正确答案对应的文档片段。

策略 chunk 数量 平均 chunk 长度 Recall@5 上下文完整度 实现复杂度
Fixed-size (500字符) ~2400 500 字符 0.62 极低
Recursive (500字符, 中文标点) ~2200 480 字符 0.71
Semantic (percentile=95) ~1800 变动大 (200-1500) 0.74
Document-based (按条款) ~1500 变动大 (100-2000) 0.83 最高

几个发现:

  1. Fixed-size 最差,差距明显。 很多关键条款在切断点附近被拆成两半,检索到的前半句和后半句分别成了两个 chunk,都不完整。
  2. Recursive 加中文标点效果提升明显。 仅仅在 separators 里加了 。!?;,,Recall@5 从 0.65 提升到 0.71。这个改动成本几乎为零。
  3. Semantic 比 Recursive 好一点,但不多。 +3% 的提升,代价是要跑 Embedding 计算,速度慢了 50 倍。性价比不高。
  4. Document-based 效果最好,但只适用于结构化文档。 对于合同这种格式规范的文档,按条款切分的优势巨大。但对非结构化文档(邮件、聊天记录)就用不了。

选型决策树

根据上面的实验数据,画了一个决策树,按图索骥即可:

你的文档是什么类型?
│
├─ 合同、法律文书、规章制度(有明确层级结构)
│  └─► Document-based Chunking
│      按条/款/项切分,保留层级路径
│
├─ 技术文档、产品手册(有标题但不规则)
│  └─► Recursive Chunking + 中文标点分隔符
│      成本最低,效果够用
│
├─ 长篇报告、调研文档(话题变化多)
│  └─► Semantic Chunking
│      需要 GPU 跑 Embedding,速度慢但切分精准
│
├─ 邮件、聊天记录、FAQ(内容短小)
│  └─► Recursive Chunking (小 chunk 200-300 字符)
│      短文本不用复杂策略
│
└─ 混合格式(什么都有)
   └─► 按文档类型分流
       结构化部分走 Document-based
       非结构化部分走 Recursive
       不要一个策略包打天下

一个实用建议:不要追求一个完美的分块策略。 真实项目里文档格式五花八门,按文档类型分流处理效果最好。合同走 Document-based,技术文档走 Recursive,FAQ 不切分直接整条入库。比用一种策略处理所有文档效果好得多。

不懂 RAG 基础架构?先看上一篇:《一套生产级 RAG 知识库:从 Demo 到生产架构的完整复盘》


Embedding 模型选型--企业项目怎么选

分块策略决定了"切什么",Embedding 模型决定了"怎么找"。选错了模型,中文检索效果会受到严重影响。但选型这件事不是看排行榜谁分高就选谁,企业项目要考虑的东西多得多。

选型要看什么

很多人选 Embedding 模型的流程是:打开 MTEB 排行榜,选分数最高的,完事。这在学术场景没问题,在企业场景会踩坑。

企业项目选 Embedding 模型,至少要看这几个维度:

中文能力。 这条单独拿出来说,因为太多人在这上面栽跟头。很多 Embedding 模型主打多语言,但中文效果拉垮。测试方法不是看 benchmark 分数,而是用你自己的文档跑 50 条真实查询,看 Recall@5。benchmark 分数高的模型在你自己的垂直领域文档上未必好。

中英文混合。 企业文档里经常中英文混用。"根据 GDPR 第 17 条的规定,数据主体有权要求擦除其个人数据"。Embedding 模型得同时理解 GDPR、第 17 条、数据主体、擦除这些概念,不能中文部分理解了英文部分丢了。

向量维度。 维度越高,表达能力越强,但存储和检索成本也越高。1024 维和 1536 维在 10 万文档量级下差别不大,到了百万级文档,存储和内存占用差距就明显了。要算清楚你的向量库能扛多少维度。

本地部署。 企业文档不能出境,这是硬约束。OpenAI 的 API 再好用,很多企业也用不了。你得选能本地部署的模型,或者选国产 API 服务。

推理速度。 批量 Embedding 10000 份文档要多久?单条查询的 Embedding 延迟是多少?这直接影响文档入库速度和用户查询响应时间。GPU 推理和 CPU 推理速度差距很大。

GPU/CPU 资源。 模型越大越准,但也越贵。你的服务器有什么资源,直接决定了可选模型范围。只有 CPU 的话,大模型推理速度可能无法接受。

商业授权。 开源不等于可以商用。要看许可证。Apache 2.0 和 MIT 最宽松,可以放心商用。有些模型是研究用途 only,企业用有法律风险。

数据安全。 用云端 API 做 Embedding,意味着你的文档内容要传给第三方。即使服务商承诺不留存数据,合规审查也未必通过。金融、医疗、法律行业对这一点尤其敏感。

与向量数据库的兼容性。 你选的向量库支持什么向量类型?Milvus 支持 FLOAT_VECTOR 和 BINARY_VECTOR。有些 Embedding 模型输出的是特殊格式(如 ColBERT 的多向量),不是所有向量库都能存。

主流方案分析

基于以上维度,看几个主流选择。这里不列具体参数和价格,因为这些信息容易过时。重点讲每个方案的定位和取舍:

OpenAI text-embedding-3 系列

效果稳定,多语言支持好,API 调用简单。但数据要出境,国内企业项目基本 pass。适合对数据安全不敏感的海外项目或个人项目。向量维度较高,存储成本需要评估。

Cohere embed-v3

英文效果顶尖,多语言也不错。同样需要 API 调用,数据出境问题。Cohere 的优势在英文场景,纯中文场景没有特别的优势。

BGE-M3(智源研究院)

Apache 2.0 协议,免费用。支持 100+ 语言,中文效果在开源模型里名列前茅。支持长文本输入(8192 tokens),一个模型同时输出 dense、sparse、ColBERT 三种向量,可以兼顾语义检索和关键词检索。与 Milvus 兼容性很好,智源和 Zilliz 有官方合作。需要本地部署,有 GPU 资源时推理速度不错,CPU 也能跑但会慢不少。

M3E(Moka AI)

Apache 2.0 协议。中文效果不错,模型轻量,CPU 就能跑。但只支持中文和英文,不支持其他语言。最大输入长度较短,对长文档不友好。近期更新较少,在新模型对比下性能已经不占优势。

最终项目为什么选择 BGE-M3

在上面那个企业知识库项目里,最终选了 BGE-M3。决策过程不是"它排行榜第一所以选它",而是逐项过:

维度 BGE-M3 OpenAI 3 Cohere v3 M3E
中文能力 优秀 良好 良好 优秀
中英文混合 优秀 优秀 优秀 一般
向量维度 中等 较高 中等 较低
本地部署
推理速度 GPU 批量 1000 条约 1 分钟,CPU 批量 1000 条约 15 分钟 API 调用 API 调用 GPU/CPU 均较快
资源需求 中等 不需要 不需要
商业授权 Apache 2.0 商用付费 商用付费 Apache 2.0
数据安全 ✅ 本地 ❌ 出境 ❌ 出境 ✅ 本地
Milvus 兼容 ✅ 官方适配
长文本支持 ✅ 8192 tokens 一般 ❌ 较短

决策逻辑:

  1. 数据不能出境 -> 排除 OpenAI 和 Cohere
  2. 剩下 BGE-M3 和 M3E
  3. M3E 最大输入长度较短,很多文档片段超过限制会被截断 -> 排除 M3E
  4. BGE-M3 胜出

这不是说 BGE-M3 在所有场景都是最优解。如果你的文档不需要本地部署,OpenAI 的效果和便利性都很好。如果你的文档全是短文本(FAQ 问答对),M3E 的轻量级部署也有优势。选型的关键是把自己的约束条件列清楚,逐项排除。

选型决策清单

做 Embedding 选型时,建议按这个流程走:

  1. 列约束:数据能不能出境?有没有 GPU?预算多少?文档主要是什么语言?
  2. 初筛:根据约束排除不可选项,通常剩下 2-3 个候选。
  3. 跑评测:用你自己的文档构建 50-100 条测试 query,跑 Recall@5 和 MRR。不要只看 benchmark。
  4. 看工程成本:部署难度、推理速度、与现有系统的集成复杂度。
  5. 做决定:选综合得分最高的,不是 benchmark 最高的。

向量数据库接入

文档解析完了、分块完了、Embedding 模型选好了,接下来要把向量存起来。第一篇里选了 Milvus,这里展开讲接入细节。

Milvus 接入完整代码

from pymilvus import MilvusClient, DataType
from FlagEmbedding import BGEM3FlagModel

class VectorStore:
    """Milvus 向量存储封装"""
    
    def __init__(self, uri: str = "http://localhost:19530", 
                 collection_name: str = "rag_docs"):
        self.client = MilvusClient(uri=uri)
        self.collection_name = collection_name
        
        # 初始化 Embedding 模型
        # 若在 GPU 环境可开启 FP16 加速推理;CPU 环境去掉 use_fp16 参数
        self.embedding_model = BGEM3FlagModel(
            'BAAI/bge-m3',
            use_fp16=True
        )
        
        if not self.client.has_collection(collection_name):
            self._create_collection()
    
    def _create_collection(self):
        """创建 Collection 和索引"""
        schema = self.client.create_schema(
            auto_id=True,
            enable_dynamic_field=True
        )
        
        schema.add_field("id", DataType.INT64, is_primary=True)
        schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=1024)
        schema.add_field("text", DataType.VARCHAR, max_length=65535)
        schema.add_field("source", DataType.VARCHAR, max_length=512)
        schema.add_field("chunk_index", DataType.INT64)
        schema.add_field("context", DataType.VARCHAR, max_length=1024)
        
        self.client.create_collection(
            collection_name=self.collection_name,
            schema=schema
        )
        
        index_params = self.client.prepare_index_params()
        index_params.add_index(
            field_name="embedding",
            index_type="IVF_FLAT",      # 倒排文件索引
            metric_type="COSINE",        # 余弦相似度
            params={"nlist": 1024}       # 聚类中心数
        )
        
        self.client.create_index(
            collection_name=self.collection_name,
            index_params=index_params
        )
        
        print(f"Collection '{self.collection_name}' 创建完成")
    
    def embed_text(self, text: str) -> list:
        """生成 Embedding 向量"""
        embeddings = self.embedding_model.encode(
            [text],
            batch_size=1,
            max_length=8192,
            return_dense=True,
            return_sparse=False,
            return_colbert_vecs=False
        )
        return embeddings['dense_vecs'][0].tolist()
    
    def insert(self, chunks: list, source_file: str):
        """批量插入文档片段"""
        data = []
        for i, chunk in enumerate(chunks):
            embedding = self.embed_text(chunk.content)
            data.append({
                "embedding": embedding,
                "text": chunk.content,
                "source": source_file,
                "chunk_index": i,
                "context": chunk.metadata.get("context", "")
            })
        
        result = self.client.insert(
            collection_name=self.collection_name,
            data=data
        )
        
        print(f"插入 {len(data)} 条片段,来源: {source_file}")
        return result
    
    def search(self, query: str, top_k: int = 5, 
               filter_expr: str = None) -> list:
        """向量检索"""
        query_embedding = self.embed_text(query)
        
        search_params = {
            "metric_type": "COSINE",
            "params": {"nprobe": 16}  # 搜索的聚类数,越大越精确但越慢
        }
        
        results = self.client.search(
            collection_name=self.collection_name,
            data=[query_embedding],
            limit=top_k,
            output_fields=["text", "source", "context", "chunk_index"],
            search_params=search_params,
            filter=filter_expr  # 例如: 'source == "合同.pdf"'
        )
        
        return [
            {
                "text": hit["entity"]["text"],
                "source": hit["entity"]["source"],
                "context": hit["entity"]["context"],
                "score": hit["distance"]
            }
            for hit in results[0]
        ]

Collection 设计要点

字段设计。 除了 embedding 向量本身,还要存 text(原文)、source(来源文件)、context(层级路径,Document-based Chunking 用的)、chunk_index(片段在原文中的位置)。这些 metadata 在检索时可以做过滤,在生成时可以提供给 LLM 作为上下文。

索引选择。 Milvus 支持多种索引类型:

索引类型 特点 适用场景
FLAT 暴力搜索,100% 精确 文档量很小,测试用
IVF_FLAT 倒排文件,精度高 中等规模,生产常用
IVF_SQ8 量化压缩,省内存 大规模,精度可接受损失
HNSW 图索引,速度最快 对延迟敏感的场景,内存占用大
DISKANN 磁盘索引,省内存 超大规模,延迟稍高

我选了 IVF_FLAT,在 10 万文档量级下精度和速度都不错。nlist=1024 表示把向量聚成 1024 个簇,nprobe=16 表示搜索时检查 16 个最近的簇。nprobe 越大越精确但越慢,这是精度和速度的核心 tradeoff。

批量写入与性能优化

单条插入很慢。Milvus 的批量插入也有讲究:

def batch_insert(self, chunks: list, source_file: str, batch_size: int = 100):
    """分批插入,避免单批过大超时"""
    total = len(chunks)
    
    for i in range(0, total, batch_size):
        batch = chunks[i:i + batch_size]
        self.insert(batch, source_file)
        print(f"进度: {min(i + batch_size, total)}/{total}")
    
    # 刷新,确保数据可检索
    self.client.flush(self.collection_name)

batch_size=100 是经验值。太大批量会内存溢出,太小批量网络开销占比高。2000 份文档,每份平均切 10 个 chunk,共 20000 条片段。Embedding 计算(GPU)大约 15 分钟,Milvus 写入大约 5 分钟。总共 20 分钟搞定。如果用 CPU 跑 Embedding,时间会膨胀到 2 小时以上。

还有一个隐藏问题:Milvus 的 flush() 操作很重,会触发段合并。不要每插入一批就 flush,攒够了再 flush,或者用后台自动 flush。


完整检索 + 生成 Pipeline

把所有环节串起来,从文档输入到答案输出:

from typing import List, Optional
from dataclasses import dataclass

@dataclass
class RAGResult:
    answer: str
    sources: list

class RAGPipeline:
    """端到端 RAG Pipeline"""
    
    def __init__(self, milvus_uri: str = "http://localhost:19530", chunker=None):
        self.parser = DocumentParser()
        # chunker 可按文档类型替换,默认用法律文书切分器
        self.chunker = chunker or LegalDocumentChunker()
        self.vector_store = VectorStore(milvus_uri)
        
        from openai import OpenAI
        # 通义千问兼容 OpenAI 接口
        self.llm = OpenAI(
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
        )
    
    def ingest(self, file_path: str):
        """文档入库:解析 -> 分块 -> Embedding -> 写入向量库"""
        text, meta = self.parser.parse(file_path)
        print(f"[1/3] 解析完成: {meta}")
        
        chunks = self.chunker.split(text)
        print(f"[2/3] 分块完成: {len(chunks)} 个片段")
        
        self.vector_store.insert(chunks, source_file=file_path)
        print(f"[3/3] 入库完成")
    
    def query(self, question: str, top_k: int = 5) -> RAGResult:
        """查询:检索 -> 生成"""
        results = self.vector_store.search(question, top_k=top_k)
        
        if not results:
            return RAGResult(answer="未找到相关文档", sources=[])
        
        context_parts = []
        for i, r in enumerate(results):
            source_info = f"[文档 {i+1}] 来源: {r['source']}"
            if r['context']:
                source_info += f" | 章节路径: {r['context']}"
            context_parts.append(f"{source_info}\n{r['text']}")
        
        context = "\n\n---\n\n".join(context_parts)
        
        prompt = f"""请根据以下检索到的文档片段回答问题。

{context}

---

问题:{question}

要求:
1. 只根据上面的文档内容回答,不要编造
2. 如果文档中没有相关信息,直接说"根据现有文档无法回答"
3. 回答时标注信息来源,例如"根据《XXX》第X条..."
"""
        
        response = self.llm.chat.completions.create(
            model="qwen-plus",
            messages=[
                {"role": "system", "content": "你是一个专业的文档问答助手。"},
                {"role": "user", "content": prompt}
            ],
            temperature=0.1
        )
        
        return RAGResult(
            answer=response.choices[0].message.content,
            sources=[
                {"source": r['source'], "context": r['context'], 
                 "score": r['score'], "text": r['text'][:100]}
                for r in results
            ]
        )


# 使用示例
pipeline = RAGPipeline()

# 文档入库
pipeline.ingest("劳动合同法.pdf")
pipeline.ingest("公司规章制度.docx")

# 查询
result = pipeline.query("员工主动离职,公司需要支付经济补偿金吗?")
print(f"答案: {result.answer}")
print(f"\n来源:")
for src in result.sources:
    print(f"  - {src['source']} (score: {src['score']:.4f})")

这套 Pipeline 在第一篇提到的企业知识库项目里跑了一段时间。文档解析准确率约 95%(扫描件 OCR 是主要误差来源),分块后的检索 Recall@5 约 0.83(Document-based Chunking + BGE-M3),端到端查询延迟约 2-3 秒。


我踩过的 5 个坑

坑 1:PDF 表格变成乱码

第一篇提到过这个问题,这里展开讲。合同里的赔偿标准表,用 PyMuPDF 提取后变成了一堆零散的数字和文字,列对应关系全丢了。用户问"违约金上限是多少",检索到的表格碎片里根本没有完整的金额信息。

解决方案是 pdfplumber 的 extract_tables() 方法。它能识别 PDF 中的表格线,按行列结构提取。但 pdfplumber 也有局限:没有明确表格线的 PDF(用空格对齐的伪表格)它识别不了。这种情况下只能上 Camelot 或 Tabula,或者用 MinerU 做深度解析。

更彻底的方案是 MinerU。它能做版面分析,自动区分正文、表格、图片、页眉页脚,表格输出为结构化格式。但 MinerU 依赖较重,部署复杂度高。项目里最终的方案是 pdfplumber 处理大部分 PDF,MinerU 作为兜底处理复杂表格。

坑 2:固定 512 字符切断语义

刚开始用 LangChain 默认的 CharacterTextSplitter,chunk_size=1000,separator="\n\n"。中文文档里段落经常超过 1000 字符,于是在段落中间硬切。一条法律条款被切成两半,前半句"经济补偿金按劳动者在本单位工作的年限"在一个 chunk,后半句"每满一年支付一个月工资"在另一个 chunk。检索到前半句时,关键的计算标准丢失了。

改成 RecursiveCharacterTextSplitter 并加上中文标点分隔符后问题缓解。但更好的方案是 Document-based Chunking,直接按条款切分,保证每条条款的完整性。

坑 3:Embedding 模型不支持中文

最初用了一个在 MTEB 排行榜上分数很高的英文模型,结果中文文档检索效果极差。"劳动合同解除"和"离职赔偿"这两个在中文里语义相关的概念,在这个模型的向量空间里距离很远。

排查后发现问题出在模型的训练数据:主要英文语料,中文语料占比很低。换成 BGE-M3 后,同样用本文的 50 条测试问题评估,Recall@5 从 0.45 提升到 0.78。选 Embedding 模型一定要在自己的中文文档上测试,不能只看 benchmark。

坑 4:批量写入向量库超时

2000 份文档,每份解析+Embedding 大概 15 秒,如果同步串行处理,理论耗时超过 8 小时。实际运行时中间还会遇到网络波动导致 Milvus 写入超时,失败了一批数据。

解决方案是 Celery 异步任务队列,10 个 Worker 并行处理。每份文档独立处理,失败了自动重试 3 次。写入 Milvus 时分批,每批 100 条,避免单批过大超时。2000 份文档 25 分钟搞定。

还有一个隐藏问题:Milvus 的 flush() 操作很重,会触发段合并。不要每插入一批就 flush,攒够了再 flush,或者用后台自动 flush。

坑 5:生成答案的幻觉问题

检索到了正确的文档片段,但 LLM 还是编了一个错误的答案。原因是 prompt 没写好,LLM 在文档片段和自身训练数据之间"自由发挥"。

解决方案是在 prompt 里加硬约束:"只根据上面的文档内容回答,不要编造。如果文档中没有相关信息,直接说'根据现有文档无法回答'。"同时把 temperature 降到 0.1,减少生成的随机性。

另外,在答案后面标注引用来源(出自哪份文档的第几页/第几条),既方便用户核对原文,也能减少 LLM "一本正经胡说八道"的概率--当它知道答案要标来源时,会更倾向于忠实于检索到的内容。


最后说一句

RAG 系统的质量,大部分由数据预处理决定。文档解析错了,后面全白费。分块策略错了,检索精度上限就被锁死。Embedding 模型选错了,中文检索效果会受到严重影响。LLM 只是最后一棒,它能做的是把检索到的信息组织好,而不是无中生有。

如果刚开始搭 RAG 系统,建议按这个顺序推进:

  1. 先把文档解析做扎实。用你自己的真实文档测试,不要用 Demo 数据。
  2. 选一个合适的分块策略。先用 Recursive + 中文标点跑起来,有评估数据集了再试其他策略。
  3. 选 Embedding 模型时列清楚约束条件,逐项排除。不要看排行榜选。
  4. 向量库先跑通基础检索,再调索引参数。
  5. 最后再优化生成环节的 prompt 和 LLM 选型。

下一篇预告:《检索优化(混合检索、查询重写、Rerank、多轮检索)》,讲讲当基础 RAG 遇到“查不准、查不全”时,如何通过混合检索(BM25+Dense)、Rerank 重排序以及 Query 拓展等技术,把检索召回率和准确率再提升一个台阶。


本文是「三个月打造企业知识库系统」系列的第二篇。第一篇:一套生产级 RAG 知识库:从 Demo 到生产架构的完整复盘

posted @ 2026-08-07 09:55  汪汪汪?  阅读(8)  评论(0)    收藏  举报