一、本周工作:把「AI 侧栏」从 Mock 升级成真实私域问答链路

前几周我们跑通了:B 站字幕抓取、清洗、语义切片、Map-Reduce 摘要、SSE 流式导入。本周的任务是让 AI 能够基于用户自己已经沉淀在库里的笔记进行问答,也就是典型的私域 RAG(检索增强生成)。

考虑到中期节点工期和部署复杂度,我们没有直接上向量数据库,而是用 MySQL 自带的 FULLTEXT 索引加上 LIKE 兜底,实现轻量级 RAG。整个工作量包括:

  • 后端新增检索服务、上下文截断策略、专用 RAG 系统提示词
  • 新增 REST 接口 POST /api/ai/rag/chat
  • 前端从纯 Mock 切换到真实请求,并实现异常降级
  • 顺带修了 Maven 聚合工程配置和异常处理类的编译问题

工作量主要集中在检索逻辑的反复调试、提示词的多次迭代、以及前后端联调时遇到的超时、映射、降级等细节上。

二、这周做私域问答,而不是继续做生成类功能

前几周我们已经实现了不少「AI 处理外部材料」的功能,包括从 B 站抓字幕、清洗、切片、摘要入库等,演示效果不错。但产品角度上,仅仅把材料整理好还不够,用户更需要的是「我已经记了这么多笔记,AI 能不能帮我总结或者回答基于这些笔记的问题」。

如果直接用大模型回答,没有检索步骤,模型很容易编造用户笔记里根本不存在的内容。引入向量检索虽然更先进,但需要额外部署 Milvus 或者 Faiss,还要处理 Embedding 服务、向量维度、召回阈值等一堆新问题,短期内很难在中期节点前稳定联调。

团队讨论后决定先做轻量版:复用现有 MySQL 表和全文索引,把链路先跑通,把接口和前端组件定下来,后续再考虑换成向量召回。这样既能满足中期演示「能问能答」的核心需求,又不会让整个系统在中期阶段变得过于庞大。

三、检索层的具体实现与调试过程

后端在 NoteMapper 里新增了两个方法。主路径 searchByFulltext 用的是 MySQL 的 MATCH AGAINST 自然语言模式,针对 title 和 content_md 两个字段做全文检索,按相关度排序后取 topK 条。注释里写明了依赖 ngram 分词器,中文查询大概两个字以上比较容易命中。

但 FULLTEXT 并不是万能的。数据量小的时候、或者查询词比较生僻的时候,经常返回空结果。我们在 RagChatService 里做了兜底:如果 FULLTEXT 结果为空或者抛异常,就自动调用 searchByLike,用 LIKE 模糊匹配标题和正文,再按更新时间倒序取最新几条。

实际编码时发现,LIKE 虽然简单,但如果不加用户 ID 和软删过滤,容易把别人的笔记也拉进来,所以两条 SQL 都必须带上 userId 和 is_deleted=0 这两个条件。

去重也是花了点时间的地方。同一篇笔记可能因为标题和正文都命中而被重复返回,我们在服务层用 LinkedHashMap 按 noteId 去重,保证后续拼上下文时不会浪费篇幅。整个检索逻辑从写完到稳定,大概花了两个晚上反复用真实笔记数据测试,才把空结果、重复、排序这些边界情况都覆盖到。

四、上下文管理与 Token 预算的实际考量

把检索到的笔记直接全部塞进 Prompt 显然不现实。一方面大模型有上下文长度限制,另一方面太长的材料也会让模型重点不突出。我们在配置里加了三个参数:

  • top-k 控制最多召回几篇
  • max-chars-per-note 限制单篇最多摘多少字
  • max-context-chars 限制整个参考材料区总字符数

实际拼装上下文时,用 StringBuilder 一边追加一边检查剩余预算,单篇笔记超过上限就截断,材料区总长度超限就提前停止。这样既保证了关键信息能进去,又避免了 Prompt 过长导致的超时或者截断。配置值目前是 top-k=5、单篇最多 4000 字、总材料 12000 字左右,后面如果发现模型回答质量不够,可以再微调这些数字。

五、RAG 专用提示词的设计与迭代

我们在 AgentPromptTemplates 里新增了 RAG_CHAT_SYSTEM 常量,和之前的内容提炼、结构排版、复习出题提示词并列,满足任务书对多套独立 System Prompt 的要求。提示词的核心约束有四条:

  1. 只能依据提供的参考材料回答,禁止编造笔记里没有的实体、日期、链接
  2. 材料不足时必须原样输出「您的私人笔记中未包含此信息」这句话,后面可以用列表说明缺什么
  3. 输出优先用 Markdown,语气要克制专业,不要寒暄
  4. 如果用户问题适合表格对比,可以用 Markdown 表格,但表格内容必须来自材料

这个提示词不是一次写完就结束的。第一次写的时候太宽松,模型还是会偶尔加一些「一般来说」之类的通用知识。后来我们把「禁止编造」和「固定话术」这两条写得更死,实际测试时幻觉情况明显减少。固定话术的设计特别有用,答辩时评委如果故意问库里没有的内容,系统行为是可预测的,而不是模型随机发挥。

六、接口、DTO、VO 的新增与前后端联调

后端在 AiWorkbenchController 里加了 POST /api/ai/rag/chat 接口,请求体用 RagQueryDTO,里面就一个 question 字段,加了 NotBlank 和 Size 校验,长度限制在 5 到 512 字之间。响应体是 RagChatResultVO,包含 citations 列表、answer 字符串、usedLlm 布尔值。

前端这边,新增了 rag.js 文件,专门封装这个接口,超时时间单独设成两分钟,因为大模型生成第一段内容有时候会比较慢。Pinia 的 aiChat store 做了调整:优先尝试调用后端接口,如果成功就把后端返回的 noteId 映射成前端侧栏卡片一直用的 id 字段;如果请求失败或者问题太短,就降级走之前基于本地笔记列表的简易 Mock 检索,同时在界面上给用户一个提示。

联调过程中遇到过几次问题。一次是 Token 失效后接口返回 401,但前端没有及时跳转登录页,后来发现是 axios 拦截器里对业务码 401 的处理逻辑有遗漏。另一次是短问题直接被后端校验 400,前端降级 Mock 时没有给用户明确提示,用户误以为接口坏了。后来我们在 Toast 里加了原因说明,体验就好多了。

七、测试与演示准备

检索功能测试主要是用自己之前导入的几篇笔记做实验,包括 Java 并发编程、B 站视频摘要、Spring Boot 配置等内容。测试了正常命中、需要兜底、完全不命中三种情况,也测试了跨用户权限隔离是否生效。提示词测试则用了故意问不存在内容的用例,验证固定话术是否稳定触发。

演示准备时,我们把配置里的 LLM 开关做了区分:先不配 API Key,演示检索和引用卡片;再配上 Key,演示模型基于材料生成的总结。两条路径用同一套接口,前端自动识别 usedLlm 字段决定显示什么提示,现场切换很顺畅。

八、目前还存在的问题和后续计划

召回效果目前还依赖关键词匹配,语义相近但用词不同的笔记容易漏掉。下一步可以考虑在标题和摘要字段上建二级索引,或者对用户高频查询做日志统计,优先优化热点路径。

会话目前是单轮的,用户追问上一句的问题时,系统不知道上下文。后续需要设计 threadId、历史消息截断、以及引用笔记的增量合并策略。

可观测性方面,现在还没有对检索耗时、FULLTEXT 命中率、LIKE 兜底频率做打点,答辩时如果能展示一些数据会更有说服力。可以考虑加 Micrometer 指标或者简单日志统计。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