RAG 知识库上线后问什么都答非所问:一次 embedding 模型不一致的复盘

一个内部文档 RAG 问答系统测试时问什么答什么,一上线就问什么都答非所问问报销流程答考勤制度,同一套代码同一批文档同一个大模型本地好端端线上成胡言乱语。排查梳理:先写脚本只跑检索把召回的原始 chunk 打出来看发现召回的全是不相关内容而且相似度分数全挤在 0.7 上下挤成一团这本身就是危险信号,grep 查建索引链路的 embedding 配置是 bge-large-zh,再 grep 查在线查询服务的 embedding 配置却是 text-embedding-ada-002 两个完全不同的模型;核心根因 embedding 在 RAG 里被调用两次建索引离线把文档块算成向量灌进库和查询在线把问题算成向量,这两步必须用同一个模型否则废掉,两个不同模型产出的向量活在两个完全不同毫不相干的向量空间同一句话 bge 和 ada 算出来数值没半点关系,检索是拿问题向量和文档向量算距离找最近现在一个在 ada 空间一个在 bge 空间这距离纯是噪声所谓挑最近退化成从噪声里掷骰子,最坑的是两个模型维度恰好相同时向量库只认维度不认模型不报任何错静默返回垃圾错误无声才致命;embedding 本质把文本映射成向量语义近则向量距离近但这套距离约等于语义的对应是某个模型训练时自己建立的私有坐标系跨模型不可比,换模型必须用新模型重算整个库;文本分块的坑 chunk 太大向量没重点太小语义被切碎按标题段落自然边界切相邻块留 overlap 每块带元数据;相似度度量向量要显式归一化后统一用 cosine 别赌默认行为,只取 Top-K 不设阈值库里没相关内容也会硬塞垃圾大模型拿垃圾就胡编必须设相似度阈值检索不到老实说没有。正确做法是答非所问先打印召回 chunk 分清检索坏还是生成坏建索引和查询必须同一个 embedding 模型,以及一套 RAG 检索排查纪律。

2024 年,一次"我们的内部知识库 RAG 问答系统,测试时问什么答什么、一上线就问什么都答非所问"的事故,把我对"向量检索"这件事的理解,从头到尾翻新了一遍。我们给公司内部文档做了一个 RAG 问答:把几百篇文档切成小块、算成向量、存进向量库;用户提问时,把问题也算成向量,去库里找最相近的几块,连同问题一起喂给大模型,让它据此作答。在我的开发机上,这套东西跑得漂亮极了——我问"报销流程是什么",它稳稳地从《财务报销手册》里捞出对应段落,答得有理有据。我很满意,打包上线。结果上线第一天,用户的反馈雪片一样飞来:问"报销流程",它答的是考勤制度;问"如何申请 VPN",它扯到了食堂菜单。问什么,都答非所问。我懵了。同一套代码、同一批文档、同一个大模型——我本地好端端的,换到线上就成了胡言乱语。我第一反应是大模型的锅,换了 prompt、换了模型,没用。我又怀疑是文档没同步,登上去一看,文档一篇不少。那一刻我是真的糊涂了:检索,不就是"把相近的找出来"吗?文档在库里、问题也算成了向量,它怎么会把八竿子打不着的东西"找"成最相近的?如果向量检索找出来的根本不是"相近"的——那它找出来的,到底【按什么标准】最相近?我以为我存进去的是"语义",可线上这套东西,显然在拿另一套标准做匹配。这件事逼着我把 embedding 到底是什么、向量空间为什么不可跨模型比较、文本分块的讲究、相似度度量与归一化,还有 RAG"答非所问"该怎么定位,彻底理清了。本文复盘这次实战。

问题背景

环境:一个内部文档 RAG 问答系统(文档分块 + 向量库 + 大模型)
事故现象:
- 测试环境:问什么答什么,检索精准
- ★ 上线后:问什么都答非所问,检索召回的全是不相关内容
- 同一套代码、同一批文档、同一个大模型

现场排查:
# 1. ★ 先把"检索这一步召回了什么"打出来看
$ python debug_retrieve.py "报销流程是什么"
# Top1: [考勤制度] 员工每月...        (score 0.71)
# Top2: [食堂安排] 本周菜单...        (score 0.70)
# Top3: [VPN 申请] 申请流程如下...    (score 0.69)
# ★★ 召回的全是不相关的,而且 score 都在 0.7 上下挤成一团

