# 博客 AI 语义搜索（RAG）开发实录：从功能设计到线上部署

> 发布时间: 2026-08-02 20:37

# 博客 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_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 条全是"网站搭建实录"系列，不相干文章全部消失。

## 五、部署与验证

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

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（执行案）*
