博客 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 个阶段推进,每阶段有独立验收:
- 环境准备:本地/服务器安装 pgvector,引入
Pgvector.EntityFrameworkCoreNuGet 包 - 数据层:新增 6 张表(document_summaries / document_chunks / search_index_state / ai_settings / ai_prompts / ai_api_keys)
- AI 配置体系:配置存库 + 内存快照 + 热更新,API 密钥 AES-256-GCM 加密存储
- AI 服务层:封装打标、总结、向量化、查询分析四个方法
- 语义搜索服务层:索引构建、删除、多路召回、多维排序、全量重建
- MCP 工具:
blog_semantic_search,遵循现有IToolHandler开放-闭合模式 - 链接触发:
BlogService的 create/update/delete 自动触发索引维护 - 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_query 和 suggested_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 条全是"网站搭建实录"系列,不相干文章全部消失。
五、部署与验证
部署到生产服务器,特别注意不破坏原有数据:
- 服务器安装 pgvector,执行建表 + 种子脚本(新增 6 张表,users/posts 原样保留)
AIDATA_MASTER_KEY注入启动脚本(已备份原脚本)- AI 密钥 AES 加密入库
- 发布 framework-dependent 版本,rsync 上传(排除 appsettings.json 保留远端配置)
- 重启服务(只动 5003,AgentHub 5004 不受影响)
- 全量重建索引:25 篇真实博文全部进入 AI 搜索
线上验证:
查询 "MCP服务器配置" → 命中「FileUploadServer 开发记录:MCP 接口」(0.60)
查询 "网站搭建" → 命中「网站搭建实录②」(0.62)
六、经验总结
- 日志先行:没有诊断日志,第 3、4 两个坑几乎无法定位。给核心流程加结构化日志,成本极低、收益巨大。
- 数据先于算法:排序不对时,先看数据(PublishDate、标签、向量)是否符合预期,再调算法。
- 标题是 RAG 的隐藏关键:正文向量化时把标题带上,检索质量提升立竿见影。
- 分阶段交付:8 个阶段各自可验证,出问题时能快速定位在哪个阶段。
- 降级兜底:AI 调用不可靠,每个环节都要有降级方案(解析失败用原文、AI 挂了不影响博客 CRUD)。
现在,https://web.sub.opengm.top/ 顶部就有 AI 搜索栏,欢迎体验——输入一句人话,看看能不能找到你想看的内容。
相关文档:doc/features/04-ai-semantic-rag.md(细案)、doc/features/05-ai-semantic-rag-execution.md(执行案)