# 2. ★ 对比:本地测试环境,同一个问题
# Top1: [财务报销手册] 报销分三步... (score 0.89)  ★ 本地是对的

# 3. ★ score 全挤在 0.7 附近,这本身是个危险信号
#    正常检索:相关的 0.85+,不相关的 0.5 以下,拉得开
#    现在全挤在 0.7 —— 像是在做"随机"匹配

# 4. ★★ 关键:查"建索引"时用的 embedding 模型
$ grep -rn 'embedding' indexer/config.yaml
# embedding_model: bge-large-zh-v1.5        # ★ 建索引用的是 bge

# 5. ★★ 查"在线查询服务"用的 embedding 模型
$ grep -rn 'embedding' query_service/config.yaml
# embedding_model: text-embedding-ada-002   # ★★ 查询用的是 OpenAI ada!

根因(后来想清楚的):
1. ★ 向量库里的索引,是离线用 bge-large-zh 这个模型,
   把每个文档块算成向量、灌进去的。
2. ★★ 而在线查询服务,把用户问题算成向量时,用的是
   另一个模型 text-embedding-ada-002。
3. ★ 两个不同的模型,产出的向量活在【两个完全不同、
   毫不相干的向量空间】里。同一句话,bge 算出来的
   向量,和 ada 算出来的向量,数值上没有半点关系。
4. ★ 检索 = 拿"问题向量"去和"文档向量"算距离、找最近。
   现在问题向量在 ada 空间、文档向量在 bge 空间 ——
   这个"距离",算的是两个无关坐标系里的点,纯是噪声。
5. ★ 两个模型的输出维度恰好能对上(没报维度错),
   所以系统【不报任何错】,静默地返回一堆随机结果。
6. ★ 本地测试时,建索引和查询在同一进程、同一份配置,
   用的是同一个模型,所以是对的;上线拆成两个服务,
   配置各写各的,模型就此分叉了。
真相:embedding 向量,只有在【同一个模型】产出时
才可比较。建索引和查询,必须用同一个 embedding 模型。

修复 1:RAG 答非所问——先分清坏在"检索"还是"生成"

# === ★ RAG 出问题,第一刀:切开"检索"和"生成" ===

# === ★ RAG 是两段式的,别把它当一个黑盒 ===
# ★ 一个 RAG 问答,内部是【两步】:
#  - ① 检索(Retrieval):把用户问题变成向量,去
#    向量库里捞出最相关的几个文档块(chunk)。
#  - ② 生成(Generation):把"问题 + 捞回的 chunk"
#    一起塞进 prompt,让大模型据此作答。
# ★ "答非所问"这一个症状,两步都可能是元凶:
#  - 检索坏了:捞回来的 chunk 本身就不相关 -> 大模型
#    拿着错资料,当然答错;
#  - 生成坏了:chunk 捞对了,但 prompt 写得烂、或
#    模型能力不够 -> 守着对的资料也答跑偏。
# ★ ★ 不先分清是哪一步,你换 prompt、换模型,很可能
#   全是白费功夫(本文我就先白折腾了半天)。

# === ★★ 决定性的一步:把"检索召回的原始 chunk"打出来 ===
# ★ 别只盯着大模型最后吐出来的那段答案。要在检索
#   之后、喂给大模型之前,把召回的 chunk 原文【打印
#   出来】—— 这是定位 RAG 问题最关键的一个动作。

# === ★ 一个强信号:看召回的相似度分数分布 ===
# ★ 健康的检索:相关 chunk 分数明显高(0.85+),
#   不相关的明显低 —— 分数【拉得开】。
# ★ ★ 本文的现象:Top1~Top3 分数全挤在 0.70 附近,
#   挤成一团、谁也不突出。这是个典型的【坏味道】——
#   它说明这个检索根本没在"区分相关与否",更像在
#   做随机匹配。分数挤成一团,八成是向量空间出了事。

# === 认知 ===
# ★ RAG = 检索 + 生成两段,"答非所问"两段都可能是
#   元凶,不先分清就瞎调 prompt/换模型多半白费。★★
#   决定性动作:把"检索召回的原始 chunk"在喂给大模型
#   之前打印出来 —— 召回的 chunk 相关 = 检索 OK 去查
#   生成;召回的 chunk 不相关 = 检索坏了。另外看分数
#   分布,正常该拉得开,全挤成一团是向量空间出事的
#   强信号。
# debug_retrieve.py —— 只跑"检索",不跑"生成",单独验检索这一步
import sys
from my_rag import embed, vector_db

