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 的全链路可以拆成五个环节,每个环节都可能成为瓶颈:
- 文档解析:把 PDF、Word、Excel 转成纯文本。扫描件 OCR 识别错误、表格结构丢失、排版混乱,这些都会污染下游所有环节。
- 分块策略:把长文本切成片段。切得太碎丢上下文,切得太粗检索不精确。这是影响检索质量最大的变量之一。
- Embedding:把文本变成向量。模型选错了,中文检索效果会受到严重影响。
- 向量存储与检索:向量怎么存、索引怎么建、检索参数怎么调。
- 生成:把检索到的片段喂给 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 | 最高 | 高 |
几个发现:
- Fixed-size 最差,差距明显。 很多关键条款在切断点附近被拆成两半,检索到的前半句和后半句分别成了两个 chunk,都不完整。
- Recursive 加中文标点效果提升明显。 仅仅在 separators 里加了
。!?;,,Recall@5 从 0.65 提升到 0.71。这个改动成本几乎为零。 - Semantic 比 Recursive 好一点,但不多。 +3% 的提升,代价是要跑 Embedding 计算,速度慢了 50 倍。性价比不高。
- 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 | ✅ | 一般 | ❌ 较短 |
决策逻辑:
- 数据不能出境 -> 排除 OpenAI 和 Cohere
- 剩下 BGE-M3 和 M3E
- M3E 最大输入长度较短,很多文档片段超过限制会被截断 -> 排除 M3E
- BGE-M3 胜出
这不是说 BGE-M3 在所有场景都是最优解。如果你的文档不需要本地部署,OpenAI 的效果和便利性都很好。如果你的文档全是短文本(FAQ 问答对),M3E 的轻量级部署也有优势。选型的关键是把自己的约束条件列清楚,逐项排除。
选型决策清单
做 Embedding 选型时,建议按这个流程走:
- 列约束:数据能不能出境?有没有 GPU?预算多少?文档主要是什么语言?
- 初筛:根据约束排除不可选项,通常剩下 2-3 个候选。
- 跑评测:用你自己的文档构建 50-100 条测试 query,跑 Recall@5 和 MRR。不要只看 benchmark。
- 看工程成本:部署难度、推理速度、与现有系统的集成复杂度。
- 做决定:选综合得分最高的,不是 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 系统,建议按这个顺序推进:
- 先把文档解析做扎实。用你自己的真实文档测试,不要用 Demo 数据。
- 选一个合适的分块策略。先用 Recursive + 中文标点跑起来,有评估数据集了再试其他策略。
- 选 Embedding 模型时列清楚约束条件,逐项排除。不要看排行榜选。
- 向量库先跑通基础检索,再调索引参数。
- 最后再优化生成环节的 prompt 和 LLM 选型。
下一篇预告:《检索优化(混合检索、查询重写、Rerank、多轮检索)》,讲讲当基础 RAG 遇到“查不准、查不全”时,如何通过混合检索(BM25+Dense)、Rerank 重排序以及 Query 拓展等技术,把检索召回率和准确率再提升一个台阶。
本文是「三个月打造企业知识库系统」系列的第二篇。第一篇:一套生产级 RAG 知识库:从 Demo 到生产架构的完整复盘

浙公网安备 33010602011771号