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

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

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