query = sys.argv[1]
qvec = embed(query)                        # 问题 -> 向量
hits = vector_db.search(qvec, top_k=3)     # 向量库检索 Top3
for i, h in enumerate(hits, 1):
    print(f'Top{i}: score={h.score:.3f}')
    print(f'  来源: {h.metadata["doc"]}')
    print(f'  原文: {h.text[:80]}')
# ★ 看这个输出一秒判断:召回的 chunk 和问题相关吗?
#  - 相关   -> 检索没问题,去查生成(prompt / 模型);
#  - 不相关 -> 检索坏了(本文),后面生成再调都没用。

修复 2:核心根因——建索引和查询,用了两个不同的 embedding 模型

# === ★ 把这次事故的总根,挖出来 ===

# === ★ 一个 RAG 系统里,embedding 在【两个地方】被调用 ===
# ★ 务必看清:embedding(把文本算成向量)这件事,在
#   RAG 的生命周期里发生【两次】,在两个不同的时间、
#   可能两个不同的服务里:
#  - ① 建索引时(离线):把每一个文档 chunk 算成
#    向量,灌进向量库。这步通常是个离线脚本 / 任务。
#  - ② 查询时(在线):把用户的每一个问题算成
#    向量,拿去检索。这步在在线服务里。
# ★ ★★ 铁律:这两步,【必须用同一个 embedding 模型】。
#   一旦不一致,整个检索就废了。

# === ★ 怎么查这两步到底用了什么模型 ===
$ grep -rn 'embedding' indexer/config.yaml
# embedding_model: bge-large-zh-v1.5
$ grep -rn 'embedding' query_service/config.yaml
# embedding_model: text-embedding-ada-002    # ★★ 不一样!
# ★ 本文的真相就在这两行里:建索引用 bge-large-zh,
#   查询用 OpenAI 的 ada —— 两个【完全不同】的模型。

# === ★★ 为什么"维度对得上"反而更坑 ===
# ★ 你可能会想:模型不一样,系统不该报错吗?
# ★ ★ 不会。向量库做检索,它只认【维度】。如果两个
#   模型输出维度不同(如 bge 1024 维、ada 1536 维),
#   向量库插入 / 检索时会直接报"维度不匹配"的错,
#   你反而能立刻发现。
# ★ ★★ 最坑的是【两个模型维度恰好相同】:向量库一看
#   维度对得上,二话不说就给你算距离、返回结果 ——
#   它【根本无法知道】这两组向量来自不同模型。于是
#   系统【不报任何错】,静默地返回一堆垃圾。错误是
#   无声的,这才致命。

# === ★ 为什么本地测试时是好的 ===
# ★ 本地开发时,建索引和查询跑在同一个进程、同一份
#   配置里,自然用的是同一个模型 -> 检索精准。
# ★ 上线后,架构拆成了"离线 indexer + 在线 query
#   service"两个服务,各有各的 config 文件 —— 有人
#   改了其中一个的模型配置,另一个没跟着改,两边
#   就此分叉。本地测不出来,一上线就爆。

# === 认知 ===
# ★ RAG 里 embedding 被调用【两次】:建索引(离线、
#   把文档 chunk 算成向量)和查询(在线、把问题算成
#   向量)。★★ 铁律:这两步必须用【同一个 embedding
#   模型】。最坑的是两个模型维度恰好相同时,向量库
#   只认维度、不认模型,会【不报错】地返回垃圾 ——
#   错误是无声的。本地建索引和查询同进程同配置所以
#   没事,上线拆成两个服务、配置各写各的就分叉了。

修复 3:embedding 的本质——不同模型的向量空间不可比

# === ★ 这一节讲透:为什么"换个模型"就全乱了 ===

# === ★ embedding 到底是什么 ===
# ★ embedding,就是把一段文本,映射成一串固定长度的
#   数字(一个向量)。比如把"报销流程"这几个字,变成
#   [0.12, -0.05, 0.88, ...] 这样 1024 个数。
# ★ ★ 它的核心价值在于:这个映射是"讲道理"的 ——
#   语义相近的文本,算出来的向量在空间里【离得近】;
#   语义无关的,【离得远】。于是"找语义相近的文本"
#   就变成了"找距离近的向量",可以高效计算。

