从"能搜"到"能问":给个人博客加语义搜索和 RAG 的全过程
95 篇笔记的向量检索与"问我的博客"是怎么落地的——以及那些值得记下来的设计取舍
有一天我想在自己博客里找一篇讲接口限流的笔记。我记得里面提到过"令牌桶",但一时想不起标题,就在搜索框里敲了「限流」。
结果——什么都没搜到。
那篇文章标题就叫《令牌桶》,正文从头到尾在讲限流,但因为通篇没出现"限流"这两个字,基于字面匹配的全文搜索就是命不中。那一刻我意识到:我的博客能搜,但不能理解。
这篇文章记录我怎么把它从"能搜"升级到"能问"——加上语义检索和一个叫「问我的博客」的 RAG 问答。不是教程,是一份把设计取舍和踩过的坑都摊开来讲的实践笔记。
起点:字面匹配的天花板
博客原本的搜索是 MySQL 的 ngram 全文索引(FULLTEXT + WITH PARSER ngram)。它很快、很稳、零外部依赖,对"搜标题里有的词"足够好。但它的世界里,「限流」和「令牌桶」是两个毫不相干的字符串。
要跨越这道坎,需要让机器理解语义而不是字面。这就是 embedding(向量化)的用武之地:把每段文字映射成一个高维向量,语义相近的文字在向量空间里距离也近。于是「限流」的向量和「令牌桶」的向量会靠得很近,cosine 相似度能达到 0.59——而它和一段无关文字只有 0.34。这个 0.25 的差距,就是"理解"和"不理解"的分界线。
目标也随之清晰起来,分两层:
- 语义搜索:搜「限流」能命中「令牌桶」。
- RAG 问答:直接问"怎么防止接口被刷",让 AI 读着我的文章,生成一段带引用来源的答案。
embedding 选型:一段弯路
第一个要定的是用什么模型做向量化。我先试了 NVIDIA NIM 的一个 embedding 模型,结果在中文上实测是噪音——相关和无关文本的相似度几乎没有区分度,向量空间里一团乱麻。
换成阿里云 DashScope 的 text-embedding-v3(1024 维)之后,中文区分度立刻就出来了:相关 0.59、无关 0.34,同句自比 1.0。它还有两个顺手的好处:中文语料训得好、价格便宜、而且和我后面要用的问答大模型(qwen3.7-max)共用一个 API key。
教训很朴素:embedding 模型一定要在你自己的真实语料上测区分度再定,别看榜单。一个在英文榜单上漂亮的模型,可能在你的中文博客上是随机数。
三个架构决策
技术选型定了,真正花心思的是架构。有三个决策我觉得值得记下来。
决策一:向量存独立表,主表零改动
最直接的做法是给 posts 表加一个 vector 字段。我没这么做,而是建了一张独立的 content_embeddings 表:
model ContentEmbedding {
id String @id @default(cuid())
refType String // "post" | "work"
refId String // 指向主表,多态,无外键
hash String // sha256(title + excerpt + content),内容指纹
vector Bytes @db.LongBlob // float32 × 1024 = 4096 字节
dim Int @default(1024)
@@unique([refType, refId])
}
为什么解耦?四个理由:检索时只查这张小表(id + 向量),不用碰 posts 的大正文字段;post 和 work 可以统一进一张表;主表的 migration 零风险;将来换 embedding 模型或加多模型也有扩展位。
向量本身存成裸 float32 字节流(不是 JSON 数组),一条 4KB,95 篇约 380KB,对 MySQL 来说毫无压力。
决策二:发布不触发向量化,交给 cron 自愈
这是我最满意的一个决定。直觉上,"发布文章"应该顺手把它向量化。但我让发布流程完全不知道 embedding 的存在——PostsService 里没有一行 embedding 调用。
取而代之,一个每 30 分钟跑一次的 cron 任务来做"对账":
扫描所有已发布内容 → 算 hash → 跟向量表比对
├─ 缺失 / hash 变了 → 重新向量化(批量调 API)
└─ 主表已删 / 转草稿 → 清掉孤儿向量
好处是写入路径保持简单、快、零外部依赖:发布文章这个核心动作,绝不会因为 DashScope 抖动而失败。向量的"最终一致"由 cron 兜底,通常半小时内补齐。
那个 hash 字段是点睛之笔——它是内容的指纹。内容没变,hash 就不变,cron 直接跳过,不会重复花钱调 embedding API。改了稿,hash 自动失效、自动重算。整个机制幂等、自愈,重复跑没有任何副作用。
决策三:全量内存 cosine,不上向量库
很多人一提向量检索就想到 Pinecone、Milvus、pgvector。但我的博客只有几百篇内容。检索时,我把所有向量一次性读进内存,手写一个 cosine 循环挨个算相似度,排序取 top-K。
所有向量 load 进内存 → 逐条 cosine(query, doc) → 排序 → 取前 K 个
几百到几千条的规模,这是毫秒级的,根本不需要专门的向量数据库和它带来的运维负担。工程上有个朴素的真理:别为还没遇到的规模付代价。等真涨到几万篇,再换 ANN 索引也只需要动检索那一个函数,其余链路一行不改。
检索:三种模式
检索做了三个模式,默认混合:
- keyword:老的 ngram 全文,字面匹配。
- semantic:query 向量化 → 内存 cosine top-K → 关联文章元数据。
- hybrid:两路并行,用 RRF(Reciprocal Rank Fusion)融合。
RRF 这里值得说一句。keyword 的相关性分数和 semantic 的 cosine 值是两个量纲完全不同的东西,直接加权融合是错的。RRF 的聪明之处是它只看排名不看分数:
score(文档) = Σ 1 / (k + 该文档在每一路的排名),k = 60
一篇文章如果在关键词和语义两路都靠前,融合分自然就高。量纲不可比的问题被"排名"这层抽象优雅地绕开了。
RAG 问答:让 AI 读着我的文章回答
语义搜索解决"找到",RAG 解决"回答"。流程是:
问题 → 向量化 → 检索 top-5 文章 → 每篇正文截到 1200 字拼成 context
→ 塞进 prompt 喂给 qwen3.7-max(流式)
→ 逐字 SSE 推给前端 + 末尾推一条"来源"事件
system prompt 我反复调过,核心是给模型立规矩:“基于提供的文章内容回答,相关主题可以主动归纳,完全无关时才说没找到,绝不编造,回答里不要列引用编号”。最后那条很重要——引用来源由前端单独渲染成可点击的卡片,比模型在正文里塞[1][2]体验好得多。
流式(SSE)是体验的关键。答案逐字蹦出来,而不是转圈几秒后整段砸到脸上。这件事在生产环境差点翻车——见后面的踩坑。
多轮对话:追问改写
单轮问答不够。我想要的是能追问的对话:“博客讲了哪些设计模式?” → “它们哪个最常用?” → “那它和工厂模式比呢?”
问题在于,"它和工厂模式比呢"这种句子,代词、省略一大堆,直接拿去做向量检索是检索不准的——向量空间里它谁都不像。
解法是 conversational retrieval 的标准做法:先改写,再检索。在检索之前,先用一次轻量的 LLM 调用(低 token、低温度),结合对话历史把追问改写成一个不依赖上下文的独立问题:
"它和工厂模式比呢" + 历史(在聊策略模式)
↓ condense 改写
"策略模式和工厂模式有什么区别"
↓ 拿这个去向量检索
准确命中《策略模式》《工厂与生成器》
几个工程细节:首轮没有历史,直接跳过改写(零额外开销);改写失败就回退用原问题,绝不因为改写挂了把整个问答拖垮。对话历史本身存在前端内存里,每轮随请求带上——后端完全无状态、不建表、不落库。匿名用户的对话零隐私负担,关掉搜索框就什么都不剩。
私密内容:在每一层都要堵
博客里有些是私密笔记,公开站、搜索、RAG 都不该暴露。可见性控制必须贯穿整条链路,而且 RAG 这里有个尤其要小心的点:
过滤必须发生在"喂给大模型之前"。 检索阶段就要把私密内容剔除,绝不能让它进入 context。否则就算前端不显示,私密内容也已经被发给了大模型——这是堵不住的泄漏。所以匿名问答在 retrieve 那一步就只捞公开内容,私密文章根本没机会进入 prompt。
踩过的坑
几个值得拎出来的:
- SSE 被反代缓冲:这是最隐蔽的坑。流式答案在本地好好的,一上生产就变成"转圈几秒、整段蹦出"。原因是反向代理(nginx/Caddy)默认会缓冲响应。解法是后端响应头加
X-Accel-Buffering: no+Cache-Control: no-transform。部署后一定要亲眼确认是逐字输出而不是整段。 - 向量序列化要对齐:float32 存成裸字节再读回来时,Buffer 不一定是 4 字节对齐的,得先拷一份再
new Float32Array,否则数据错乱。 - embedding 有批量上限:DashScope 单次最多 10 条输入,全量回填时要分批。
- 优雅降级别 crash:没配 API key 的时候,语义搜索自动退回关键词、RAG 返回 503,整站照常工作。一个增强功能不该成为单点故障。
写在最后
回头看,这套东西的内核其实和具体技术栈关系不大,可以抽象成五步:定义内容单元 → 向量化 → 存储 → 同步 → 检索(RAG 再加一步拼 context + LLM)。我用的是 NestJS + Prisma + MySQL + DashScope,但换成任何栈,骨架都是这五步。真正需要按自己业务重新设计的,只有"同步"那一步——我选了 cron 自愈,你的场景可能是发布事件即时触发,或者消息队列。
至于扩展性,我心里清楚现在这套全量内存 cosine 的拐点在哪:几千篇以内高枕无忧,到几万篇就该上专用向量索引了。但那是未来的问题。现在它跑得很好,搜「限流」能找到「令牌桶」,问"怎么防接口被刷"能得到一段有理有据、还附带来源的回答。
这就够了。一个博客,从能搜,到能问。