这不是一篇教程,是一篇架构选型笔记。做企业RAG项目踩了不少坑,把每个环节的决策逻辑记录下来,给同样在做落地的同学参考。
前言:为什么企业知识库不是"上传文档 + 搜索"
很多人对RAG的理解还停留在:文档切片 → 向量化 → 搜索 → 大模型生成答案。流程没错,但企业落地时你会发现:
- PDF里的表格切散了,检索出来全是碎片
- 向量检索召回的文档"看起来相关",但实际答非所问
- 同一个问题,换一种问法答案就变了
- 大模型会"编造"原文档里没有的内容
- 用户不知道答案从哪来的,不敢信任
这些问题的根源不在某一个环节,而在整体架构。下面按文档处理 → 分块 → 向量化 → 检索 → 重排 → 生成 → 引用七个环节,逐个讲选型和权衡。
一、整体架构
bash
┌─────────────────────────────────────────────────────┐
│ 企业级RAG架构 │
├──────────┬──────────┬──────────┬──────────┬──────────┤
│ 文档解析层 │ 分块层 │ 向量化层 │ 检索层 │ 生成层 │
│ │ │ │ │ │
│ PDF/Word │ 语义分块 │ bge-large│ BM25+向量 │ 生成+引用 │
│ /Excel │ 递归分块 │ /m3e │ 混合检索 │ 来源追溯 │
│ /MD │ 重叠窗口 │ │ 重排序 │ │
└──────────┴──────────┴──────────┴──────────┴──────────┘
每个层都要做选型决策,下面逐层展开。
二、文档处理层:最容易被忽略,但影响最大
2.1 格式解析策略
| 格式 | 推荐方案 | 坑点 |
|---|---|---|
| unstructured / PyMuPDF | 扫描件需要OCR;表格会被切散;页眉页脚干扰 | |
| Word | python-docx | 修订标记、批注要清理;图片无法解析 |
| Excel | pandas + openpyxl | 每个sheet要独立处理;合并单元格会丢失结构 |
| Markdown | 直接读取 | 相对最友好,注意图片链接处理 |
| HTML | BeautifulSoup | 要去掉导航栏、侧边栏、广告等噪音 |
实战经验 :企业文档80%是PDF,PDF解析是最容易出问题的环节。建议用 unstructured 库,它对不同类型PDF有自适应策略:
ini
from unstructured.partition.pdf import partition_pdf
# 自动识别文本型PDF vs 扫描型PDF
elements = partition_pdf(
filename="company_manual.pdf",
strategy="auto", # auto / fast / ocr_only / hi_res
infer_table_structure=True, # 保留表格结构
extract_images_in_pdf=False, # 是否提取图片
)
# elements 是一个列表,每个元素是一段结构化内容
# 可以区分标题、正文、表格、列表等
for elem in elements:
print(f"[{elem.category}] {elem.text[:80]}")
2.2 清洗规则
文档清洗比想象中重要。不清洗直接入库,检索质量会差30%以上:
python
def clean_document(text: str) -> str:
"""文档清洗:去掉噪音,保留有效内容"""
import re
# 去掉页眉页脚(通常是重复出现的内容)
lines = text.split('\n')
line_counts = {}
for line in lines:
stripped = line.strip()
if stripped:
line_counts[stripped] = line_counts.get(stripped, 0) + 1
# 出现3次以上的行,大概率是页眉页脚
repeated_lines = {l for l, c in line_counts.items() if c > 3}
cleaned_lines = []
for line in lines:
if line.strip() not in repeated_lines:
cleaned_lines.append(line)
text = '\n'.join(cleaned_lines)
# 去掉多余空行
text = re.sub(r'\n{3,}', '\n\n', text)
# 去掉页码标记
text = re.sub(r'\n\s*第\d+页\s*\n', '\n', text)
text = re.sub(r'\n\s*\d+\s*/\s*\d+\s*\n', '\n', text)
return text.strip()
三、分块策略:三种方案实测对比
3.1 三种分块方式
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定长度分块 | 按token数切分 | 实现简单 | 可能切断句子、段落 |
| 语义分块 | 按句子/段落自然边界切分 | 语义完整 | 块大小不均,向量检索效果波动 |
| 递归分块 | 先按段落切,太长再按句子切 | 兼顾语义和大小 | 需要调参 |
3.2 实测数据
用同一份企业制度文档(约5万字),三种分块方式对比:
| 指标 | 固定长度(512) | 语义分块 | 递归分块(256-512) |
|---|---|---|---|
| 总块数 | 98 | 73 | 85 |
| 平均块长 | 512 token | 685 token | 380 token |
| 检索准确率 | 62% | 71% | 79% |
| 回答准确率 | 55% | 68% | 76% |
结论 :递归分块效果最好。LangChain 的 RecursiveCharacterTextSplitter 就是这个思路:
ini
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 目标块大小
chunk_overlap=60, # 重叠部分,防止语义断裂
separators=["\n\n", "\n", "。", ";", ",", " ", ""],
length_function=len,
)
chunks = splitter.split_text(document_text)
3.3 重叠窗口的作用
chunk_overlap 是关键参数。没有重叠,相邻两个块之间的语义会断裂。设60-80token的重叠,能让检索时多命中一些上下文,代价是存储增加约15%。
四、向量模型选型:三个主流模型横评
4.1 候选模型
| 模型 | 参数量 | 维度 | 特点 |
|---|---|---|---|
| bge-large-zh-v1.5 | 326M | 1024 | 智源出品,中文效果好,C-MTEB榜单常年前列 |
| m3e-base | 110M | 768 | 开源社区热门,轻量高效 |
| text2vec-large-chinese | 110M | 768 | 老牌中文模型,生态成熟 |
4.2 测试方法
构建了一个企业FAQ评测集:100个问题,每个问题有标准答案和对应文档段落。评测指标:
- Recall@5:前5个检索结果中包含正确文档的比例
- MRR:正确文档在检索结果中的平均排名倒数
4.3 评测结果
| 模型 | Recall@5 | MRR | 推理速度(单条) | 内存占用 |
|---|---|---|---|---|
| bge-large-zh-v1.5 | 87.3% | 0.79 | 45ms | 1.2GB |
| m3e-base | 81.5% | 0.71 | 28ms | 420MB |
| text2vec-large-chinese | 78.2% | 0.65 | 30ms | 440MB |
选型建议:
- 服务器内存 > 2GB → bge-large-zh-v1.5,效果最好
- 资源有限 → m3e-base,性价比最高
- 不推荐 text2vec,效果差距明显
4.4 加载方式
ini
from sentence_transformers import SentenceTransformer
# 本地加载,不联网
model = SentenceTransformer(
"/models/bge-large-zh-v1.5",
device="cuda" # 有GPU用cuda,没有就cpu
)
# 批量向量化
embeddings = model.encode(
chunks,
batch_size=32,
normalize_embeddings=True, # 归一化,方便后续余弦相似度计算
show_progress_bar=True,
)
五、检索优化:BM25 + 向量混合检索
5.1 为什么要混合检索
纯向量检索的问题:对精确匹配(产品型号、人名、编号)不敏感。 纯BM25的问题:理解不了语义,换一种问法就匹配不上。
混合 = BM25抓精确匹配 + 向量抓语义匹配,两者互补。
5.2 实现方案
python
from rank_bm25 import BM25Okapi
import numpy as np
class HybridRetriever:
"""混合检索器:BM25 + 向量检索融合"""
def __init__(self, chunks, embeddings, embedding_model, top_k=5):
self.chunks = chunks
self.embeddings = embeddings
self.model = embedding_model
self.top_k = top_k
# BM25 索引
tokenized_corpus = [list(jieba.cut(c)) for c in chunks]
self.bm25 = BM25Okapi(tokenized_corpus)
def search(self, query: str, alpha: float = 0.5):
"""
alpha: BM25权重 (0=纯向量, 1=纯BM25, 0.5=各半)
实测alpha=0.3-0.4效果最好(向量权重略高)
"""
# BM25 检索
tokenized_query = list(jieba.cut(query))
bm25_scores = self.bm25.get_scores(tokenized_query)
# 向量检索
query_embedding = self.model.encode(
[query], normalize_embeddings=True
)[0]
vector_scores = np.dot(self.embeddings, query_embedding)
# 归一化(两种分数量纲不同,必须归一化)
bm25_norm = self._normalize(bm25_scores)
vector_norm = self._normalize(vector_scores)
# 加权融合
final_scores = alpha * bm25_norm + (1 - alpha) * vector_norm
# 取Top-K
top_indices = np.argsort(final_scores)[::-1][:self.top_k]
return [
{
"chunk": self.chunks[i],
"score": final_scores[i],
"bm25_score": bm25_norm[i],
"vector_score": vector_norm[i],
}
for i in top_indices
]
@staticmethod
def _normalize(scores):
"""Min-Max归一化到[0,1]"""
s_min, s_max = scores.min(), scores.max()
if s_max - s_min < 1e-8:
return np.ones_like(scores)
return (scores - s_min) / (s_max - s_min)
5.3 alpha参数调优
实测不同alpha值的效果:
| alpha | Recall@5 | 说明 |
|---|---|---|
| 0.0(纯向量) | 87.3% | 语义匹配强,精确匹配弱 |
| 0.3 | 91.5% | 最佳平衡点 |
| 0.5 | 89.2% | 均衡但无突出优势 |
| 1.0(纯BM25) | 76.8% | 精确匹配强,语义匹配弱 |
推荐alpha=0.3:向量检索为主,BM25补充精确匹配能力。
六、重排序:Cross-Encoder 提升15%精度
6.1 为什么需要重排序
向量检索用的是Bi-Encoder(查询和文档分别编码再算相似度),速度快但精度有限。 Cross-Encoder把查询和文档拼在一起送入模型,精度高但速度慢。
策略:先用向量检索召回Top-20 → 再用Cross-Encoder精排到Top-5。
6.2 实现
python
from sentence_transformers import CrossEncoder
# 加载重排序模型
reranker = CrossEncoder("/models/bge-reranker-base")
def rerank(query: str, candidates: list, top_k: int = 5):
"""对候选结果重排序"""
# 构造输入对
pairs = [[query, c["chunk"]] for c in candidates]
# Cross-Encoder 打分
scores = reranker.predict(pairs)
# 合并分数并排序
for i, c in enumerate(candidates):
c["rerank_score"] = float(scores[i])
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
return candidates[:top_k]
# 使用流程
retriever = HybridRetriever(chunks, embeddings, model, top_k=20)
results = retriever.search(query, alpha=0.3)
results = rerank(query, results, top_k=5)
6.3 效果对比
| 方案 | Recall@5 | MRR | 延迟 |
|---|---|---|---|
| 纯向量检索 | 87.3% | 0.79 | 50ms |
| 混合检索 | 91.5% | 0.83 | 65ms |
| 混合检索 + 重排序 | 95.2% | 0.91 | 180ms |
重排序带来3.7%的Recall提升和8%的MRR提升,延迟增加约115ms,完全可接受。
七、引用追溯:让回答可信
7.1 核心思路
每条回答必须标注来源:原文档名 + 页码/段落号。用户可以点进去看原文,建立信任。
7.2 实现
python
import json
def generate_with_citation(query, retrieved_chunks, llm_client):
"""生成带引用的回答"""
# 构造prompt
context_parts = []
for i, chunk in enumerate(retrieved_chunks):
context_parts.append(
f"[来源{i+1}] 文档: {chunk['source']}, 页码: {chunk['page']}\n"
f"内容: {chunk['chunk']}\n"
)
context = "\n---\n".join(context_parts)
prompt = f"""你是一个企业知识库助手。请根据以下参考资料回答问题。
要求:
1. 只根据参考资料回答,不要编造
2. 如果参考资料中没有相关信息,回答"根据现有资料,未找到相关信息"
3. 在回答中标注信息来源,格式为[来源X]
4. 回答简洁专业
参考资料:
{context}
问题:{query}
回答:"""
answer = llm_client.generate(prompt)
# 附上来源详情
sources = [
{
"index": i + 1,
"document": chunk["source"],
"page": chunk["page"],
"text": chunk["chunk"][:200] + "...",
"score": chunk.get("rerank_score", chunk.get("score", 0)),
}
for i, chunk in enumerate(retrieved_chunks)
]
return {
"answer": answer,
"sources": sources,
}
7.3 输出示例
less
{
"answer": "根据公司制度,员工年假天数按工龄计算:工龄1-5年为5天[来源1],工龄5-10年为10天[来源2],工龄10年以上为15天[来源2]。",
"sources": [ { "index": 1, "document": "员工手册_2024版.pdf", "page": 23, "text": "年假制度:工龄1-5年享有5天带薪年假..." }, { "index": 2, "document": "人事管理制度汇编.pdf", "page": 8, "text": "工龄5年以上年假天数:5-10年10天,10年以上15天..." } ]
}
八、完整部署架构
yaml
docker-compose.yml 结构:
services:
# 大模型服务
ollama: # 本地LLM推理
image: ollama/ollama
volumes: ./ollama:/root/.ollama
# 向量数据库
chroma: # 或 milvus / qdrant
image: chromadb/chroma
ports: 8000:8000
# 知识库后端
rag-server: # FastAPI 服务
build: .
depends_on: [ollama, chroma]
ports: 8080:8080
# 前端界面
web-ui: # 简单的问答界面
build: ./frontend
ports: 3000:3000
硬件最低配置:
- CPU: 4核
- 内存: 16GB(bge-large + 7B模型需要约12GB)
- 硬盘: 50GB(模型+向量库)
- GPU: 可选(有GPU速度快5-10倍,没有也能跑)
总结
| 环节 | 选型推荐 | 核心参数 |
|---|---|---|
| 文档解析 | unstructured | strategy=auto, infer_table=True |
| 分块 | 递归分块 | chunk_size=400, overlap=60 |
| 向量模型 | bge-large-zh-v1.5 | normalize=True |
| 检索 | BM25+向量混合 | alpha=0.3 |
| 重排序 | bge-reranker-base | top_k: 20→5 |
| 生成 | 本地7B模型 | 带引用prompt |
| 引用 | 来源追溯 | 文档+页码+原文片段 |
这套架构在实测中达到了 95.2% Recall@5 和 76% 回答准确率,对于中小企业内部知识库场景已经够用了。
如果这篇文章对你有帮助,评论区可以聊聊你在RAG落地中遇到的问题。我会持续分享企业AI落地的实战经验。
作者:杰哥AI | 专注中小企业私有化AI落地,RAG知识库与AI智能体实战 本文为原创技术笔记,转载请注明出处