# === ★★ 关键:这套"距离≈语义"的对应,是某个模型私有的 ===
# ★ 一个 embedding 模型,是被训练出来的。训练的过程,
#   本质是它【自己建立了一套坐标系】—— 在这套坐标系
#   里,它把它理解的"语义",编码成了具体的坐标。
# ★ ★★ 致命的点:每个模型建立的坐标系,是它【自己
#   独有】的。模型 A 把"报销"放在它坐标系里的某个
#   位置,模型 B 把"报销"放在它坐标系里的另一个位置
#   —— 这两个位置的数值,【没有任何可比性】,就像
#   一个用经纬度、一个用门牌号,都在描述位置,但
#   数字不能直接相减。

# === ★ 于是,本文的"答非所问"彻底解释清楚了 ===
# ★ 检索这一步做的事:拿【问题向量】,去和库里每个
#   【文档向量】算距离,挑距离最近的几个。
# ★ 现在:文档向量在 bge 的坐标系里,问题向量在 ada
#   的坐标系里。拿它俩算距离 —— 等于拿"北京的经度"
#   和"上海的门牌号"做减法。算出来是个数,但【毫无
#   意义】。
# ★ ★ 所谓"挑距离最近的几个",此刻就退化成了【从一堆
#   噪声里挑几个】—— 跟掷骰子没区别。这就是为什么
#   召回全是不相关内容、分数还全挤在 0.7:那 0.7 不是
#   "有点相关",是噪声的平均水平。

# === ★ 由此推出几条铁律 ===
# ★ ① 建索引 和 查询,★ 必须用【同一个 embedding
#   模型】,连版本都要一致(bge-v1.5 和 bge-v1.0 也是
#   两个模型)。
# ★ ② 哪天你要【换 embedding 模型】(升级、换供应商)
#   —— 必须把整个向量库,用新模型【全部重新算一遍】
#   (re-index)。绝不能新老向量混在一个库里。
# ★ ③ 把 embedding 模型名 + 版本,作为向量库的元数据
#   记下来 —— 让"这个库是哪个模型建的"有据可查。

# === 认知 ===
# ★ embedding 把文本映射成向量,核心是"语义相近 ->
#   向量距离近"。★★ 但这套"距离≈语义"的对应,是某个
#   模型训练时【自己建立的私有坐标系】—— 模型 A 和
#   模型 B 的坐标系毫不相干,同一句话两个模型给的
#   向量数值没有可比性。跨模型算距离 = 噪声。铁律:
#   建索引和查询用同一模型(含版本);换模型必须用
#   新模型重算整个库;把模型名 + 版本记进库的元数据。
# 亲眼看:同一句话,两个模型给出毫不相干的向量
text = "报销流程是什么"
v_bge = embed_with_bge(text)      # bge 模型
v_ada = embed_with_ada(text)      # ada 模型

print(v_bge[:4])   # [ 0.021, -0.118,  0.067,  0.090]
print(v_ada[:4])   # [-0.044,  0.012, -0.205, -0.031]
# ★ 同一句话,两个模型产出的向量,数值上毫无关系。
#   它们各自都是【对的】—— 但它们活在两个宇宙里,
#   拿一个宇宙的坐标去另一个宇宙找邻居,只能找到噪声。

修复 4:文本分块——chunk 的大小、切点与重叠

# === ★ 检索质量的另一半,在"文档怎么切块" ===

# === ★ 为什么文档要"切块"再存 ===
# ★ 你不能把一篇一万字的文档,整篇算成一个向量。
#   原因:① embedding 模型有输入长度上限,超了会被
#   【截断】;② 一整篇文档塞进一个向量,等于把无数
#   个主题"平均"成一个点 —— 这个向量谁都不像,检索
#   时谁都匹配不上。
# ★ 所以要把文档切成一个个【语义相对完整的小块】
#   (chunk),每块单独算向量、单独存。

# === ★ 坑 1:chunk 太大 / 太小 ===
# ★ ★ 太大:一个 chunk 里塞了好几个主题,它的向量
#   被多个主题"拉扯",变得没重点 -> 检索不精准,
#   而且可能超模型输入上限被悄悄截断。
# ★ ★ 太小:把一句话、半个段落单独成块,语义被切碎、
#   上下文丢光 -> 检索出来是个残句,大模型看不懂。
# ★ 经验值:中文一般 300~600 字一个 chunk,按你的
#   文档类型和 embedding 模型上限去调。

