这是这个系列里我最想写的一篇。
不是因为我修好了什么了不起的东西,而是因为我第一次完整地走了一遍「怀疑 → 验证 → 定位 → 判断严重程度 → 决定怎么修」的过程。这个过程比结论重要。
一、它解决什么问题
先说这个功能本来该做什么。
StyleKit 里有个 advisor:用户用自然语言描述想要的设计风格,它从知识库里捞出相关资料,再交给模型生成推荐。
export async function retrieveKnowledgeForAdvisor(query: string, limit = 5) {
const resources = await loadKnowledgeCatalog()
const hits = searchKnowledgeCatalog(resources, { query, limit })
return { query, retrieved: hits.length > 0, references: hits.map(toAdvisorReference) }
}retrieved 这个字段是重点——它记录「这次到底有没有检索到东西」。如果没有,模型就退化成凭空生成。
我是在读自己写的这段代码时停下来想了一下:中文查询真的能命中吗?
二、不用它会怎样
最坏的结果不是报错,是静默地什么都检索不到。
用户用中文问「我要一个适合金融后台的克制风格」,检索返回空,retrieved: false,然后模型靠自己的知识瞎编一个推荐。用户看到的回答看起来很正常——流畅、有道理、格式漂亮。
但那个回答和我的知识库一点关系都没有。
这种失败最难发现,因为没有任何错误信号。日志里没有 exception,接口返回 200,用户也不会来投诉。它只是悄悄地从「基于资料回答」变成了「凭空回答」。
三、代码是怎么做的
缺陷在分词函数
lib/knowledge/catalog.ts:45:
function tokenize(value: string): string[] {
return value
.toLocaleLowerCase()
.split(/[^\p{L}\p{N}]+/u) // ← 问题在这
.map((token) => token.trim())
.filter(Boolean)
}这行的意图是「按非字母非数字的字符切分」。对英文是对的:"dark mode dashboard" 按空格切开,得到三个词。
但对中文是错的。\p{L} 这个 Unicode 属性包含汉字,而中文书面语不用空格分词。所以对分词函数来说,一整句中文里没有任何「分隔符」——它切不开。
我的验证方法
我没有靠猜,也没有只看代码就下结论。我把这个函数原样抄出来单独跑了一遍,结果:
| 查询 | token 数 | token 内容 |
|---|---|---|
| 我要一个适合金融后台的克制风格 | 1 | ["我要一个适合金融后台的克制风格"] |
| 深色夜景毛玻璃质感 | 1 | ["深色夜景毛玻璃质感"] |
| dark mode dashboard | 3 | ["dark","mode","dashboard"] |
| glassmorphism frosted blur | 3 | ["glassmorphism","frosted","blur"] |
中文整句变成一个 token。而匹配逻辑要求 token 完全相等:
const matchedFields = Object.entries(fields).filter(([, value]) => {
const fieldTokens = new Set(tokenize(value))
return queryTokens.some((token) => fieldTokens.has(token)) // 全等比较
})整句中文要和一个字段里的某个 token 一模一样才算命中。这几乎不可能发生。
再往下验证一层
光看分词还不够,我照着 searchKnowledgeCatalog 写了一个忠实复刻版,跑真实数据。
第一次跑的时候我差点得出错误结论——所有查询都是 0 命中,包括英文。查了才发现:knowledge/manifests/ 下的 10 个文件全都是 pending / unpublished 状态,而 isRetrievable 要求 approved 且 published,目录本身就是空的。
把这一层剥掉(includeUnpublished: true)之后,缺陷才露出来:
| 查询 | token 数 | 命中数 |
|---|---|---|
| accessibility | 1 | 4 |
| headless | 1 | 1 |
| react accessible components | 3 | 10 |
| 无障碍组件库 | 1 | 0 |
| 毛玻璃 | 1 | 0 |
| 我要一个适合金融后台的克制风格 | 1 | 0 |
「无障碍组件库」和「accessibility」是同一个意思。英文命中 4 条,中文 0 条。
这就是缺陷的证据:按设计就永远找不到。
我原以为只修分词不够 —— 后来发现判断错了
写这篇时的判断是:补上中文分词只能修复字面命中,而「我要一个适合金融后台的克制风格」这种句子里没有任何一个词出现在风格文档里,所以非上语义检索不可。
这个判断是错的。
我后来做了一套 60 条标注的评估集来量化。修好分词、换上一个像样的关键词检索(BM25)之后,中文口语化意图那一组的 Recall@1 是 80.0%——而修之前是 0%。
也就是说,「金融后台」「克制」这些词本来就在风格文档里。不是「词没出现过」,是「出现过,但分词器切不开,所以字面命中失效了」。
补上一点:这一组同时换掉了匹配算法(旧的词元重叠 → BM25),所以不能把 80% 全算在分词头上。但主因是分词——同一份 BM25,只把分词器换回有缺陷的那个,这一组立刻掉回 0%。
同义表达(毛玻璃 ↔ 玻璃拟态 ↔ frosted glass)和跨语言(中文提问、英文文档)这两类问题确实还在,只是它们在标注集里的占比比我原先以为的低。
语义检索能不能在这个基础上再加分,我后来配好 key 测了:整体 Recall@1 从 80.0% 升到 96.7%,跨语言那一组从 90.0% 到 100.0%。所以原先「只修分词不够」这句话方向是对的,但我把两部分的量级估反了——分词修复承担了主要部分(35.0% → 81.7%),向量层补的是剩下那一截。
顺带说一个对比
同一个项目里,风格搜索(lib/discovery.ts 的 searchStyles)没有这个缺陷。它用的是另一套逻辑:
if (name.includes(q) || zhName.includes(q) || slug.includes(q)) return 80
if (tags.some((t) => t.includes(q))) return 60子串匹配,不做分词。 中文没有空格反而无所谓——"毛玻璃" 作为子串直接出现在中文描述里就能命中。
这个对比很说明问题:知识库检索是项目里唯一用「切词 + 全等」的地方,也正好是唯一在中文上归零的地方。问题是选错了匹配方法。
四、面试官会追问什么
问:你是怎么发现这个问题的?
答:读自己的检索代码时,看到匹配用的是「token 全等」。我对
\p{L}是否包含汉字不确定,就把分词函数单独抄出来跑了一遍——中文整句输出 1 个 token。然后我照着完整的匹配逻辑写了个复刻版跑真实数据,确认中文查询命中数为 0。我没有靠猜,是先建立可复现的证据再下结论。
问:为什么不能只修分词?
答:这句话我原先的答案是错的。我原以为用户说的词根本不在文档里,实测不是——「金融后台」「克制」这些词本来就在,只是分词器切不开。修好分词加 BM25 之后,中文口语化意图那一组的 Recall@1 从 0% 到 80%。
需要语义检索的是剩下那一截:跨语言(某条查询的决定性证据只存在于英文副本里,中文副本没有对应词)和同义改写。加上向量层后整体从 80% 到 96.7%,跨语言组从 90% 到 100%。
问:这个缺陷影响有多大?
答:中文自然语言查询命中率是 0%,且是静默的——不报错,用户拿到的是模型凭空生成的回答。对一个双语站点来说,中文侧的检索能力实质为零。
但要诚实说一句:我在验证过程中还发现目录里 10 个 manifest 全是未发布状态,所以当前阶段英文检索同样是空的。这是另一个独立问题。我把两层分开测,才能确认分词缺陷是真实存在的,而不是被发布状态掩盖的假象。
问:你打算怎么修?
答:分两步。第一步是把分词修好并把匹配换成 BM25——这一步比我原先估计的重要得多,整体 Recall@1 从 35% 到 81.7%。第二步是在此之上加向量路做混合检索,把剩下的部分补到 96.7%。
我原以为第二步才是重点,评估结果不是。不做对照实验的话,我会把资源投错地方。
五、一句话版本
\p{L}包含汉字而中文不用空格分词,整句中文被切成一个 token,加上全等匹配,中文自然语言查询命中率为 0;修好分词后整体 Recall@1 从 35.0% 到 81.7%,再叠加向量层到 96.7%。
六、自测
\p{L}这个正则属性为什么对中文「不生效」?是它不匹配汉字,还是别的原因?- 用本文的方法,验证一下「深色夜景毛玻璃质感」会被切成几个 token。
- 修好分词之后还剩哪一类查询召回不了?举一个向量层才能解决的例子。
- 风格搜索为什么不受这个缺陷影响?它的匹配方式有什么不同?
- 如果要向别人证明「这个缺陷真实存在」,你需要控制哪些变量?为什么不能直接把线上 0 命中当作证据?