<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://blog.wmc.pub/</id>
  <title type="text">Milk Blog</title>
  <subtitle type="text">Milk 的博客</subtitle>
  <updated>2026-09-16T15:34:00.000Z</updated>
  <author><name>Milk</name></author>
  <link rel="alternate" href="https://blog.wmc.pub/"/>
  <link rel="self" href="https://blog.wmc.pub/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.8</generator>
    <entry>
      <id>https://blog.wmc.pub/posts/music-semantic-search/</id>
      <title type="text">给 1400 首歌做语义搜索：一个 QQ 机器人里的曲名检索设计</title>
      <published>2026-09-16T15:34:00.000Z</published>
      <updated>2026-09-16T15:34:00.000Z</updated>
      <author><name>Milk</name></author>
      <link rel="alternate" href="https://blog.wmc.pub/posts/music-semantic-search/"/>
      <summary type="text">字符串三层 + 向量兜底的混合检索架构，以及为什么我们给 1400 首歌拒绝了 faiss。</summary>
      <content type="html"><![CDATA[<p>这篇讲它在曲名检索上的设计：<strong>字符串三层 + 向量兜底</strong>的混合架构，以及为什么我们做了向量检索，却没用任何向量数据库。</p>
<section><h2>一、玩家搜歌的方式有多野<a href="#一玩家搜歌的方式有多野"><span>#</span></a></h2><p>舞萌的曲库是个重灾区：</p><ul>
<li>曲名本身是日文：<code>ハッピーシンセサイザ</code></li>
<li>玩家会打简体：<code>千本樱</code>（原曲是 <code>千本桜</code>）</li>
<li>会打罗马音：<code>senbonzakura</code>、<code>happy synthesizer</code></li>
<li>会打拼音：<code>qianbenying</code></li>
<li>甚至会打语义描述：「那个什么花少女的歌」</li>
</ul><p>前几种用字符串技巧还能救。最后一种是<strong>字符串搜索天生无解</strong>的 —— 你没法穷举所有「玩家会怎么形容一首歌」。这才是我们引入 embedding 的真正动机：不是「向量检索很酷」，而是有一类查询字符串永远接不住。</p></section>
<section><h2>二、整体架构：三层漏斗，向量只做兜底<a href="#二整体架构三层漏斗向量只做兜底"><span>#</span></a></h2><div><div><div><div><span></span></div><div><span><p>命中 ~0.2ms</p></span></div><div><span><p>空</p></span></div><div><span><p>命中 ~30ms</p></span></div><div><span><p>空</p></span></div><div><span><p>命中 ~200ms+</p></span></div><div><span><p>空 / 失败</p></span></div><div><span><p>用户输入 query</p></span></div><div><span><p>① 字符串精确层<br /><br />精确 / 前缀 / 子串 / 罗马音 / 拼音</p></span></div><div><span><p>返回结果</p></span></div><div><span><p>② 字符串模糊层<br /><br />trigram + 编辑距离</p></span></div><div><span><p>③ 向量兜底层<br /><br />embedding API + 余弦相似度</p></span></div><div><span><p>返回「没找到」</p></span></div>
</div><div><div><span></span></div><div><span><p>命中 ~0.2ms</p></span></div><div><span><p>空</p></span></div><div><span><p>命中 ~30ms</p></span></div><div><span><p>空</p></span></div><div><span><p>命中 ~200ms+</p></span></div><div><span><p>空 / 失败</p></span></div><div><span><p>用户输入 query</p></span></div><div><span><p>① 字符串精确层<br /><br />精确 / 前缀 / 子串 / 罗马音 / 拼音</p></span></div><div><span><p>返回结果</p></span></div><div><span><p>② 字符串模糊层<br /><br />trigram + 编辑距离</p></span></div><div><span><p>③ 向量兜底层<br /><br />embedding API + 余弦相似度</p></span></div><div><span><p>返回「没找到」</p></span></div>
</div></div></div><p>关键决策是<strong>顺序</strong>，不是技术选型。</p><p>①② 覆盖了约 96% 的真实查询（拿生产环境 1394 首曲库验证过），③ 只服务「意译 / 语义描述」这类字符串救不回来的剩下 4%。</p><div><div><div></div><div>反过来会怎样</div></div><div><p>如果把向量层放前面，每次查询都要吃一次网络往返（embedding API 是远端服务），等于把 200ms 的成本摊到 100% 的查询上，只为解决 4% 的问题。</p><p><strong>贵的层放最后</strong>，是检索系统设计的通用原则。</p></div></div></section>
<section><h2>三、字符串层：先穷尽「便宜」的手段<a href="#三字符串层先穷尽便宜的手段"><span>#</span></a></h2><p>在碰向量之前，把便宜的招数打满。核心是一条归一化管线：</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>原串</p></span></div><div><span><p>NFKC</p></span></div><div><span><p>全角→半角</p></span></div><div><span><p>小写</p></span></div><div><span><p>丢装饰符号</p></span></div><div><span><p>简体→日文异体</p></span></div><div><span><p>压空白</p></span></div><div><span><p>归一化形</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>原串</p></span></div><div><span><p>NFKC</p></span></div><div><span><p>全角→半角</p></span></div><div><span><p>小写</p></span></div><div><span><p>丢装饰符号</p></span></div><div><span><p>简体→日文异体</p></span></div><div><span><p>压空白</p></span></div><div><span><p>归一化形</p></span></div>
</div></div></div><p>几个值得说的点：</p><p><strong>1. 简体 → 日文异体字映射表。</strong> <code>樱→桜</code>、<code>泽→沢</code>、<code>铁→鉄</code>……只收「形近但字不同」的映射。同形字（<code>花→花</code>）是噪声，不收。这张表只能手工维护，因为中日汉字对应没有简单算法，也不存在权威的自动化映射。</p><p><strong>2. 假名 → 罗马音静态表。</strong> 舞萌曲库里出现过的假名只有 152 种，全在标准五十音范围内。一个 dict 就够了，不需要引入任何分词库或罗马音转换依赖。用户打 <code>senbonzakura</code>，我们把曲名里的假名转成罗马音去比。</p><p><strong>3. 装饰符号全丢。</strong> <code>★☆♪</code>、全角括号、长音符 <code>ー</code>……这些在曲名里大量出现，但玩家永远不会输入。让它们参与匹配只会添乱。</p><p>归一化之后，① 层做精确 / 前缀 / 子串匹配，② 层用 trigram + 编辑距离兜住错别字。</p><div><div><div></div><div>为什么不上分词 / 全文索引</div></div><div><p>曲名是短文本，且高度非自然语言（假名、符号、英文混排）。jieba 这类分词器对曲名的切分结果不稳定，反而会引入噪声；曲库规模也不值得上 SQLite FTS 之类的索引。归一化 + trigram 在这个量级已经够用。</p></div></div></section>
<section><h2>四、向量层：只对「漏网之鱼」出手<a href="#四向量层只对漏网之鱼出手"><span>#</span></a></h2><section><h3>4.1 离线算一次，在线算一次<a href="#41-离线算一次在线算一次"><span>#</span></a></h3><p>成本控制的核心就这一句话：</p><ul>
<li><strong>曲库向量离线算一次</strong>：跑 <code>scripts/build_music_vectors.py</code>，把全部曲名批量交给 embedding API 算好，存进本地数据库。换模型或改文本时用 <code>--rebuild-stale</code> 增量重算。</li>
<li><strong>在线只算查询那一句</strong>：用户发「那个什么花少女」，只对这一句话调 1 次 embedding API，然后和本地的 1400 条向量比余弦相似度。</li>
</ul><p>一次 embedding 调用（一句话、1024 维）按量计费基本可以忽略；如果你在本地跑模型（比如 bge-m3），成本是零，代价只是内存和启动时间。</p><div><div><div>在线查询本地库Embedding API离线脚本在线查询本地库Embedding API离线脚本1394 条曲名（批量）1394 × 1024 维向量存 BLOB + model_name1 条查询文本1 × 1024 维向量读全量向量矩阵（热缓存）矩阵乘法 → 余弦相似度 Top-K
</div><div>在线查询本地库Embedding API离线脚本在线查询本地库Embedding API离线脚本1394 条曲名（批量）1394 × 1024 维向量存 BLOB + model_name1 条查询文本1 × 1024 维向量读全量向量矩阵（热缓存）矩阵乘法 → 余弦相似度 Top-K
</div></div></div></section><section><h3>4.2 喂给模型的文本是「人可改」的<a href="#42-喂给模型的文本是人可改的"><span>#</span></a></h3><p>存库里有个 <code>text_blob</code> 字段，是真正喂给 embedding 模型的文本。它不是简单塞曲名，而是把归一化形、罗马音、拼音全拼进去 —— 让向量<strong>天然带上跨书写系统的语义</strong>。</p><p>这样 <code>happy synthesizer</code> 和 <code>ハッピーシンセサイザ</code> 即使字符串层没命中，向量距离也会很近。</p><p>这也是给某首歌补别名的地方：发现某首歌搜不到，就往 <code>text_blob</code> 里加别名，重算那一条向量。</p><div><div><div></div><div>text_blob vs embedding 字段</div></div><div><p><code>text_blob</code> 是人写的，<code>embedding</code> / <code>model_name</code> 是机器写的。</p><p>手动去编辑 embedding 里的浮点数没有任何意义 —— 向量是模型学出来的语义坐标，不是可以手调的参数。要改语义，改文本，然后重算。</p></div></div></section><section><h3>4.3 为什么不用 faiss / chromadb / milvus<a href="#43-为什么不用-faiss--chromadb--milvus"><span>#</span></a></h3><p>这是整个设计里最反直觉的部分：我们做了向量检索，但<strong>没有用任何向量数据库</strong>。</p><p>先算一笔账：</p>

<table><thead><tr><th>项</th><th>数值</th></tr></thead><tbody><tr><td>曲库规模</td><td>1394 首</td></tr><tr><td>向量维度</td><td>1024</td></tr><tr><td>数据类型</td><td>float32</td></tr><tr><td>总占用</td><td>≈ 5.7 MB</td></tr><tr><td>暴力算余弦（Top-K）</td><td>~1ms 量级</td></tr></tbody></table><p>5.7MB 全部装进一个 numpy 矩阵，暴力算余弦相似度只要 1ms 量级 —— 比任何 ANN（近似最近邻）索引都快，而且是<strong>精确结果</strong>，零精度损失。</p><p>那引入 faiss 的成本呢？C++ 编译依赖（生产环境是 Python 3.14，wheel 覆盖不全）、安装体积、维护成本，而收益为零。就算曲库涨到 10 万首也才 400MB，暴力算仍是几毫秒。</p><div><div><div></div><div>结论</div></div><div><p><strong>真到百万级再换不迟。</strong></p><p>「用向量检索」和「用向量数据库」是两回事。小数据集上，numpy 就是最好的向量数据库。</p></div></div></section><section><h3>4.4 工程细节<a href="#44-工程细节"><span>#</span></a></h3><ul>
<li><strong>embedding 存 BLOB 不存 JSON</strong>：float32 二进制体积约是 JSON 文本的 1/3，<code>np.frombuffer</code> 零拷贝解析，启动加载 1400 条只要几毫秒。</li>
<li><strong>热缓存</strong>：启动时把全量向量规整成一个 numpy 矩阵常驻内存，查询一次矩阵乘法出 Top-K。没装 numpy 时降级为纯 Python 循环 —— 慢一点，但能用。</li>
<li><strong>阈值过滤</strong>：余弦相似度低于 0.55（可配置）视为「没找到」，避免强行返回不相关的结果误导用户。</li>
<li><strong>boost / disabled</strong>：每首歌可以设排序加权或直接屏蔽，给运营留手动干预的口子。</li>
<li><strong>热生效配置</strong>：模型、Base URL、API Key、开关、阈值都存数据库，管理员在群里发 <code>向量检索配置 模型 bge-m3</code> 就能改，不用重启。换模型名后需要跑一次重算（不同模型的向量空间不通用）。</li>
<li><strong>失败静默降级</strong>：embedding API 挂了、没配置、向量库为空 —— 向量层一律返回空列表并记日志，字符串层照常工作。</li>
</ul><div><div><div></div><div>增强功能绝不能拖垮主流程</div></div><div><p><strong>向量检索是锦上添花。</strong> 它的任何失败都不应该影响查歌这个主功能。</p><p>生产环境里，一个「可选增强」把自己失败成主流程故障，是最常见的翻车方式。</p></div></div></section></section>
<section><h2>五、和 NoneBot 异步模型的配合<a href="#五和-nonebot-异步模型的配合"><span>#</span></a></h2><p>最后一层工程约束：检索的同步路径里包含 DB 读和 numpy 计算，<strong>不能直接跑在事件循环里</strong>，否则会卡住整个 bot。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># search_sync 是同步实现，调用方必须用线程池包裹</span></div></div><div><div><div>2</div></div><div><span><span>result </span><span>=</span><span> </span></span><span>await</span><span><span> asyncio.</span><span>to_thread</span><span>(search_sync, query)</span></span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># search_async 是现成的异步入口，内部就是 to_thread(search_sync)</span></div></div><div><div><div>5</div></div><div><span><span>result </span><span>=</span><span> </span></span><span>await</span><span><span> </span><span>search_async</span><span>(query)</span></span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span># embedding 客户端提供同步 embed_batch（批量、带二分重试），异步包装同样走线程池</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这延续了项目一贯的规矩：NoneBot 事件循环里禁止 PyMySQL、慢 SQLite、Pillow、numpy 大计算 —— 该进线程的进线程。</p></section>
<section><h2>六、效果与总结<a href="#六效果与总结"><span>#</span></a></h2>

<table><thead><tr><th>层</th><th>耗时</th><th>覆盖</th></tr></thead><tbody><tr><td>① 字符串精确 / 前缀 / 子串 / 罗马音 / 拼音</td><td>~0.2ms</td><td>绝大多数查询</td></tr><tr><td>② trigram + 编辑距离模糊</td><td>~30ms</td><td>错别字、书写变体</td></tr><tr><td>③ 向量兜底（仅 ①② 都空时）</td><td>~200ms+</td><td>意译、语义描述</td></tr></tbody></table><p>总结：</p><ol>
<li><strong>先穷尽便宜的招，再上贵的。</strong> 归一化 + 罗马音 / 拼音映射能吃掉 96% 的查询，向量只做那 4% 的兜底。</li>
<li><strong>小数据集不需要向量数据库。</strong> 几 MB 的矩阵，numpy 暴力算是最快、最准、依赖最少的方案。</li>
<li><strong>离线算曲库、在线算查询。</strong> 把 embedding API 成本压到几乎为零。</li>
<li><strong>喂给模型的文本是运营接口。</strong> <code>text_blob</code> 设计成「人可改」，别名补在那里，向量自然懂。</li>
<li><strong>增强功能必须可降级。</strong> 向量层任何失败都静默返回空，主流程永不受影响。</li>
</ol></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.wmc.pub/posts/hello-world/</id>
      <title type="text">Hello World</title>
      <published>2026-09-16T08:00:00.000Z</published>
      <updated>2026-09-16T08:00:00.000Z</updated>
      <author><name>Milk</name></author>
      <link rel="alternate" href="https://blog.wmc.pub/posts/hello-world/"/>
      <summary type="text">博客的第一篇文章。</summary>
      <content type="html"><![CDATA[<p>欢迎来到 Milk Blog～</p>
<p>这里会记录我的技术笔记、项目复盘和一些日常碎碎念。</p>]]></content>
    </entry>
</feed>
