s-blog

从"能搜"到"能问":给个人博客加语义搜索和 RAG 的全过程

95 篇笔记的向量检索与"问我的博客"是怎么落地的——以及那些值得记下来的设计取舍

ssssmy · 2026-06-09 · 8 min

有一天我想在自己博客里找一篇讲接口限流的笔记。我记得里面提到过"令牌桶",但一时想不起标题,就在搜索框里敲了「限流」。

结果——什么都没搜到。

那篇文章标题就叫《令牌桶》,正文从头到尾在讲限流,但因为通篇没出现"限流"这两个字,基于字面匹配的全文搜索就是命不中。那一刻我意识到:我的博客能搜,但不能理解

这篇文章记录我怎么把它从"能搜"升级到"能问"——加上语义检索和一个叫「问我的博客」的 RAG 问答。不是教程,是一份把设计取舍和踩过的坑都摊开来讲的实践笔记。

起点:字面匹配的天花板

博客原本的搜索是 MySQL 的 ngram 全文索引(FULLTEXT + WITH PARSER ngram)。它很快、很稳、零外部依赖,对"搜标题里有的词"足够好。但它的世界里,「限流」和「令牌桶」是两个毫不相干的字符串。

要跨越这道坎,需要让机器理解语义而不是字面。这就是 embedding(向量化)的用武之地:把每段文字映射成一个高维向量,语义相近的文字在向量空间里距离也近。于是「限流」的向量和「令牌桶」的向量会靠得很近,cosine 相似度能达到 0.59——而它和一段无关文字只有 0.34。这个 0.25 的差距,就是"理解"和"不理解"的分界线。

目标也随之清晰起来,分两层:

  1. 语义搜索:搜「限流」能命中「令牌桶」。
  2. 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 的拐点在哪:几千篇以内高枕无忧,到几万篇就该上专用向量索引了。但那是未来的问题。现在它跑得很好,搜「限流」能找到「令牌桶」,问"怎么防接口被刷"能得到一段有理有据、还附带来源的回答。

这就够了。一个博客,从能搜,到能问。

原文链接:https://www.ssssmy.com/blog/cong-neng-sou-dao-neng-wen-gei-ge-ren-bo-ke-jia-yu-yi-sou-suo-he-rag-de-quan-guo-cheng