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 模型 向量空间不可跨模型比
避坑清单
- RAG 答非所问先切开检索和生成,把检索召回的原始 chunk 打印出来看,别上来就调 prompt
- 召回的 chunk 不相关就是检索坏了,chunk 相关但答案跑偏才是生成或 prompt 坏了
- 建索引和在线查询必须用同一个 embedding 模型,连版本都要一致否则向量空间不可比
- 两个模型维度恰好相同时向量库不报错会静默返回垃圾,错误无声才最致命
- 不同 embedding 模型有各自私有的向量坐标系,同一句话两个模型的向量数值毫无可比性
- 换 embedding 模型必须用新模型把整个向量库全部重算,绝不能新老向量混在一个库里
- 检索召回的相似度分数全挤成一团拉不开,是向量空间出问题的强信号
- 文档切块别太大太小,按标题段落等自然边界切,相邻 chunk 之间要留 overlap 重叠
- 向量要显式归一化后统一用 cosine 相似度,别赌 embedding 模型默认归没归一化
- 检索除了取 Top-K 还要设相似度阈值,知识库没有相关内容时要老实回复未找到
总结
这次"RAG 一上线就问什么都答非所问"的事故,纠正了我一个关于"相似"的、藏得极深的错觉。在我过去的脑子里,向量检索这件事,简单得近乎天经地义:文本变成向量,向量之间一算距离,近的就是相似的——"相似",在我心里是一个【绝对的、客观的属性】,是写在那两个向量身上、谁来量都一样的事实。所以当线上那套系统,把"报销流程"和"食堂菜单"判成了最相似,我的第一反应是它"算错了"——一定是某个距离公式写反了、某个索引建坏了。我从没想过去怀疑一件更根本的事:它算的那个距离,到底【是不是】在量"相似"。直到我把两个模型对同一句话产出的向量并排放在一起,我才如遭雷击地看清:线上那套系统,它的距离公式一点没错、它的检索算法一点没错——它老老实实地、精确地,算出了"问题向量"和"文档向量"之间的距离。错的是,这两个向量,根本【不是用同一把尺子量出来的】。一个是 bge 这个模型在它的坐标系里给"报销"标的位置,一个是 ada 那个模型在它【完全不同】的坐标系里给问题标的位置。系统拿这两个坐标做减法,算出来一个数——这个数,在数学上无可指摘,可它【什么都不代表】。它不是"相似度",它是两个无关宇宙的坐标硬凑出来的一个幻影。我一直以为我在比较"语义",其实我只是在比较两串数字,而那两串数字背后,压根没有一个共同的、能让它们"可比"的语义空间。复盘到最深,我意识到这件事真正点醒我的,是"可比性"这三个字——它不是一个免费的、默认成立的前提,它是一个【需要被保证】的前提。两个数能比较、两个向量能算距离、两个分数能排高下,背后都隐藏着一个我从来没有意识到、却必须成立的假设:它们出自【同一个度量过程、同一把尺子、同一套坐标系】。这个假设一旦被打破,后面所有的计算,无论多么精确、多么无懈可击,都只是在精确地计算一个没有意义的东西。而最可怕的地方在于:这个假设被打破时,系统【不会报错】。它不会跳出来告诉你"喂,你拿两把不同的尺子在量"——它只会沉默地、勤勤恳恳地,给你算出一个看起来很正常的数字。0.71,多正常的一个相似度啊。它不长着"我是错的"的脸。这个教训,我后来发现到处都是它的影子:两个团队各自统计的"转化率",数字摆在一起对比,可它们的口径、分母、过滤条件根本不是一回事;两次 A/B 实验的"提升幅度",拿来横向比较,可它们的流量、时段、用户群完全不同;一个模型在两个数据集上的"准确率",一个高一个低就下结论,可那两个数据集的难度根本不在一个量级。这些场景,表象都是"两个数字,比一比",而陷阱都一样:那两个数字,看起来同名、同单位、甚至同量级,可它们【不是同一把尺子量出来的】,因而【根本不可比】。这次最大的收获,是我养成了一个新习惯:每当我要把两个数、两个向量、两个指标放在一起"比"的时候,我都会先停下来,逼问自己一句——我凭什么认为这两个东西可比?它们,是不是同一个过程、同一把尺子、同一套标准产出的?如果我答不上来,那我接下来做的所有比较、排序、择优,就都悬在半空。embedding 那两串各自正确、合在一起却毫无意义的数字教给我的,不是一个 RAG 的技术细节,而是一个更朴素也更要命的道理:在你比较两样东西之前,你真正要确认的,不是它们各自对不对,而是——它们,到底站不站在同一个可以被比较的地基上。地基不在,比较就是错觉。
—— 别看了 · 2026