第5章实践:美食语义匹配器——同样的字不等于同样的意思
预计 2×45 分钟。先完成"观察与运行",再完成"单变量修改与解释"。
第1步:需求与背景
为什么做这个
你在外卖App搜索"西红柿鸡蛋烩饭",系统应该推荐西红柿炒鸡蛋、蛋炒饭这类菜品,而不是红烧肉或烤红薯。但系统怎么知道哪些菜品"相关"?
最简单的方法是看查询和菜品名称有多少相同的字。但"想吃点酸的开胃菜"和"凉拌黄瓜"之间几乎没有相同的字,它们却不相关——等等,其实相关!"酸的""开胃"和"凉拌黄瓜"在意思上是接近的。
这就是字面匹配和语义匹配的区别。
项目目标
制作一个最小菜品匹配器:
- 输入:一句自然语言查询(如"西红柿鸡蛋烩饭")
- 输出:20道候选菜品按相关度排序的列表
- 两种方法:共同字基线 vs 嵌入向量语义匹配
- 验收标准:能指出两种方法各自的优势和失败情况
明确不做
- 不做真实的菜品推荐系统(数据是课程自编的)
- 不训练自己的嵌入模型(使用课程提供的或预计算的向量)
- 不把相似度分数等同于"正确答案"
系统全景
查询文本 + 20道菜品描述
↓
方法A:共同字计数 → 字面相似度 → 排序
方法B:嵌入向量 → 余弦相似度 → 排序
↓
两种排序对照 + 人工判断
↓
分析:哪些查询字面方法失败?哪些语义方法也失败?
第2步:数据与信号
数据长什么样
打开 data/candidates.json,你会看到:
- 20道菜品,每道有一个名称和一句描述
- 6条查询,每条标注了"相关"和"不相关"的菜品编号
文字怎样变成可计算的数字
计算机不能直接比较两段文字的意思。我们需要把文字变成数字向量,然后用数学方法比较两个向量的接近程度。
方法A:共同字基线
最直觉的方法:数两段文字有多少相同的字。
"西红柿鸡蛋烩饭" vs "西红柿炒鸡蛋" → 共同字:西、红、柿、鸡、蛋 = 5个
"西红柿鸡蛋烩饭" vs "红烧肉" → 共同字:红 = 1个
这个方法的问题:
- "想吃点酸的开胃菜" vs "凉拌黄瓜" → 共同字:0个!但它们语义相关
- "西红柿鸡蛋烩饭" vs "番茄蛋汤" → 共同字很少,但它们很相关(西红柿=番茄)
方法B:嵌入向量
嵌入模型把每段文字变成一个数字向量(如256个数字组成的数组)。意思接近的文字,向量在空间中也接近。
"西红柿鸡蛋烩饭" → [0.12, -0.34, 0.78, ...] (256维)
"番茄蛋汤" → [0.11, -0.31, 0.75, ...] (很接近!)
"红烧肉" → [-0.45, 0.22, -0.11, ...] (很远)
余弦相似度
两个向量的接近程度用余弦相似度衡量:
$$\cos(\theta) = \frac{\vec{a} \cdot \vec{b}}{|\vec{a}| \times |\vec{b}|} = \frac{\sum_{i=1}^{n} a_i \times b_i}{\sqrt{\sum_{i=1}^{n} a_i^2} \times \sqrt{\sum_{i=1}^{n} b_i^2}}$$
直观理解:
- 值域:-1 到 1
- 1 = 方向完全相同(意思非常接近)
- 0 = 方向垂直(意思无关)
- -1 = 方向完全相反
代码实现(完整可见):
import numpy as np
def cosine_similarity(a, b):
"""计算两个向量的余弦相似度"""
a = np.array(a)
b = np.array(b)
dot_product = np.dot(a, b) # 点积:对应位置相乘再求和
norm_a = np.linalg.norm(a) # a的长度(模)
norm_b = np.linalg.norm(b) # b的长度(模)
if norm_a == 0 or norm_b == 0:
return 0.0
return dot_product / (norm_a * norm_b)
可视化:向量空间中的菜品
Notebook 中会生成一张图,把20道菜品和6条查询投影到二维平面。你会看到:
- 语义相近的菜品聚在一起(如西红柿系列、鱼类菜品)
- 查询落在相关菜品的附近
- 字面方法和语义方法的排序差异一目了然
第3步:AI原理
嵌入模型做了什么
嵌入模型是一个把文字变成向量的函数。它不是数相同的字,而是理解了文字的含义。
- "西红柿"和"番茄"在嵌入空间中接近,因为它们指的是同一种东西
- "酸的"和"醋""柠檬"在嵌入空间中接近,因为它们描述相似的味觉
- "下饭"和"红烧肉""麻婆豆腐"在嵌入空间中接近,因为经验上它们常一起出现
嵌入模型怎样训练出来的
嵌入模型通常在大量文本上训练,学习"哪些词经常出现在相似的上下文中"。这就是分布假说:如果两个词出现在相似的语境中,它们的意思就相近。
我们不需要自己训练嵌入模型。课程提供两种路径:
1. 本地嵌入模型:通过 Ollama 运行 nomic-embed-text 等轻量嵌入模型
2. 预计算向量:如果本地模型不可用,直接使用资源包中预计算好的向量
为什么相似度高 ≠ 正确答案
余弦相似度高只说明两段文字在嵌入空间中接近,但:
- 否定句("不要辣的")可能被忽略——嵌入模型可能只关注"辣的"
- 专业知识("低GI主食")可能不被理解——嵌入模型不知道GI是什么
- 文化差异("下饭")可能不被捕捉——取决于训练数据
第4步:协同开发
使用 Qwen Code 完成以下任务(选择一项):
任务A:为菜品数据集新增3道菜品,要求:
- 至少一道能被"想吃点酸的"匹配到
- 至少一道是"不要辣的"应该排除的
- 先预测新菜品在两种方法下的排序位置,再运行验证
任务B:修改阈值,把"自动推荐"改为"需要人工确认":
- 当最高相似度低于某个值时,不自动推荐,而是提示"请人工确认"
- 先预测阈值变化会导致哪些查询从"推荐"变成"请确认"
协同流程:
1. 在 Qwen Code 中描述你的选择(原始意图)
2. 查看 Qwen Code 的修改建议(AI方案)
3. 审查差异,接受或改写(人工审查)
4. 运行修改后的代码(实际变更)
5. 对比预测和实际结果(最终判断)
第5步:测试与边界
必测项目
| 测试 | 查询 | 预期 | 两种方法对比 |
|---|---|---|---|
| 正常-字面 | Q01 "西红柿鸡蛋烩饭" | 字面和语义方法都应排前 | 差异不大 |
| 正常-语义 | Q05 "鱼肉做法" | 语义方法找到鱼菜品 | 字面方法失败 |
| 边界-否定 | Q03 "不要辣的,要清淡的汤" | 两种方法都可能失败 | 都需要注意 |
| 边界-模糊 | Q06 "下饭的荤菜" | 语义方法应理解 | 字面方法失败 |
| 失败-无匹配 | 自定义:"我想吃日料" | 两种方法都不应推荐不相关的 | 检查是否有低分推荐 |
人工兜底
- 当两种方法排序不一致时,由学生人工判断哪个更合理
- 当最高相似度低于阈值时,系统应提示"请人工确认"而非强行推荐
- 嵌入模型不可用时,使用预计算向量完成A档观察
第6步:交付与验收
交付物
- 两种排序对照表:6条查询 × 2种方法,列出Top-5排序
- 反例分析报告:至少3个"字面方法失败"和1个"语义方法也失败"的例子
- 阈值卡:你选择的阈值、理由和影响
- 关键代码解释:用自己的话解释余弦相似度的计算过程
- AI协同证据:第4步的完整五段留痕
验收标准
- [ ] 能指出"向量相似"与"正确答案"之间的差别
- [ ] 至少找到1个能骗过字面方法的反例
- [ ] 至少找到1个能骗过语义方法的反例
- [ ] 阈值修改有修改前预测和修改后验证
- [ ] 模型或服务失败时有清晰降级
- [ ] 能用自己的话解释嵌入和余弦相似度
第7步:迁移与拓展
核心方法迁移
本章学到的"字面 vs 语义"对比,可以迁移到:
- 校园资料检索:关键词搜索 vs 语义搜索(第8章RAG的前身)
- 商品搜索:电商搜索框怎样理解你的查询
- 音乐推荐:按歌名搜索 vs 按"心情"推荐
拓展任务(选做)
- 把菜品数据集替换为你自己专业的数据(如课程名称、图书馆书籍、设备名称)
- 比较不同嵌入模型(如
nomic-embed-textvs 云端嵌入API)的排序差异 - 为阈值选择设计一个有10条标注数据的测试集,计算准确率和召回率