Neko 博客助手设计:从关键词匹配到可追问的 RAG
给个人博客接入 AI,最直接的方式是检索几段文章,再交给大模型回答。但真正使用后,会发现“能够回答”和“回答得可靠”之间还有不少距离。
例如,问“最新的一篇文章是什么”,不应该收到十篇文章的分页目录;问“有哪些 Kafka 相关文章”,不应该看到一堆只是偶然提到 Kafka 的文章;接着问“讲一下这篇文章”,助手也不应该突然忘记刚刚推荐了什么。
Neko 是我博客里的 AI 助手。本文基于 2026 年 9 月的代码实现,介绍它如何把问题理解、资料查询、回答生成和文章上下文拆开处理。它是受控的问答流程,而不是能够自主修改或发布博客的通用 Agent。
一、先理解问题,再决定怎么查
早期遇到的问题,并不全是模型能力不足。有些错误来自程序把不同任务塞进了同一条检索链路。
“最新的一篇文章是什么”需要排序;“Kafka 为什么快”需要解释;“有哪些 Kafka 相关文章”需要推荐。这三件事不应该用同一种方式完成。
当前链路先让模型输出结构化查询计划,由程序校验并执行,而不是直接让模型自由回答。
1 | 用户问题 + 最近历史 + 当前页面 + 上轮文章关联 |
模型负责理解自然语言,程序负责限制执行范围和保证查询规则。比如“最新一篇”对应按发布日期排序并取一篇;只有完整目录等任务才进入分页逻辑。
当前支持的主要分支如下:
| 问题类型 | 示例 | 处理方式 |
|---|---|---|
| 最新文章 | 最新的一篇是什么? | 从公开目录排序后直接回答 |
| 目录和统计 | 列出所有 Go 文章 | 按明确条件筛选、计数或分页 |
| 文章推荐 | 有哪些 Kafka 相关文章? | 混合检索后筛选相关候选 |
| 文章追问 | 讲一下这篇文章 | 定位文章,只读取该文章片段 |
| 普通知识问答 | Kafka 为什么吞吐量高? | 检索资料,按需联网,再生成解释 |
| 需要澄清 | 多篇候选时只说“这篇” | 请用户指定对象 |
这里保留了一个重要区别:明确的主题目录和计数仍可能采用关键词筛选,并不等于语义分类统计。系统不能拿“检索到几篇”冒充“全站总共几篇”。
二、检索只是找候选,不是最终推荐
普通问答和相关文章推荐使用关键词与向量混合检索。
关键词检索适合精确术语,比如 Kafka、KRaft;向量检索适合表达不同但含义接近的问题,比如“消息积压怎么办”和“消费者处理能力不足”。两者互补,而不是简单地用向量取代关键词。
1 | 经过上下文改写的独立问题 |
当前实现使用 bge-m3 生成 1024 维向量,语义检索最多取 20 条匹配。两路结果通过 RRF 融合,最终保留最多 6 个片段,每篇文章最多占 2 个。
RRF 根据候选在各路结果中的排名累加分数,避免直接混合量纲不同的关键词分数和向量相似度。同一片段在两路都排名靠前,就更容易进入最终结果。这里的“双路”是逻辑上的两种召回方式,不意味着它们在代码中并发执行。
向量结果也不是可以直接使用的正文。程序会将匹配 ID 与当前公开索引重新对应,无法对应的旧片段不会进入回答。向量服务异常或没有有效结果时,系统退回关键词检索。
为什么还需要推荐筛选
语义相近,不代表文章真正讲了用户关心的内容。一篇 Agent 文章可能提到消息队列,但未必值得推荐给正在寻找 Kafka 资料的人。
因此,推荐分支会额外调用模型,从候选中选择真正相关的文章,并提供推荐理由与原文依据。程序检查候选编号、重复项、返回数量,以及依据是否确实出现在候选标题或正文中。
这能约束凭空生成来源,但不能证明模型对相关性的判断永远正确。原文包含一句话,也不等于整篇文章都围绕这个主题展开。
最终只有筛选通过的文章会展示给用户,并进入下一轮文章关联。筛选失败时不会把未经确认的候选直接当作推荐;推荐成功时也只承诺“本轮找到的相关笔记”,不声称已经列全。
三、“这篇文章”为什么需要独立上下文
仅仅把聊天记录发给模型,并不能稳定解决文章指代。
假设上一轮回答推荐了一篇 Kafka 文章,又顺带说明其他三篇候选不相关。如果程序把所有引用都当作下一轮候选,“讲一下这篇文章”就会变成一道四选一的问题。
Neko 将自然语言历史和结构化文章关联分开传递。
1 | 你:有哪些 Kafka 相关文章? |
客户端携带的文章路径只是定位线索,不是可信正文,也不能绕过公开索引。上一轮回答同样只用于理解对话,不能代替文章内容。
单篇文章分支默认包含文章开头,再从该文章内部选择相关片段,总计最多 6 个。因此这仍是基于片段的解释,不保证每次都读取全文。文章较长、片段不足时,应明确说明限制。
当前上下文还有几个边界:
- 后端最多接收最近 6 条消息,是消息条数而不是对话轮数。
- 上一轮确实有多篇候选,而用户未指定对象时,仍需要澄清。
- 文章已经退出公开索引时,不用旧回答或当前页面悄悄替代它。
- 新问题明显切换主题时,不强行绑定上一轮文章。
- 前端成功完成回答后才更新历史和关联;回答被截断时清空新文章关联。
这是一种短期会话状态,不是跨会话的永久记忆。普通知识回答目前也不会自动把所有参考文章变成追问对象。
四、哪些回答需要模型,哪些不需要
并不是每个最终答案都由模型自由生成。
最新文章、目录和统计由程序根据公开索引组织答案;推荐结果由程序根据筛选后的条目生成列表。只有文章解释和普通问答等分支,才进入自由文本生成。
这样做既减少调用,也让排序、数量、引用编号和展示结果更一致。
| 场景 | 通常的聊天模型调用次数 |
|---|---|
| 最新文章、目录、统计 | 1 次:理解意图 |
| 推荐相关文章 | 2 次:理解意图、筛选候选 |
| 单篇文章解释 | 2 次:理解意图、生成解释 |
| 普通知识问答 | 2~3 次:理解意图、可选资料充分性判断、生成回答 |
这些数字不包含向量化调用、重试或特殊分支。没有候选时,推荐筛选也可以直接返回空结果。
普通问答会根据联网设置、时效需求和资料充分性决定是否搜索网络。网络搜索由 Tavily 提供,博客来源和网络来源分别编号,避免把外部资料包装成博客作者的观点。
推荐和单篇文章解释默认不联网,以免悄悄扩大任务范围;用户允许并强制联网时,才走相应补充分支。目录直答不会因此变成网络搜索。
流式返回的不只有正文
Worker 使用 SSE 返回不同类型的事件:
| 事件 | 作用 |
|---|---|
status |
展示正在理解、检索或整理等阶段 |
sources |
返回参考来源及检索状态 |
context |
返回下一轮可用的文章关联 |
delta |
返回正文内容 |
done |
标记结束及是否达到长度上限 |
error |
明确报告失败 |
模型生成的解释可以逐段显示;程序直接生成的目录或推荐列表也使用同一协议,但不一定逐字输出。前端只有收到完成标记,才将本轮视为成功结束。
五、失败处理与知识更新
服务异常不等于用户没问清楚
“需要澄清”和“服务失败”必须分开。
多篇文章无法确定指代,是需要澄清;意图模型超时、返回非法结构,则是系统异常。当前意图解析对部分可重试错误自动重试一次,每次默认最多 8 秒;仍失败就报告服务异常,不偷偷退回猜测性筛选。
请求入口还包含来源校验、输入限制、可配置的人机验证,以及按 IP 和全站统计的额度保护。后端记录模型调用、Token、检索和完成状态,帮助区分问题发生在哪个阶段。
这些约束并不保证系统永远不会误判,但能让失败更可解释,而不是靠一句模糊回复掩盖错误。
发布文章和同步向量是两个步骤
知识更新不发生在每次聊天过程中。
1 | 新增或修改文章 |
这里有一个容易混淆的细节:聊天时 Worker 读取线上公开索引;向量同步脚本默认读取本地构建生成的公开索引。发布与同步需要使用一致的文章版本,而不是假定同步脚本会自动抓取线上最新文章。
仅发布而未同步时,新文章仍可能被目录查询和关键词检索找到,但语义检索可能漏掉它。
当前同步逻辑基于片段内容生成 ID,只为新增或变化的片段生成向量。旧向量默认保留,可以在确认发布后显式清理;聊天检索仍会过滤无法对应当前公开索引的旧结果。Vectorize 是最终一致的,提交同步不代表所有查询立即可见,线上索引缓存也可能带来短暂延迟。
同步令牌只属于本地运维配置,不应进入文章、前端脚本或版本库。博客助手在聊天时不需要读取本地同步令牌。
六、这套设计解决了什么,又没解决什么
这次改造的重点,不是增加一个更复杂的提示词,而是把职责边界划清:模型理解意图,程序执行确定性查询;检索提供候选,筛选决定推荐;历史帮助定位,当前正文提供事实依据。
它改善了关键词误匹配、推荐混入无关文章、文章追问失焦和服务错误表达不清等问题,但仍有现实限制:最多 6 个片段可能漏掉内容,意图与相关性判断仍可能出错,短期历史不是长期记忆,向量更新也存在同步延迟。
如果继续迭代,可以考虑扩大候选池并增加更细的重排序,为长文章设计分层摘要,并建立覆盖“最新文章、主题推荐、连续追问、检索失败”的固定评测集。这些是后续方向,不是当前已经具备的能力。
对个人博客来说,可靠的 AI 助手不一定需要自主行动。更重要的是:知道什么时候应该查目录,什么时候应该读文章,什么时候可以补充资料,以及什么时候应该承认还不能确定。