# === ★★ 坑 2:在句子中间"硬切" ===
# ★ 最糙的切法:不管三七二十一,每 500 字一刀。这一刀
#   很可能正好切在一句话、一个表格的中间 -> 切出两个
#   都不完整的残块。
# ★ ★ 正确做法:【优先按自然边界切】—— 先按标题 /
#   章节,再按段落,再按句子。尽量让每个 chunk 是一个
#   语义自洽的单元。

# === ★★ 坑 3:相邻 chunk 不留重叠(overlap)===
# ★ 假设答案那句话,正好横跨在两个 chunk 的切割线上
#   —— 切完后,前一块只有半句、后一块只有半句,哪一
#   块都不完整,检索哪块都答不全。
# ★ ★ 解法:相邻 chunk 之间留一段【重叠】(overlap),
#   比如每块结尾的 50~100 字,也放进下一块的开头。
#   这样跨切割线的内容,总有一个完整 chunk 包住它。

# === ★ 坑 4:chunk 丢了"它从哪来" ===
# ★ 每个 chunk 存进向量库时,一定要带上【元数据】:
#   它来自哪篇文档、哪个章节、原始位置。
# ★ 这样检索命中后,你能告诉用户"答案出自《X 手册》
#   第 3 节",也方便排查、回溯。

# === 认知 ===
# ★ 文档必须切块再存:整篇算一个向量会超模型输入上限
#   被截断、且多主题被"平均"成谁都不像的点。切块的
#   坑:① chunk 太大向量没重点、太小语义被切碎,中文
#   经验 300~600 字;②★ 别在句子中间硬切,按标题/段落
#   等自然边界切;③★ 相邻 chunk 留 overlap,避免答案
#   正好跨在切割线上;④ 每个 chunk 带元数据记住它来自
#   哪篇文档哪节。
def chunk_text(text, chunk_size=500, overlap=80):
    """按段落聚合、带重叠地切块"""
    paras = [p for p in text.split('\n') if p.strip()]
    chunks, buf = [], ''
    for p in paras:
        if len(buf) + len(p) <= chunk_size:
            buf += p + '\n'
        else:
            chunks.append(buf.strip())
            buf = buf[-overlap:] + p + '\n'   # ★ 新块开头带上一块结尾 overlap 字
    if buf.strip():
        chunks.append(buf.strip())
    return chunks

# ★ 每个 chunk 入库时带元数据:{'doc': '财务报销手册', 'section': '3. 报销流程'}

修复 5:相似度度量与归一化的坑

# === ★ 向量都对了,"算距离"这一步还有坑 ===

# === ★ 坑 1:cosine 还是 dot product,要和向量归一化匹配 ===
# ★ 算两个向量"有多近",常见三种度量:
#  - cosine similarity:看两个向量的【夹角】,只管
#    方向、不管长度。
#  - dot product(点积):既受方向、也受长度影响。
#  - L2 距离:看两点之间的直线距离。
# ★ ★ 关键:有的 embedding 模型,输出的向量是【已
#   归一化】的(长度为 1);有的【没归一化】。
#  - 向量已归一化时,cosine 和 dot product 等价;
#  - ★ 向量没归一化、你却用 dot product —— 长度长的
#    向量会被"偏爱",检索结果失真。
# ★ 稳妥做法:★ 自己把向量【显式归一化】,然后统一
#   用 cosine。别赌模型默认归没归一化。

# === ★★ 坑 2:只取 Top-K,不设相似度阈值 ===
# ★ 检索一般写成 search(qvec, top_k=3) —— "给我最近
#   的 3 个"。
# ★ ★ 陷阱:如果你的知识库里【根本没有】能回答这个
#   问题的内容,top_k=3 仍然会【硬塞 3 个】给你 ——
#   它们是"最不相关里相对没那么不相关"的 3 个,本质
#   还是垃圾。大模型拿着垃圾,就开始一本正经地胡编。
# ★ 解法:Top-K 之外,★ 再设一个【相似度阈值】,
#   低于阈值的直接丢弃 —— 检索不到就老实说没有。

# === ★ 坑 3:阈值不能跨模型照搬 ===
# ★ 不同 embedding 模型,相似度分数的"手感"不同 ——
#   有的模型,相关内容打 0.9;有的,相关内容只打 0.6。
#   所以阈值是【跟着模型走】的,换了模型,阈值要
#   重新拿数据标定。

