博客过了一百篇之后出现了一个新问题:读者的疑问很少只属于一篇文章。”你们的多会话是怎么协作的”、”这个机器人本身怎么实现的”——答案散在三四个地方,靠读者自己拼。

所以给博客装了一个站内 AI 问答:左下角悬浮窗,用自然语言提问,回答只依据站内文章,带来源链接。这篇讲它的架构和一个反直觉的结论——语义检索在我们这个场景里是靠不住的,真正扛事的是老式关键词匹配。

架构:静态站借了个后端

GitHub Pages 是纯静态托管,跑不了任何服务端逻辑,而 AI 问答需要一个能保管 API 密钥、执行检索的服务端。自建第三方 SaaS(Chatbase 之类)最快,但内容要出站、免费档有限制、挂着别人品牌;最后选了更顺的路——借用渐识项目的服务器,它本来就跑着 FastAPI 后端和模型调用,加一个接口的边际成本近乎为零。

三件套就位:站内 Hexo 生成的全文索引(search.xml,约 1.5MB,覆盖全部文章)作为数据源;服务器上加一个 POST /api/blog/ask 接口负责检索加生成;博客端一个零依赖的 JS 悬浮窗负责对话。

检索:两条通道,主次分明

接口收到问题后的第一件事不是问模型,是先在站内文章里翻证据。翻的方式有两条通道。

关键词通道(主干):问题压平(去空白和符号)后切成相邻二元词加 ASCII 单词,用 IDF 稀有度加权打分——“什么、办法、一台”这类人人都有的大路词权重趋零,”插u、u盘、装系”这种稀有词决定排名。这一通道对技术文命中非常准。

语义通道(兜底):把问题和文章切块向量化算余弦相似度,用来接住”关键词完全对不上但意思相近”的改写式提问。用的 MiniMax embo-01(1536 维)。

分流规则是实测校准出来的:关键词通道有命中就走关键词排序,语义只在关键词零命中时主政,另外在关键词结果不足时补位。

两个实测踩出来的坑

坑一:空格会杀死匹配。 读者问”插U盘装系统”,问题侧压平后得到二元词”插u、u盘”;但 Cobbler 那篇原文写的是”插 U 盘”——带空格,按原文匹配永远查不中。修复是文档在索引构建时就生成一份”压平版”用于匹配,原文照旧展示。这一个字符级别的修复,把这篇最该命中的文章从查无此项救回了榜首。

坑二:embedding 模型对技术长文是噪声。 embo-01 对短句的判别力尚可,但对塞满命令输出的技术长文,余弦相似度完全失真——实测一个不相关的磁盘分区文章能拿 0.50,而正确答案只有 0.22。第一版用 RRF 融合两路排序,结果语义噪声污染了关键词的正确排名。最后承认现实:这个模型在这种语料上只配当兜底,把融合改成了分流。

另外 MiniMax 的 embeddings 接口和 OpenAI 协议不同参——要 texts 数组加 type(入库用 db、提问用 query),直接套 input 会报参数缺失。这类”协议形似神不似”的坑,报错信息基本不帮你。

防滥用:公开接口的三层锁

接口是公开的,等于把”免费调 LLM”挂在了公网上,三道锁缺一不可:

  1. nginx 层:问答路径单独限流(每 IP 每分钟 10 次,突发 5),请求体上限 8KB;
  2. 应用层:每 IP 每小时 10 次,问题超 200 字直接拒绝;
  3. 内容层:站内索引是外部 XML,解析前拒绝任何 DTD/实体声明(防实体扩展攻击);系统提示词强制”只依据摘录回答、标注引用编号、不足时如实说”——实测问一个站外问题(”红烧肉怎么做”),它老老实实回答站内没覆盖,还顺手列了检索到的四篇文章证明自己翻过。

窗体:左下角,编号即链接

悬浮窗放左下角——右下角被主题的回顶按钮和暗色切换占了。回答里的【1】【2】编号自动替换成来源文章链接,支持暗色模式,移动端收起文字只留图标。系统提示词里写死身份:博客助手”干将”,只谈站内内容。

实测三问:它现在这个水平

上线时拿三类问题做了验收,各代表一种典型场景。

场景一:关键词命中的技术问题。问”不想一台一台插 U 盘装系统,有什么办法让机器开机自己装好”——返回 Cobbler 那篇置顶,答案精确到”Rocky Linux 9 + Cobbler 3.3.7 实测、机器开机选网络启动自动装完”,来源链接直达。这正是坑一修复后才可能出现的成绩:修复前同一问题查无此文。

场景二:零相关的题外话。问”红烧肉怎么做”——它回答站内文章没有覆盖,还列出检索时翻过的四篇文章标题作为”我真的找过”的证据。诚实拒答比硬编一个菜谱重要得多,这是系统提示词里那句”不足时如实说”在起作用。

场景三:需要跨文章拼答案的元问题。问”这个博客有几个系列”——它基于检索到的渐识上架记两篇给出部分答案,并主动声明”仅基于当前摘录的几篇判断,博客实际可能更多”。不全,但每个字都有出处。

悬浮窗:几百行 JS 的事

窗体本身是零依赖的原生 JS:注入一个悬浮按钮和一个面板,提交问题走 fetch,回答里的【1】【2】编号正则替换成来源文章链接。三个细节:位置选左下角,因为主题自带的回顶按钮和暗色切换占了右下;暗色模式跟着主题的 data-theme 属性走,单独写了一套配色;面板内消息自动滚底,加载中给”翻站内文章中…”的占位,防止用户以为卡死。

成本与诚实边界

成本侧:每次提问 = 一次向量调用加一次 LLM 调用(检索到的段落作为上下文),按 DeepSeek/MiniMax 的价格几乎可以忽略;向量缓存落盘,六小时刷新一次,不会反复计费。

两个已知边界:站内新文章要等索引缓存过期(最长六小时)才会进入机器人的知识——比如这篇,它要几个小时后才能”读过”;embo-01 对技术长文判别力弱,语义通道现在只能兜底——下一步如果在服务器上跑本地 bge 模型(配置里已留了字段),语义通道才真正能打。

现在左下角那个”🤖问干将”就是成品。问它”这个机器人怎么实现的”,它应该能把这篇文章找出来回答你——毕竟答案已经进了它的索引。


相关文章:承载这个接口的服务器基础设施,见渐识上架记(二):从立项到上架的全流程自动化;发布前的质量流程,见博客产线(三):发布前的六道自动体检。