跳至主要内容

个人博客做 RAG:小语料暴露的四个坑

我的博客导出成纯文本大约 31 万字,一百多篇文章。我原以为这个体量做 RAG 是杀鸡用牛刀:语料这么小,检索几篇出来塞进上下文不就行了?第一版跑起来,排在最前面的结果是归档页和「关于我」,还有同一篇文章的三个分页。模型没出错,错的是我喂给它的东西。

抓下来的第一版语料,三成是噪音

我一开始从 sitemap 拿 URL,看到一百八十多个地址还挺高兴。真正清洗时才发现,里面混着标签页、分类页、分页、归档页,以及「关于」「友链」这类固定页面。正文选择器我也偷懒用了 body,结果导航、版权声明、相关阅读全进了语料。最明显的一次是搜「缓存」,每篇文章都命中,因为每篇底部都有一句「相关阅读:缓存 key 多加一个时间戳」。这句话本身没错,但它不该被当成文章内容。

后来我改成只取 article 容器,删掉 nav、footer、aside、script、style,以及 class 里带 related、pagination 的块。URL 也做规范化:去掉 query,去掉尾斜杠,按 canonical 去重,/page/2 这种直接排除。清洗完从一百八十多个 URL 降到一百二十来篇正文。代码大致是这样:

PYTHON10 行
1
2
3
4
5
6
7
8
9
10
from bs4 import BeautifulSoup

def clean(html):
soup = BeautifulSoup(html, 'html.parser')
for tag in soup.select('nav, footer, aside, script, style, .related, .pagination'):
tag.decompose()
article = soup.select_one('article') or soup.select_one('.post-content')
if not article:
return ''
return article.get_text(separator=' ').strip()

这个清洗函数很土,但它把「哪些东西算文章」这件事显式化了。我现在的判断是,小语料 RAG 的第一步不是选嵌入模型,而是把站点结构翻译成一份干净的文章清单。清单不干净,后面调什么参数都像在猜。

分块切在标题上,召回再拼回文章

第一版分块我按固定字数切,600 字一块,重叠 100 字。跑起来才发现问题:代码块被切成两半,列表被切成两半,一个 h2 下面的论证被切到两个块里,检索命中前半段,模型拿到的却是半截话。更麻烦的是,块和块之间没有标题信息,模型不知道这段在讲什么。

我改成按 h2 切,超长再按段落切,每块大约 400 到 800 字,代码块整体保留,不切断。每个块前面拼一行「文章标题 > h2 标题」,嵌入时用这行前缀加正文,展示时只给正文。这个前缀有没有用?我的体感是有,尤其是博客里很多 h2 叫「问题」「做法」「结果」,不带文章标题根本分不清。我还没有定论前缀该拼到多细,目前只拼到二级标题,再往下会太吵。

另一个和想象不一样的地方是召回粒度。我一开始让模型只读召回的块,答案经常缺上下文。后来改成:先用块召回 top 8,去重得到 3 篇文章,再把整篇文章或者该 h2 所在章节送给模型。一篇文章平均两千多字,大致一千八百 token,三篇五千多 token,加上问题完全在预算内。小语料的好处就在这里:检索只负责找到文章,阅读交给模型。块不是最终答案的单位,文章才是。

检索本身也没上向量数据库。几百个 chunk 存在 sqlite 里,启动时把 embedding 读进内存,归一化后暴力算余弦相似度,一次查询几十毫秒。代码就十来行:

PYTHON7 行
1
2
3
4
5
6
7
import numpy as np

def search(query_vec, top_k=8):
# chunk_matrix 已归一化
scores = chunk_matrix @ query_vec
idx = np.argsort(scores)[-top_k:][::-1]
return [(chunk_ids[i], float(scores[i])) for i in idx]

日期和「无答案」比相似度更麻烦

博客和文档不一样,它有时间。我早期写过一些现在不同意的做法,后来文章里改了,旧文章还留在那里。检索会把新旧一起召回,模型混着答。有一次问「缓存 key 要不要加时间戳」,它同时引用了两篇,结论互相打架。解决不是删旧文,而是给每个 chunk 带上 published_at 和 updated_at,在 prompt 里标注「这篇发布于 2021 年,可能已经过时」,排序时对两年以上的文章轻微降权。有些旧文是唯一来源,不能直接扔掉。

比日期更麻烦的是「无答案」。我手写了三十个问题,其中八个是博客根本没写过的,比如「你怎么做多租户隔离」。第一版模型全回答了,还从不相干的文章里凑了一段。我试过调相似度阈值,效果不好,因为总有一段相似度不低。后来改成两段式:先让模型看文章标题和摘要列表,判断有没有相关文章;没有就直接拒答,有才进入块检索。这个改动比换嵌入模型明显。二十来个可回答问题里,能命中正确文章的从个位数变成一半以上;无答案问题从全答变成大部分拒答。这个数字不严谨,但方向很清楚。

小语料的技术选型:能暴力算就别上框架

我试过把流程搭得很完整:解析、分块、嵌入、向量库、检索、重排、生成,每一步都加配置。跑到最后发现,个人博客这个体量,复杂度主要来自我自己加的东西。现在我的管线只有四步:清洗后的文章存成 JSON,按 h2 切块,嵌入存 sqlite,查询时暴力算 top k。嵌入模型用本地的 bge-m3,几百个 chunk 一次算完,换模型时重跑一遍也不心疼。我没有做增量索引,也没有做缓存,因为全量重算只要几分钟。

这套东西不优雅,但它让我能快速验证「是不是语料的问题」。我现在的判断是,小语料 RAG 值不值得继续调,先看两件事:清洗后的文章数是不是显著少于抓取数,以及无答案问题能不能被拒掉。如果这两个都不行,换更大的嵌入模型基本是白换。下一步我想做的不是换模型,而是把每篇文章的「更新于」和「勘误」补全,再重跑那三十个问题。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#RAG #博客 #检索 #评测

评论