# === 认知 ===
# ★ 算距离这步的坑:①★ cosine 看夹角、dot product
#   还受长度影响 —— 模型输出向量有的归一化有的没有,
#   稳妥做法是自己显式归一化后统一用 cosine,别赌默认
#   行为;②★★ 只取 Top-K 不设阈值,库里没相关内容时
#   也会硬塞 K 个垃圾,大模型拿垃圾就胡编 —— 必须再设
#   相似度阈值,低于阈值视为"没检索到";③ 阈值跟着
#   模型走,换模型要重新标定。
import numpy as np

def normalize(v):
    v = np.asarray(v, dtype='float32')
    n = np.linalg.norm(v)
    return v / n if n > 0 else v          # ★ 显式归一化,长度归到 1

# ★ 建索引和查询,向量都过一遍 normalize,再统一用 cosine

# === Top-K 之外加阈值过滤 ===
hits = vector_db.search(normalize(qvec), top_k=5)
good = [h for h in hits if h.score >= 0.80]   # ★ 阈值:太低的视为没检索到
if not good:
    answer = "知识库中没有找到相关内容"        # ★ 老实承认,别硬答
else:
    context = "\n\n".join(h.text for h in good)
    answer = llm_generate(query, context)

修复 6:RAG 检索排查纪律

# === 这次事故暴露的认知盲区,定几条纪律 ===

# === 1. ★ RAG 答非所问,先切开检索和生成,别上来就瞎调 prompt ===
$ python debug_retrieve.py "某个问题"     # 把检索召回的原始 chunk 打出来

# === 2. ★ 召回 chunk 不相关 = 检索坏;chunk 相关但答跑偏 = 生成坏 ===

# === 3. ★★ 建索引 和 查询,必须用同一个 embedding 模型(连版本一致)===
$ grep -rn 'embedding' indexer/config.yaml query_service/config.yaml

# === 4. ★ 召回的相似度分数全挤成一团,是向量空间出事的强信号 ===

# === 5. ★ 换 embedding 模型,必须用新模型重算整个向量库(re-index)===

# === 6. ★ chunk 别太大太小,按自然边界切,相邻块留 overlap ===

# === 7. ★ 每个 chunk 带元数据,记住它来自哪篇文档哪节 ===

# === 8. ★ 向量显式归一化后统一用 cosine,别赌模型默认行为 ===

# === 9. ★ Top-K 之外要设相似度阈值,没相关内容就老实说"没有" ===

# === 10. 排查 RAG"答非所问"的步骤链 ===
$ python debug_retrieve.py "某个问题"      # ① 看检索召回了什么
# 召回相关   -> 去查生成(prompt / 模型能力)
# 召回不相关 -> ② 查两条链路的 embedding 模型一不一致
#            -> ③ 查向量归一化、相似度度量、阈值
#            -> ④ 查 chunk 切分质量(大小 / 切点 / overlap)

命令速查

需求                          做法 / 命令
=============================================================
单独验证检索这一步            只跑 embed + vector_db.search,打印召回 chunk
查两条链路的 embedding 模型   grep -rn 'embedding' indexer/ query_service/
判断检索好坏                  看召回 chunk 相不相关 + 分数拉不拉得开
换了 embedding 模型           用新模型重算整个向量库(re-index)
文本切块                      按标题/段落自然边界切,300~600 字,留 overlap
chunk 元数据                  入库带 {doc, section, position}
向量归一化                    v / np.linalg.norm(v),再统一用 cosine
过滤不相关召回                Top-K 之外加相似度阈值,低于阈值丢弃
没检索到相关内容              老实回复"未找到",别硬塞垃圾给大模型

口诀:答非所问先打印召回的 chunk 分清检索坏还是生成坏
      建索引和查询必须同一个 embedding 模型 向量空间不可跨模型比

