博客 AI 语义搜索(RAG)开发实录:从功能设计到线上部署

博客 AI 语义搜索(RAG)开发实录:从功能设计到线上部署

2026-08-02 · 本文记录 WebDoor 博客系统上线 AI 语义搜索功能的完整过程:功能设计、分阶段实施、踩过的 4 个关键坑及其修复、最终部署上线。

一、功能背景

WebDoor 是一个 ASP.NET Core 10 MVC 博客系统。博客文章越写越多,传统的关键词搜索已经无法满足"用一句话找到想要的内容"的需求。比如用户搜"如何配置 MCP 服务器",希望直接命中讲 MCP 配置的文章,而不是靠拼关键词。

于是我们决定引入 RAG(Retrieval-Augmented Generation)语义检索

  • 每篇文章发布时自动做 AI 打标签、AI 总结、全文分块、向量化,写入 PostgreSQL + pgvector
  • 搜索时把用户查询向量化,通过向量相似度召回最相关的文章
  • 对外暴露 MCP 工具 blog_semantic_search,也做了 Web 前台搜索栏

二、技术架构

写入管线(增量索引)
  博文 create/update/delete
    → BlogService 链接触发
    → AI 打标签 / AI 总结 / 全文分块
    → 总结向量 + 分块向量 写入 pgvector (HNSW 索引)

查询管线
  用户自然语言查询
    → AI 查询意图分析(改写 + 提取标签)
    → 查询向量化
    → 总结向量召回 + 分块向量召回(多路)
    → 多维综合排序
    → 返回相关博文 + 评分 + 匹配片段

技术选型:

组件 选择
向量存储 PostgreSQL 16 + pgvector(HNSW 索引)
Chat 模型 DeepSeek(打标/总结/查询分析)
Embedding 模型 火山方舟 Doubao embedding(1024 维)
对接方式 OpenAI 兼容 REST API
配置管理 全部存数据库(ai_settings / ai_prompts / ai_api_keys),热更新免重启

三、分阶段实施

整个功能按 8 个阶段推进,每阶段有独立验收:

  1. 环境准备:本地/服务器安装 pgvector,引入 Pgvector.EntityFrameworkCore NuGet 包
  2. 数据层:新增 6 张表(document_summaries / document_chunks / search_index_state / ai_settings / ai_prompts / ai_api_keys)
  3. AI 配置体系:配置存库 + 内存快照 + 热更新,API 密钥 AES-256-GCM 加密存储
  4. AI 服务层:封装打标、总结、向量化、查询分析四个方法
  5. 语义搜索服务层:索引构建、删除、多路召回、多维排序、全量重建
  6. MCP 工具blog_semantic_search,遵循现有 IToolHandler 开放-闭合模式
  7. 链接触发BlogService 的 create/update/delete 自动触发索引维护
  8. Admin 界面 + 命令行:可视化配置管理 + --rebuild-index 全量重建

四、踩过的 4 个关键坑

功能从"能跑"到"好用",靠的是这几个 bug 的排查。每个坑都留下了日志与思考。

坑 1:EF Core 列名大小写不匹配

现象:服务启动后,查询 ai_settings 表报错 column a.Id does not exist

原因:SQL 建表时列名是 snake_case(setting_key),但 EF 实体没配 HasColumnName,EF 按 PascalCase(SettingKey)生成 SQL,PostgreSQL 折叠后变成 settingkey,对不上。

修复:给 6 个实体补全 HasColumnName 映射,与现有 User.password_hash 的写法保持一致。

教训:SQL 手工建表时,EF 实体映射必须显式声明列名,别依赖 EF 默认命名。

坑 2:query_analyze 输出被截断导致降级

现象:日志里反复出现 查询分析结果无法解析为 JSON 对象,降级为原始查询

原因ai_prompts 表里 query_analyze 任务的 max_tokens 只配了 200。DeepSeek 是推理模型,思考过程(reasoning_content)会先消耗大量 token,200 的额度在输出最终 JSON 前就被 finish_reason: length 截断了。

修复:把 max_tokens 调到 1000,提示词改用精简模板。日志从"降级"变为正常解析出 refined_querysuggested_tags

教训:推理型 LLM 的 max_tokens 要给足思考余量;JSON 输出要有降级兜底。

坑 3:Recency 权重失真——所有文章都"满分"

现象:排序结果里,时间维度完全没起作用,老文章和新文章这项分数都一样。

原因:文档批量导入时,publish_date 用了服务端当前时间,导致所有文章 PublishDate 相同。recency = 1/(1+天数/365) 全部等于 1.0,变成恒定量。

修复:这是数据问题而非公式问题。同时调整了排序权重——把 Recency 从 0.15 降到 0.05,TagMatch 提到 0.25、ChunkHit 提到 0.20,让更可信的信号(标签、chunk 命中)主导排序。

教训:调试排序问题时,先确认数据本身对不对,别急着改算法;给 SearchAsync 加诊断日志,能看到每个候选的得分构成。

坑 4:标题没进向量化——最强的语义锚点丢了

现象:搜"网站搭建过程中可能遇到哪些问题",返回结果里 OpenAI接口接入调试 的分数比 网站搭建实录 还高。

原因:检查 BuildIndexAsync 发现——标题用于打标签,但总结向量化和分块向量化的输入都只有正文。标题这个最强的语义信号,在向量层面完全丢失。

修复:总结向量改为 标题\n总结 一起向量化;每个分块向量改为 标题: 分块文本。改后全量重建索引。

效果:同样查询"网站搭建",前 5 条全是"网站搭建实录"系列,不相干文章全部消失。

五、部署与验证

部署到生产服务器,特别注意不破坏原有数据

  1. 服务器安装 pgvector,执行建表 + 种子脚本(新增 6 张表,users/posts 原样保留)
  2. AIDATA_MASTER_KEY 注入启动脚本(已备份原脚本)
  3. AI 密钥 AES 加密入库
  4. 发布 framework-dependent 版本,rsync 上传(排除 appsettings.json 保留远端配置)
  5. 重启服务(只动 5003,AgentHub 5004 不受影响)
  6. 全量重建索引:25 篇真实博文全部进入 AI 搜索

线上验证:

查询 "MCP服务器配置" → 命中「FileUploadServer 开发记录:MCP 接口」(0.60)
查询 "网站搭建"     → 命中「网站搭建实录②」(0.62)

六、经验总结

  1. 日志先行:没有诊断日志,第 3、4 两个坑几乎无法定位。给核心流程加结构化日志,成本极低、收益巨大。
  2. 数据先于算法:排序不对时,先看数据(PublishDate、标签、向量)是否符合预期,再调算法。
  3. 标题是 RAG 的隐藏关键:正文向量化时把标题带上,检索质量提升立竿见影。
  4. 分阶段交付:8 个阶段各自可验证,出问题时能快速定位在哪个阶段。
  5. 降级兜底:AI 调用不可靠,每个环节都要有降级方案(解析失败用原文、AI 挂了不影响博客 CRUD)。

现在,https://web.sub.opengm.top/ 顶部就有 AI 搜索栏,欢迎体验——输入一句人话,看看能不能找到你想看的内容。


相关文档:doc/features/04-ai-semantic-rag.md(细案)、doc/features/05-ai-semantic-rag-execution.md(执行案)

📥 导出 Markdown