2024 年我接手了一个内部知识库搜索的项目业务诉求很直白把公司过去十年的几万篇技术文档变成 AI 能"读懂"的样子员工提问时返回最相关的几篇我第一反应是这事不难嘛把每篇文档丢给 embedding 模型生成向量塞进 Pinecone 查询时把问题向量化做近邻搜索就完了原型一周做完跑几个问题效果不错心里很笃定向量检索嘛就是 embedding + 余弦相似度可等真把这套上线一串问题冒了出来第一种最先把我打懵用户问"如何配置 Nginx 反向代理"返回的第一名居然是"如何配置 Apache 虚拟主机"两篇文档讲的根本不是一回事可向量相似度就是高第二种最难缠某个新员工问"年假怎么休"系统返回了一堆三年前的过期 HR 文档新政策那篇排在第五页根本没人看到第三种最离谱我把一篇 50 页的产品手册整篇做了 embedding 用户问其中某个具体功能怎么用居然没召回到这篇文档我研究半天才明白整篇做 embedding 等于把所有细节平均成一个模糊的"全文摘要向量"召回不到任何具体问题第四种最莫名其妙我换了一个号称更强的中文 embedding 模型重新跑了一遍以为效果会涨结果某些垂直领域的术语反而召回更差了模型不知道我们的内部黑话第五种最致命某天 OpenAI embedding 接口涨价我算了一下我们的全量重建一次要 8000 美元而我之前根本没考虑过怎么增量更新我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为向量检索就是 embedding + 相似度搜索可这个认知是错的真正能用的向量检索是一个"切片策略 + 模型选型 + 元数据过滤 + 混合检索 + 增量更新 + 重排"的工程组合拳少任何一环效果都会断崖式下滑本文从头梳理为什么文档切片是第一关键 embedding 模型怎么选 metadata filter 为什么必不可少向量检索和 BM25 怎么混合 reranker 的价值在哪以及一些把向量检索做扎实要避开的工程坑
问题背景
RAG(Retrieval-Augmented Generation)这两年是 LLM 应用最热的形态之一,几乎所有"问公司内部知识"的产品形态都会走到这一步。很多团队第一版的实现都很顺利:loader 读文档、splitter 切块、embedding 模型转向量、Pinecone/Weaviate/PG vector 存索引、查询时近邻搜索 + 拼到 prompt 里。问题是这套链路里每一环都有大量"教程不讲、上线后才发现"的细节,搞不好整个 RAG 就会沦为"看起来在搜实际全是噪音"。常见的几类失败模式:
- 切片不当:切太大召回失焦,切太小语义不完整,机械按字数切会把表格代码块对话切碎。
- 模型与场景不匹配:用通用英文模型搜中文内容,或用通用模型搜垂直领域术语,效果断崖式下降。
- 没有元数据过滤:用户问"今年的政策",结果召回的全是过期文档,系统不知道时间维度。
- 只用向量不用 BM25:关键词精确匹配场景(产品编号、错误码、人名)向量搜不过传统倒排。
- 没有 reranker:向量召回 top10 后直接喂给 LLM,排序往往不准,真正的答案在第 5-10 位。
- 更新成本失控:没设计增量更新,每次知识库改动要全量重建,几千美元烧一次。
一、文档切片:RAG 效果的第一关键变量
切片(chunking)是 RAG 链路里被严重低估的一环。大多数团队会想"切片不就是按 1000 字一段嘛",随手用 LangChain 的 RecursiveCharacterTextSplitter,然后把效果不好怪到 embedding 模型头上。实际上切片策略对最终召回质量的影响往往比模型选型还大。
切片要解决的核心问题是"在一个 chunk 里既要语义完整,又要主题聚焦"。切太大,一段里包含多个话题,embedding 出来的向量就是这些话题的"平均值",任何具体问题都搜不到精准命中;切太小,一段里上下文不全,embedding 出来的向量缺少足够语义信息,容易跟无关 chunk 撞相似度。一个常见的经验值是 200-500 token 一片,但具体值跟内容类型强相关——技术文档可以稍长,FAQ 可以更短,法律条款应该按条切。
几种常见切片策略对比
1. 固定字符切片 (CharacterTextSplitter)
chunk_size=1000 chunk_overlap=200
优点: 实现简单, 速度快
缺点: 完全不顾语义边界, 表格代码块都被切碎
适用: 同质化的纯文本
2. 递归字符切片 (RecursiveCharacterTextSplitter)
按 \n\n > \n > . > ' ' 优先级递归切
优点: 倾向于保留段落和句子完整性
缺点: 不识别 markdown 结构, 不识别代码块
适用: 一般文档默认选择
3. 结构化切片 (MarkdownHeaderTextSplitter / HTML)
按标题层级切, 子节点继承父标题作为上下文
优点: 切片携带"我在文档哪一节"信息, 召回准
缺点: 要求文档有清晰结构
适用: 技术文档 / Wiki / 帮助中心
4. 语义切片 (SemanticChunker)
用 embedding 计算相邻句子相似度, 在低相似处切
优点: 语义边界最准
缺点: 慢, 贵, 切出来的 chunk 长度不可控
适用: 高价值长文档 / 论文 / 报告
5. 业务切片 (按"答疑单元"切)
一个 QA 一片 / 一个 SOP 步骤一片 / 一个条款一片
优点: 召回粒度跟用户提问粒度对齐, 最准
缺点: 需要人工或规则预处理
适用: FAQ / 政策制度 / SOP 手册
实际生产中,我推荐的切片流程是:首先按文档类型分流(markdown 走结构化、纯文本走递归字符、FAQ 走业务切片),然后给每个 chunk 加上"上下文标签"——它属于哪篇文档、哪一节、原始位置在哪。这些标签不参与 embedding,但会进入元数据,后续召回时可以过滤、可以展示出处、可以让 LLM 知道"这一段来自哪里"。
from langchain_text_splitters import (
MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter,
)
def chunk_markdown(text: str, source_id: str,
source_title: str) -> list[dict]:
# 一阶段:按标题层级切, 保留 H1 / H2 / H3 作为上下文
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
)
sections = header_splitter.split_text(text)
# 二阶段:对每个 section 再按字符切, 保证 chunk 不超长
char_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, chunk_overlap=80,
separators=["\n\n", "\n", "。", "!", "?", ".", " "],
)
chunks = []
for sec in sections:
path = " > ".join(
sec.metadata.get(k, "") for k in ("h1", "h2", "h3") if sec.metadata.get(k)
)
for i, part in enumerate(char_splitter.split_text(sec.page_content)):
# 关键:embedding 文本前面拼上"上下文路径",让 chunk 自带定位
content_for_emb = f"{source_title} | {path}\n{part}"
chunks.append({
"id": f"{source_id}#{path}#{i}",
"text": part, # 原文用于展示
"embedding_text": content_for_emb, # 实际拿去 embed
"source_id": source_id,
"source_title": source_title,
"section_path": path,
"position": i,
})
return chunks
这段代码里有一个常被忽略的细节:embedding_text 是把文档标题和章节路径拼到了 chunk 前面再做 embedding,而原文 text 只用于最后展示给 LLM。这么做的原因是 embedding 模型理解的是"独立的一段文字",但用户的问题往往隐含了文档背景(比如"配置文件怎么写"——但是哪个产品的配置?)。把上下文路径拼进 embedding 文本,等于给向量加上了"我属于哪一类文档哪一节"的标识,召回准确率能提升 10-30%。
认知翻转:切片不是"把长文档切短"的格式操作,是"为召回设计语义单元"的策略决策。切片做得好,即使用一个普通 embedding 模型召回率也能达到 80%+;切片做得差,即使用顶级模型召回率也上不去 60%。RAG 链路里 80% 的效果差异来自切片策略和模型选型这两件事,而切片策略的优化往往比换模型便宜得多——不增加 API 成本,只需要重新跑一次入库。先在切片上做扎实,再考虑模型升级,顺序反了多半事倍功半。
二、Embedding 模型选型:不是越大越好
Embedding 模型这两年百花齐放,从 OpenAI 的 text-embedding-3-large 到开源的 BGE、E5、JinaAI、阿里 GTE 等几十款。选型的核心维度有四个:语言匹配度、领域适配度、维度与成本、是否需要自部署。
语言匹配度是基础底线。用纯英文模型搜中文内容,效果会比想象中差很多——不是完全不能用,而是召回的 top10 里会有相当一部分是"字面相似但语义无关"的噪音。中文内容必须用支持中文的模型(BGE、M3E、GTE、OpenAI 都支持),不要因为"OpenAI 名气大"就忽视开源中文模型,后者在中文场景上往往不输。
领域适配度是隐藏门槛。通用 embedding 模型在通用语料上训练,对你的垂直业务术语理解未必到位。比如"商品 SKU 怎么生成"对电商人来说是日常,通用模型可能把 SKU 当成噪音 token。判断方法是拿你领域里 20 个典型查询和对应文档,跑一遍召回看 Recall@5——低于 80% 就说明模型跟你领域不够搭,要么换模型,要么 fine-tune。
主流 Embedding 模型对比 (2024 年下半年生产参考)
OpenAI text-embedding-3-large
维度: 3072 (可降到 256 / 512 / 1024)
价格: $0.13 / 1M tokens
优点: 中英文都好, 支持降维, 维护省心
缺点: 必须联网, 数据出境, 成本不低
OpenAI text-embedding-3-small
维度: 1536 (可降维)
价格: $0.02 / 1M tokens
优点: 性价比极高, 一般业务首选
缺点: 精度比 large 略低
BGE-M3 / bge-large-zh-v1.5 (智源)
维度: 1024
价格: 自部署 (一张 V100 可跑)
优点: 中文场景顶级, 完全可私有化
缺点: 自己运维, 显存占用要算
Cohere embed-multilingual-v3.0
维度: 1024
价格: $0.10 / 1M tokens
优点: 100+ 语言, 多语种检索强
缺点: 国内调用不便
Voyage voyage-3
维度: 1024
价格: $0.06 / 1M tokens
优点: 长上下文友好 (32K), 召回质量很好
缺点: 中文支持中等
阿里 GTE-large-zh (灵积)
维度: 1024
价格: 阿里云按调用算
优点: 中文场景顶级, 国内合规, 可直接 API
缺点: 仅限阿里云生态
选型流程建议是:第一步,明确语言(中文 / 英文 / 多语种);第二步,定数据敏感等级(能不能出公司、能不能出国);第三步,跑一个 mini 评测集(20-50 个查询对应文档)对比候选模型;第四步,综合质量、价格、运维成本做最终决策。绝对不要"看哪个 benchmark 排名高就用哪个",benchmark 跟你业务的相关性可能很低。
认知翻转:Embedding 模型选型的核心是"跟你业务的契合度",不是"绝对的模型能力"。一个排行榜上第三名的模型在你的业务上完全可能比第一名好,因为它的训练数据更接近你的内容。判断方法只有一个:拿你自己的数据跑评测集。任何脱离评测集的"哪个模型最好"的讨论都是空话,因为最好永远是"在你的数据上"最好,不是"在通用 benchmark 上"最好。
三、元数据过滤:让"语义相似"再加一道筛子
纯向量检索的一个天然缺陷是它只会按"语义相似度"排序,完全不管别的维度。用户问"今年的请假政策",向量召回的 top10 可能包括 2018 年、2020 年、2022 年的旧政策——它们语义都"相似",但用户要的是 2024 年。这种情况下任何 embedding 模型升级都救不了你,必须靠元数据过滤。
元数据过滤的本质是"把向量检索看作一种 SQL WHERE 条件"。每个 chunk 入库时打上结构化标签(发布时间、文档类型、所属部门、生效状态、权限范围等),查询时根据用户问题或会话上下文先 filter 再做向量检索。下面是一个典型的元数据 schema:
# 入库时为每个 chunk 打上的元数据
chunk_metadata = {
# 基础来源信息
"source_id": "doc_12345",
"source_title": "2024 年员工请假政策 v3",
"source_type": "hr_policy", # policy / faq / sop / api_doc / blog
"source_url": "https://wiki/...",
# 时间维度(用于"按时间过滤" + "新内容优先")
"published_at": "2024-03-15",
"effective_from": "2024-04-01",
"effective_to": "2025-03-31",
"is_active": True,
# 组织维度
"owner_dept": "hr",
"applicable_to": ["all_staff"], # 或 ["engineering"]
"visibility": "internal", # internal / department / restricted
# 内容维度
"language": "zh",
"topics": ["leave", "hr_policy", "annual_leave"],
"section_path": "请假政策 > 年假",
}
def retrieve(query: str, user_dept: str,
current_date: str, top_k: int = 10) -> list[dict]:
query_vec = embed(query)
# 必须满足的硬过滤(filter)
filters = {
"is_active": True,
"visibility": {"$in": ["internal", "department"]},
"effective_from": {"$lte": current_date},
"effective_to": {"$gte": current_date},
"applicable_to": {"$in": ["all_staff", user_dept]},
}
# 在 filter 后的子集里做近邻
results = vector_db.query(
vector=query_vec,
filter=filters,
top_k=top_k,
include_metadata=True,
)
return results
元数据过滤在 Pinecone、Weaviate、Milvus、PG vector 都有原生支持,但实现细节略有差异。一个常见的失误是把所有元数据都设为可过滤——某些向量库对"高基数过滤字段"性能很差(比如 source_id 这种几万个值的字段),要根据具体引擎的最佳实践来选哪些字段建过滤索引、哪些字段只存不过滤。
另一种高级用法是"过滤 + 后处理 boost"。比如同时召回 top 30,然后按"发布时间近 + 部门匹配"给每个结果加权重,最终输出 top 5。这种做法比硬过滤更柔性,能让"新政策但相关性稍低"的内容不至于完全被丢掉。
认知翻转:元数据过滤是 RAG 系统从"能跑"到"真好用"的分水岭。没有它,任何向量召回都会被"看似相似但实际无效"的噪音淹没;有了它,即使是一个普通的向量索引也能给出精准结果。设计 RAG 时元数据 schema 应该和 embedding 模型选型同等重要——前者决定召回准确度的天花板,后者决定起点。把元数据当成一等公民,从入库时就严格打标,而不是"以后再补"——以后再补意味着要全量重跑入库,成本和时间都很贵。
四、混合检索:向量 + BM25 才是完整方案
纯向量检索的另一个短板是"对精确匹配场景不友好"。用户搜"错误码 E-2456"或者"API 端点 /v2/orders/refund"这种关键词强匹配的查询,向量检索会输给传统的 BM25 倒排索引——因为 embedding 模型会把"E-2456"和"E-2457"理解成"非常相似",但用户要的是精确匹配。
真正稳的检索系统是混合检索(hybrid search):同时跑向量检索和 BM25 检索,把两个结果按某种策略融合。最简单的融合策略是 RRF(Reciprocal Rank Fusion):每个文档在每种检索结果里的排名取倒数,加起来排序。
[mermaid]
flowchart TD
A[用户查询] --> B[查询改写 同义词 拼写纠错]
B --> C[向量检索 top 30]
B --> D[BM25 检索 top 30]
C --> E[元数据 filter 二次过滤]
D --> E
E --> F[RRF 融合排序]
F --> G[reranker 重排 top 10]
G --> H[去重 截断 top 5]
H --> I[拼到 prompt 给 LLM]
下面是一个最小的 RRF 融合实现,可以直接生产用:
def rrf_merge(rankings: list[list[str]], k: int = 60) -> list[str]:
"""Reciprocal Rank Fusion
rankings: 每个内层 list 是一种检索器返回的 doc_id 排序结果
k: 平滑常数, 默认 60
返回: 融合后的 doc_id 排序
"""
scores: dict[str, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.keys(), key=lambda x: -scores[x])
def hybrid_search(query: str, top_k: int = 10) -> list[dict]:
# 并行跑两路
vec_results = vector_db.search(embed(query), top_k=30)
bm25_results = bm25_index.search(query, top_k=30)
vec_ids = [r["id"] for r in vec_results]
bm25_ids = [r["id"] for r in bm25_results]
merged_ids = rrf_merge([vec_ids, bm25_ids])[:top_k]
# 按 merged 顺序补回 metadata
by_id = {r["id"]: r for r in vec_results + bm25_results}
return [by_id[i] for i in merged_ids if i in by_id]
混合检索还有一个更精细的做法:给两路结果不同权重,根据查询特征动态调整。比如检测到查询里包含产品编号、错误码、特定 API 路径,就提高 BM25 权重;检测到查询是自然语言描述、模糊问题,就提高向量权重。这种"自适应混合"在生产里能再涨一截召回质量,但实现复杂度也高,建议先用 RRF 跑起来再优化。
BM25 在很多团队里被忽略,是因为大家觉得"都什么年代了还用倒排"。实际上 Elasticsearch / OpenSearch / Tantivy / Pisa 这些 BM25 引擎成熟稳定,部署简单,运维成本低,而且对"用户预期是精确匹配"的查询永远比纯向量准。生产 RAG 系统不上 BM25,等于自废一半武功。
认知翻转:RAG 不应该是"纯向量检索",而是"向量 + BM25 + filter + rerank"的多阶段流水线。每一阶段解决一种问题,缺一不可。纯向量检索是 RAG 的入门版,真正生产可用的版本必须把这条链路补完整。很多团队上线后效果不好,排查半天发现根本不是模型问题,而是"只用了向量没用 BM25"——这是 RAG 工程里最常见的 1 号致命错误。
五、Reranker:让 top 10 真正变成 top 10
即使有了混合检索 + filter,召回回来的 top 10 排序也未必准。原因是 embedding 模型和 BM25 都是"快速但粗糙"的相似度判断,它们能告诉你"这 10 个文档跟问题相关",但很难精准告诉你"这 10 个之间谁更相关"。这一步要交给 reranker。
Reranker(交叉编码器,cross-encoder)是一种小型 transformer 模型,输入是"查询 + 单个候选文档",输出是一个 0-1 的相关性分数。它比 embedding(双塔编码)精度高得多,因为它能让 query 和 doc 在同一个模型里做注意力交互,捕获细粒度匹配关系。代价是慢——单次只能算一对,所以只能用在召回之后对 top 10-30 重排,不能拿来做大规模召回。
# 用 BGE Reranker 重排 (开源, 可自部署)
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
def rerank(query: str, candidates: list[dict],
top_n: int = 5) -> list[dict]:
# 一次性算所有 (query, doc) 对的分数
pairs = [[query, c["text"]] for c in candidates]
scores = reranker.compute_score(pairs)
# 按分数降序排
for c, s in zip(candidates, scores):
c["rerank_score"] = float(s)
candidates.sort(key=lambda x: -x["rerank_score"])
return candidates[:top_n]
def full_rag(query: str, top_n: int = 5) -> list[dict]:
# 1. 召回 top 30 (向量 + BM25 + filter)
candidates = hybrid_search(query, top_k=30)
# 2. reranker 重排
top = rerank(query, candidates, top_n=top_n)
# 3. 截断到喂给 LLM 的最终数量
return top
Reranker 的常用选项是 BGE Reranker(中文好,开源)、Cohere Rerank(API 服务,质量很高)、Voyage Rerank(API 服务,长文本友好)、Jina Reranker(开源中文 OK)。生产里推荐 BGE Reranker 自部署——一张消费级 GPU 就能跑,单次重排 30 个候选大概 50-100ms,完全可接受。Cohere Rerank 质量更稳一点但要联网且按次收费,适合数据敏感不强的场景。
是否值得加 reranker,可以拿你的评测集做对照实验:跑一次"只召回"的 Recall@5,再跑一次"召回 + rerank" 的 Recall@5,如果差距超过 5 个百分点就值得加。我个人的经验是,90% 的中文 RAG 场景加 reranker 都能涨 10-20 个百分点,这是性价比极高的工程优化。
认知翻转:Reranker 是 RAG 的"最后一公里",它能把召回阶段的粗糙排序变成真正精准的排序。很多团队不上 reranker 的原因是"觉得贵或慢",但 30 个候选的重排在自部署下只要 100ms,远快于后续 LLM 调用的 1-2 秒,完全在用户感知容忍内。砍 reranker 是省钱省错地方了——真正应该砍的是 top_k(召回数量),而不是 reranker。一个"召回 30 → rerank top 5"的链路在大多数场景下完爆"召回 100 不 rerank"。
六、工程坑:那些"上线后才发现"的细节
除了上面五节讲的主要话题,真实生产里还有一堆"教程不教但你一定撞上"的细节。挑几个最常见、最坑的:
第一,embedding 和 chunk 一定要做增量更新,不要每次知识库改动就全量重建。设计上每个 chunk 入库时记下 source_hash(原文 SHA256),源文档更新时只重 embed 内容变化的 chunk,删除已不存在的 chunk。这样几百万文档的库改一篇也只要重算几个 chunk:
import hashlib
def upsert_doc(doc_id: str, new_text: str, metadata: dict) -> dict:
new_chunks = chunk_markdown(new_text, doc_id,
metadata["source_title"])
new_hashes = {c["id"]: hashlib.sha256(
c["text"].encode()).hexdigest() for c in new_chunks}
# 查老的 chunk 状态
old = vector_db.fetch_by_source(doc_id) # 返回 [{id, hash}]
old_hashes = {o["id"]: o["hash"] for o in old}
to_add = [c for c in new_chunks
if old_hashes.get(c["id"]) != new_hashes[c["id"]]]
to_remove = [oid for oid in old_hashes
if oid not in new_hashes]
if to_add:
vecs = embed_batch([c["embedding_text"] for c in to_add])
for c, v in zip(to_add, vecs):
c["embedding"] = v
c["hash"] = new_hashes[c["id"]]
c.update(metadata)
vector_db.upsert(to_add)
if to_remove:
vector_db.delete(to_remove)
return {"added": len(to_add), "removed": len(to_remove),
"total_chunks": len(new_chunks)}
第二,向量库的"硬限制"要提前查清楚。Pinecone 元数据每条最大 40 KB、每秒查询有上限;Weaviate 单 collection 推荐不超过 1 亿个 vector;PG vector 在 100 万向量以内顺手,超过要上 IVFFlat/HNSW 索引并精调参数。选型时按 1-3 年的预期数据量做容量规划,而不是按当前。
第三,embedding 调用要做批处理 + 重试 + 限流。OpenAI 单次最多 2048 条 / 总长 8192 token,超了 400;失败要指数退避;并发要尊重 RPM 限制。生产里一个常见错误是同步串行 embed 几万条文档,跑两小时才发现一半 429 失败了。
第四,中文场景要考虑分词对 BM25 的影响。Elasticsearch 默认 standard 分词对中文是"一字一词",效果很差,必须装 IK 或 jieba 插件。OpenSearch 同理。这一步不做,BM25 的效果会比英文场景差一大截,影响混合检索整体表现。
第五,查询改写(query rewriting)是被低估的环节。用户问"那个怎么办"或者只写半句,直接拿原文 embedding 召回率会很低。生产做法是用一个小 LLM 把用户问题改写成"自包含的标准问句"再 embed,准确率能涨一截:
REWRITE_PROMPT = """请把用户的提问改写成一个自包含、关键词清晰、
适合在知识库检索的标准问句。如果原问题已经清晰则原样返回。
不要回答问题, 只输出改写后的问句。
历史对话:
{history}
用户原问题: {query}
改写后问句:"""
def rewrite_query(query: str, history: str = "") -> str:
resp = client.chat.completions.create(
model="gpt-4o-mini", # 改写用便宜模型
temperature=0,
max_tokens=100,
messages=[{
"role": "user",
"content": REWRITE_PROMPT.format(history=history, query=query),
}],
)
return resp.choices[0].message.content.strip()
第六,召回回来的内容要去重再喂给 LLM。同一篇文档的相邻 chunk 经常都被召回,合并去重后能省 token 也能让 LLM 看到的内容更聚焦。简单做法是按 source_id 分组,每组最多保留 2 个 chunk。
第七,要做"无答案兜底"。如果 reranker 后的最高分都低于某个阈值(比如 0.5),说明知识库里没有相关内容,应该让 LLM 直接回复"暂未找到相关信息"而不是强行编造。这个阈值要在评测集上调出来,而不是拍脑袋。
第八,引用要给出处。每条召回内容拼进 prompt 时带上 source_url 或 source_title,让 LLM 回答时附上"详见 XXX 文档",这对企业知识库场景是合规和信任的关键。
第九,要做"召回内容长度控制"。每个 chunk 单独长度限制 + 总输入长度限制 + 留给输出的预算,这三件事要同时管,不然某些大 chunk 会一举吃掉所有上下文。
第十,要做向量库的备份和版本管理。Embedding 重跑一次很贵,误删一个 collection 损失巨大。生产里至少要每天 snapshot,关键变更要打 tag 方便回滚。
认知翻转:RAG 工程量的 90% 在 embedding 调用之外,集中在"切片 + 元数据 + 混合检索 + reranker + 增量更新 + 评测 + 监控"这些环节。模型调用本身的代码量很小,真正决定项目成败的是这些"被低估的工程细节"。一个团队的 RAG 系统能不能走到生产可用,看这些细节做得扎不扎实就知道。把这些事提前做对,即使用一个普通 embedding 模型也能做出顶级 RAG;反过来用顶级模型但工程粗糙,效果会让人怀疑模型是不是坏了——其实坏的是整个流水线。
关键概念速查
| 概念 | 含义 | 常见误区 | 正确做法 |
|---|---|---|---|
| 切片策略 | 把长文档切成检索单元 | 固定字符切, 不顾结构 | 按文档类型分流, 结构化文档按标题切 |
| Embedding 模型 | 把文本转成向量 | 看排行榜直接选 | 用自己的评测集对比 |
| 元数据过滤 | 按时间/部门/权限筛子集 | 没有, 纯向量召回 | 必须有, RAG 系统标配 |
| BM25 | 传统倒排关键词检索 | 觉得过时不用 | 跟向量并存做混合检索 |
| 混合检索 | 向量 + BM25 融合 | 只用向量 | RRF 融合, top 30 → rerank |
| Reranker | 交叉编码器精排 | 不用 | 对召回 top 20-30 精排到 top 5 |
| 查询改写 | 把用户问句标准化 | 原文直接 embed | 小模型改写再 embed |
| 增量更新 | 只 embed 变化部分 | 每次全量重建 | 用 source_hash 对比变化 |
| 无答案兜底 | 低分时不强答 | 什么分数都喂给 LLM | 设阈值, 低于阈值直说没找到 |
| 引用出处 | 回答附 source | 不附 | 每个 chunk 带 url, 回答里展示 |
避坑清单
- 不要用固定字符切片把所有文档都切成 1000 字一段,按文档类型分流,结构化文档走标题切,FAQ 走业务切片。
- 不要直接用 OpenAI 通用模型搜中文垂直内容,先用评测集对比 BGE/GTE 等中文模型,效果可能反超。
- 不要忘了元数据过滤,纯向量召回会被"语义相似但时间过期 / 部门不符 / 权限不够"的噪音淹没。
- 不要只用向量检索,必须叠加 BM25 做混合检索,精确匹配类查询纯向量根本搞不定。
- 不要省掉 reranker,召回 top 30 → rerank top 5 的链路完爆"召回 top 100 不 rerank"。
- 不要每次知识库改动都全量重建,要按 source_hash 增量更新,几百万文档库改一篇只重算几个 chunk。
- 不要忽视查询改写,用户提问含糊或带上下文时,先用小 LLM 改写成标准问句再 embed 准确率能涨。
- 不要不打元数据 schema 就入库,以后再加元数据要全量重跑,几千美元烧一次。
- 不要没设召回阈值就喂给 LLM,reranker top1 分数太低应该走"找不到答案"分支,避免幻觉。
- 不要忘了引用出处,每个 chunk 必须带 source_url / source_title,回答里展示是企业场景的合规底线。
总结
向量检索和 RAG 是 LLM 应用里"看起来最简单、实际最深"的一类。简单是因为 SDK 文档把所有步骤都封装好了,十几行代码就能跑一个"似乎能工作"的版本;深是因为每一步的默认值都是"满足通用 demo 的妥协",换到你的业务上几乎都要重新调。一份生产可用的 RAG 方案不取决于代码有多少行,取决于"每一个环节是否都按你的业务场景做了取舍"。
另一层被严重低估的是,RAG 的效果"上限"主要由非模型因素决定。切片粒度、元数据 schema、混合检索权重、reranker 选型、查询改写质量、评测集覆盖度,这些每一项都能让最终召回质量浮动 10-20 个百分点,而它们都跟"用了什么 embedding 模型"没直接关系。换个更大的模型只能让你从 70 分涨到 75 分,把工程链路补完整能让你从 70 分涨到 90 分。前者花钱,后者花心思,后者性价比高得多。
打个不太严谨的比方,做 RAG 有点像运营一家图书馆。Embedding 是你给每本书贴的"内容指纹标签",但光有标签不够——你还要有书架分类(切片)、按出版年代部门主题贴的小标签(元数据)、目录卡片+全文搜索两套系统(混合检索)、图书管理员看到读者来询后帮忙再精选(reranker)、新书入库时只动变化部分(增量更新)、读者问得含糊时帮忙明确需求(查询改写)。一家好图书馆这些环节缺一不可,只用 embedding 标签做检索就好比图书馆只有"内容指纹"没有书架分类——读者来了不知道往哪走,书也找不到。
所以做 RAG,本地跑通几个 demo case 永远暴露不了真正的问题。它暴露不了上线后用户的真实问题分布跟你想象的天差地别,暴露不了切片不当导致整本手册都召不回某个具体功能,暴露不了过期文档把新政策永远压在第五页,暴露不了用户搜错误码时纯向量搜不到,暴露不了知识库一更新就要全量重建的成本震惊,更暴露不了 reranker 没上线时 top 5 排序的混乱无章。真正的检验在生产环境,在第一次用户大规模使用的一周,在一次知识库批量更新的早晨,在一次老板试用时问了个具体问题没召回的尴尬午后。把上面六节里的功夫提前做扎实,等那些时刻到来时,你会感谢自己当初没图省事。如果你正在做或者准备做 RAG 系统,请把它当成一个"切片 + 检索 + 排序 + 更新"的多阶段工程系统设计,而不是"调几个 embedding API 的脚本"——这是从 demo 到生产最关键也最容易被忽略的认知差。
—— 别看了 · 2026