避坑清单

  1. RAG 答非所问先切开检索和生成,把检索召回的原始 chunk 打印出来看,别上来就调 prompt
  2. 召回的 chunk 不相关就是检索坏了,chunk 相关但答案跑偏才是生成或 prompt 坏了
  3. 建索引和在线查询必须用同一个 embedding 模型,连版本都要一致否则向量空间不可比
  4. 两个模型维度恰好相同时向量库不报错会静默返回垃圾,错误无声才最致命
  5. 不同 embedding 模型有各自私有的向量坐标系,同一句话两个模型的向量数值毫无可比性
  6. 换 embedding 模型必须用新模型把整个向量库全部重算,绝不能新老向量混在一个库里
  7. 检索召回的相似度分数全挤成一团拉不开,是向量空间出问题的强信号
  8. 文档切块别太大太小,按标题段落等自然边界切,相邻 chunk 之间要留 overlap 重叠
  9. 向量要显式归一化后统一用 cosine 相似度,别赌 embedding 模型默认归没归一化
  10. 检索除了取 Top-K 还要设相似度阈值,知识库没有相关内容时要老实回复未找到

总结

这次"RAG 一上线就问什么都答非所问"的事故,纠正了我一个关于"相似"的、藏得极深的错觉。在我过去的脑子里,向量检索这件事,简单得近乎天经地义:文本变成向量,向量之间一算距离,近的就是相似的——"相似",在我心里是一个【绝对的、客观的属性】,是写在那两个向量身上、谁来量都一样的事实。所以当线上那套系统,把"报销流程"和"食堂菜单"判成了最相似,我的第一反应是它"算错了"——一定是某个距离公式写反了、某个索引建坏了。我从没想过去怀疑一件更根本的事:它算的那个距离,到底【是不是】在量"相似"。直到我把两个模型对同一句话产出的向量并排放在一起,我才如遭雷击地看清:线上那套系统,它的距离公式一点没错、它的检索算法一点没错——它老老实实地、精确地,算出了"问题向量"和"文档向量"之间的距离。错的是,这两个向量,根本【不是用同一把尺子量出来的】。一个是 bge 这个模型在它的坐标系里给"报销"标的位置,一个是 ada 那个模型在它【完全不同】的坐标系里给问题标的位置。系统拿这两个坐标做减法,算出来一个数——这个数,在数学上无可指摘,可它【什么都不代表】。它不是"相似度",它是两个无关宇宙的坐标硬凑出来的一个幻影。我一直以为我在比较"语义",其实我只是在比较两串数字,而那两串数字背后,压根没有一个共同的、能让它们"可比"的语义空间。复盘到最深,我意识到这件事真正点醒我的,是"可比性"这三个字——它不是一个免费的、默认成立的前提,它是一个【需要被保证】的前提。两个数能比较、两个向量能算距离、两个分数能排高下,背后都隐藏着一个我从来没有意识到、却必须成立的假设:它们出自【同一个度量过程、同一把尺子、同一套坐标系】。这个假设一旦被打破,后面所有的计算,无论多么精确、多么无懈可击,都只是在精确地计算一个没有意义的东西。而最可怕的地方在于:这个假设被打破时,系统【不会报错】。它不会跳出来告诉你"喂,你拿两把不同的尺子在量"——它只会沉默地、勤勤恳恳地,给你算出一个看起来很正常的数字。0.71,多正常的一个相似度啊。它不长着"我是错的"的脸。这个教训,我后来发现到处都是它的影子:两个团队各自统计的"转化率",数字摆在一起对比,可它们的口径、分母、过滤条件根本不是一回事;两次 A/B 实验的"提升幅度",拿来横向比较,可它们的流量、时段、用户群完全不同;一个模型在两个数据集上的"准确率",一个高一个低就下结论,可那两个数据集的难度根本不在一个量级。这些场景,表象都是"两个数字,比一比",而陷阱都一样:那两个数字,看起来同名、同单位、甚至同量级,可它们【不是同一把尺子量出来的】,因而【根本不可比】。这次最大的收获,是我养成了一个新习惯:每当我要把两个数、两个向量、两个指标放在一起"比"的时候,我都会先停下来,逼问自己一句——我凭什么认为这两个东西可比?它们,是不是同一个过程、同一把尺子、同一套标准产出的?如果我答不上来,那我接下来做的所有比较、排序、择优,就都悬在半空。embedding 那两串各自正确、合在一起却毫无意义的数字教给我的,不是一个 RAG 的技术细节,而是一个更朴素也更要命的道理:在你比较两样东西之前,你真正要确认的,不是它们各自对不对,而是——它们,到底站不站在同一个可以被比较的地基上。地基不在,比较就是错觉。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

单表两亿行扛不住了:一次分库分表的复盘

2026-5-20 17:08:18

技术教程

接入大模型一个月后 API 账单暴涨 20 倍:一次 token 计费与上下文膨胀的复盘

2026-5-21 11:18:38

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索