第5章实践:美食语义匹配器——同样的字不等于同样的意思

预计 2×45 分钟。先完成"观察与运行",再完成"单变量修改与解释"。


第1步:需求与背景

为什么做这个

你在外卖App搜索"西红柿鸡蛋烩饭",系统应该推荐西红柿炒鸡蛋、蛋炒饭这类菜品,而不是红烧肉或烤红薯。但系统怎么知道哪些菜品"相关"?

最简单的方法是看查询和菜品名称有多少相同的字。但"想吃点酸的开胃菜"和"凉拌黄瓜"之间几乎没有相同的字,它们却不相关——等等,其实相关!"酸的""开胃"和"凉拌黄瓜"在意思上是接近的。

这就是字面匹配和语义匹配的区别。

项目目标

制作一个最小菜品匹配器:

明确不做

系统全景

查询文本 + 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 "下饭的荤菜" 语义方法应理解 字面方法失败
失败-无匹配 自定义:"我想吃日料" 两种方法都不应推荐不相关的 检查是否有低分推荐

人工兜底


第6步:交付与验收

交付物

  1. 两种排序对照表:6条查询 × 2种方法,列出Top-5排序
  2. 反例分析报告:至少3个"字面方法失败"和1个"语义方法也失败"的例子
  3. 阈值卡:你选择的阈值、理由和影响
  4. 关键代码解释:用自己的话解释余弦相似度的计算过程
  5. AI协同证据:第4步的完整五段留痕

验收标准


第7步:迁移与拓展

核心方法迁移

本章学到的"字面 vs 语义"对比,可以迁移到:

  1. 校园资料检索:关键词搜索 vs 语义搜索(第8章RAG的前身)
  2. 商品搜索:电商搜索框怎样理解你的查询
  3. 音乐推荐:按歌名搜索 vs 按"心情"推荐

拓展任务(选做)