3 RAG:从博客文章到可引用回答

3 RAG:从博客文章到可引用回答

RAG 这个词很容易被说得太大。对 StarryRAG 来说,它没有试图让模型“记住整个互联网”,只做了一件比较具体的事:从个人博客文章里找出几段相关内容,把它们放进提示词,再让模型据此回答。 完整链路可以压缩成:  博客页面   -> 抓取和正文提取   -> 文本切分   -> Embed

RAG 这个词很容易被说得太大。对 StarryRAG 来说,它没有试图让模型“记住整个互联网”,只做了一件比较具体的事:从个人博客文章里找出几段相关内容,把它们放进提示词,再让模型据此回答。

完整链路可以压缩成:

 博客页面
   -> 抓取和正文提取
   -> 文本切分
   -> Embedding
   -> Qdrant 检索
   -> RRF 重排
   -> LLM 流式回答

每一步都可能影响最终答案。模型本身只是最后一环。

1. 先把博客页面变成干净文本

知识库入口是 sitemap 和 RSS。爬虫发现 URL 后,项目会用 Jsoup 读取页面,再优先选择文章正文区域。页面导航、评论区和站点脚注不应该混进知识库,否则模型会把菜单文字当成文章内容。

当前博客文章的路径统一使用 archives 规则:

 site:
   base-url: https://starryv.top
   include-patterns:
     - ".*/archives/.*"

这条规则看起来简单,但它解决了一个实际问题:sitemap 里可能同时有标签页、分页页和文章页。如果不先过滤,导入结果会膨胀,召回内容也会变杂。

网站 URL 还要做归一化。站点生成的链接有时带有 80 端口,而 HTTPS 反代实际监听 443。抓取器会去掉不必要的端口,避免同一篇文章产生多个来源地址。

2. 切分不是越碎越好

当前默认切分参数是:

 chunk:
   size: 600
   overlap: 100

600 和 100 不是模型的硬性要求,而是针对博客正文的折中:

  • 块太大,检索命中后会带入很多无关内容。

  • 块太小,标题和解释容易被拆开,模型看不到完整上下文。

  • 保留 100 个字符重叠,可以减少边界处的信息断裂。

代码块、列表和段落不一定适合完全相同的切分策略。文章里如果有一大段 Java 代码,按字符切开后,单独检索到的片段可能无法编译,也很难解释。后续如果文章类型增加,可以给代码块和正文分别设计切分规则。

3. 召回阶段只负责找候选

用户提问后,RetrievalService 先把问题编码成向量,再向 Qdrant 查询最多 8 个结果:

 retrieve:
   top-k: 8
   min-score: 0.0

这里的 top-k 是候选数量,不是最终一定会引用 8 篇文章。后续会做重排,最终提示词里仍然只保留有限的上下文。

当前默认关闭 HyDE:

 hyde:
   enabled: false

HyDE 的做法是先用一个非流式模型,根据用户问题生成一段“假设性文档”,再对假设文档做 Embedding 检索。它有机会改善抽象问题的召回,但会多一次 LLM 请求和一次向量化请求。对个人博客问答来说,首响应延迟往往比这一点召回收益更明显,所以先关闭更实际。

如果确实遇到“用户的问法和文章表达差别很大”的问题,可以在服务器环境变量里打开 HyDE,再比较召回结果,而不是默认给每个请求都增加一轮调用。

4. RRF 解决什么问题

向量相似度擅长找语义接近的内容,但对专有名词、类名和路径不一定稳定。比如用户问:

项目里的 QdrantEmbeddingStoreImpl 做了什么?

这时,文本中出现的类名本身很有价值。

StarryRAG 使用内置 RRF,把向量排序和简单词法排序合并。它不依赖额外的重排模型,成本低,也容易排查:

 向量排序:按相似度排名
 词法排序:按查询词在候选文本中的命中情况排名
 RRF:把两个排名转换成分数后合并

如果配置 Cohere,也可以切换到 Cohere 重排。但在默认配置下,RRF 已经足够支撑这个项目,至少不会因为又增加一个外部接口而让排障链条变长。

5. 来源要进入上下文,也要返回给前端

只在提示词里告诉模型“请引用来源”不够。模型有时会忘记,或者把 URL 写错。因此项目做了两层处理。

第一层是在上下文中显式写入来源:

 - Source: https://starryv.top/archives/example
 - Content: 文章片段内容

第二层是在 SSE 的 done 事件中返回来源数组:

 {
   "type": "done",
   "sources": [
     "https://starryv.top/archives/example"
   ]
 }

前端把来源数组渲染到回答底部,用户可以直接打开文章。即使模型没有在正文中写链接,回答仍然有出处。

来源 URL 还需要做协议校验。widget 只允许 http 和 https,避免把未经处理的字符串直接当成链接插入页面。

6. 不相关时要承认没有找到

RAG 系统最容易犯的错,不是偶尔答错,而是明明没有检索到内容,却让模型继续编。

系统提示词要求模型只根据参考资料回答。如果候选为空,项目会进入规则兜底。兜底回答的内容来自 resources/knowledge 和 rules.json,而不是临时猜测。

这条边界看起来保守,但个人博客场景不适合让助手替博主补全不存在的经历。回答短一点没关系,来源和事实边界要清楚。

7. RAG 调优应该看哪几个指标

不要只看“感觉回答更聪明了”。可以记录:

  • 首个 token 的延迟。

  • 检索接口耗时。

  • 候选片段的来源和相似度。

  • 最终回答是否使用了正确文章。

  • 空召回比例。

  • 缓存命中比例。

  • 用户是否点击了参考文章。

这些数据能帮助区分问题到底在 Embedding、切分、重排,还是生成模型。否则调参很容易变成“换一个模型再试试”。

LICENSED UNDER CC BY-NC-SA 4.0
Comment