# 个人知识库笔记全文搜索MCP **Repository Path**: qiliping/note-mco ## Basic Information - **Project Name**: 个人知识库笔记全文搜索MCP - **Description**: 个人知识库笔记全文搜索MCP,基于markdown笔记 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-05-29 - **Last Updated**: 2026-05-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Note MCP Server 为 AI 提供笔记 BM25 全文搜索的 MCP 工具。 Spring Boot 3.3 + Spring AI 1.0,专注业务逻辑,MCP 协议由 Spring AI 自动处理。 --- ## 技术路线 ### 为什么选 Spring AI MCP Server MCP Server 本质上是一个"对接 AI 的协议适配层",本身没有业务价值。手写 JSON-RPC、SSE、session 管理全是重复劳动。 Spring AI 的 `spring-ai-starter-mcp-server-webmvc` 接管了所有这些底层协议: | 手写方案 | Spring AI 方案 | |---------|--------------| | 手写 `@RestController` 处理 `/sse` 和 `/messages` | 自动注册端点 | | 手动解析 JSON-RPC 请求、组装响应 | 自动处理协议 | | 自己维护 `Map` session 池 | 自动管理 session | | 手写 tools/list 的 schema 生成 | 从 `@Tool` 注解自动推断 | | 处理 HTTP 202 Accepted、SSE event 格式 | 自动处理 | 最终业务代码只剩两个 `@Tool` 注解的方法,每个方法只关注自己的业务逻辑。 ### 为什么用 Spring Boot + Java 老齐的主栈是 Java/Spring,选这个栈维护成本最低。项目体量极小(~500 行 Java),核心只依赖三样:Spring Boot、Spring AI、JDK 17。 ### BM25 全文检索(非向量 RAG) 笔记库以专有名词为主(曲美、云仓WMS、米哈游),精确匹配比语义搜索可靠。BM25 纯算法实现,无向量库/embedding 模型/GPU 依赖。查询性能约 1ms。 --- #### BM25 不是向量模型 BM25(Best Matching 25)是**概率检索函数**,不是向量/嵌入模型。它不产生向量、不做语义理解,而是通过**词频统计**计算文档和查询的匹配度。 与向量检索的对比: | 维度 | BM25(本项目) | 向量 RAG | |------|-------------|----------| | 原理 | 词频 + 逆文档频率统计 | 语义嵌入 + 向量距离 | | 匹配方式 | 精确关键词匹配 | 语义相似度 | | 对专有名词的匹配 | 强(字面命中) | 弱(易被语义稀释) | | 对同义替换的匹配 | 弱("到期"≠"截止") | 强 | | 是否需要模型/GPU | 否 | 是 | | 是否需要数据库 | 否 | 是 | | 查询延迟 | < 10ms | ~100-500ms | | 索引大小 | 内存中 ~文档文本的 30%-50% | 取决于模型维度 | > 本项目的笔记以专有名词和技术术语为主,查询场景是"找到提到 X 的段落",BM25 的精确命中比语义联想更可靠。 --- #### 索引存在哪里?如何存取? **索引全部在内存中**,不写入磁盘。 ```java // 核心数据结构 private final Map indexes = new ConcurrentHashMap<>(); ``` - 每个 `notesDir` 对应一个 `PerDirIndex` 对象 - `PerDirIndex` 内部包含:文档块列表、词频表、文档频率表、文档长度数组、平均文档长度 - **服务重启后索引丢失**,需要重新调用 `initNotes` 或由首次搜索触发懒索引 - 不存在数据库、不存在文件缓存、不存在序列化 为什么不做磁盘持久化: - 索引构建极快(一万个文档块 ~1-2 秒),重启重建成本低 - 免去了缓存一致性问题(笔记文件变更后无需同步持久化索引) - 保持零中间件、部署即用的特性 --- #### 完整流程 ##### 索引构建流程 ``` 扫描 .md 文件 │ ▼ 按 Markdown 标题切块(每个 # 一级标题起一个新块) │ ▼ 块大小超过 800 字符时,在空行处拆分 │ ▼ 分词(中文按单字、英文按空格、保留 -_ 符号) │ ▼ 统计每个块的: · term_frequency(词在该块中出现的次数) · doc_frequency(词在多少个块中出现过) · doc_length(块的分词总数) │ ▼ 计算 avgDocLength = 所有块长度的平均值 │ ▼ 存入 ConcurrentHashMap(索引就绪) ``` ##### 搜索流程 ``` 输入查询词 "曲美合同到期时间" │ ▼ 分词 → ["曲", "美", "合", "同", "到", "期", "时", "间"] │ ▼ 对每个文档块计算 BM25 分数 │ ▼ 过滤 domain(如果指定,只保留 filePath 包含 domain 的块) │ ▼ 按 BM25 分数降序排列 │ ▼ 取 topK 条结果返回 ``` --- #### BM25 算法详解 BM25 对每个查询词在文档块中计算一个分数,所有查询词的分数累加得到该块的最终分数: ``` score(D, Q) = Σ[idf(q) * (tf(q,D) * (k1 + 1)) / (tf(q,D) + k1 * (1 - b + b * |D| / avgdl))] 其中: tf(q,D) = 词 q 在文档块 D 中的出现次数 |D| = 文档块 D 的长度(分词数) avgdl = 所有文档块的平均长度 k1 = 1.2(控制词频饱和速度,防止高频词主导) b = 0.75(控制长度归一化强度,0=不归一化,1=完全归一化) idf(q) = ln(1 + (N - df(q) + 0.5) / (df(q) + 0.5)) N = 文档块总数 df(q) = 包含词 q 的文档块数量 ``` 关键特性: - **词频饱和**:`tf / (tf + k1)` 曲线随着 tf 增大趋近于 1,防止一个词重复出现就无限提高分数 - **长度归一化**:长文档块不天然占优,因为分母中 `|D| / avgdl` 会惩罚过长的块 - **IDF 权重**:罕见的词贡献更大,常见的词(如"的"、"了")贡献极小 --- #### 中文分词策略:按单字索引 不引入任何中文分词器(jieba/ HanLP等),直接将中文文本按单字拆分。 查询"曲美合同" → 索引词为 `["曲", "美", "合", "同"]` 为什么按单字就够了: - 中文词汇多为 2-4 字的组合词,按单字索引后,BM25 的**词频统计天然捕捉到字与字的共现规律**。"曲美"作为品牌名,在一个文档块中"曲"和"美"同时高频出现,BM25 会给出高分 - 避免了中文分词的**词库维护**(新品牌名、技术术语需要不断更新词库)和**边界错误**("曲美"可能被拆成"曲/美丽"?) - 避免了额外依赖,保持零中间件 代价是**假阳性略高**("曲"和"美"分别出现在不同上下文中会导致误匹配),但 BM25 的统计特性会对共现频率高的组合大幅加分,实践中效果足够。 --- #### 索引数据结构:PerDirIndex ``` PerDirIndex ├── chunks: List ← 所有文档块 ├── termFreqs: List> ← 每个块的词 → 词频 ├── docFreqs: Map ← 每个词 → 出现在多少个块中 ├── docLengths: int[] ← 每个块的词数 ├── avgDocLength: double ← 平均块长度 └── totalDocs: int ← 块总数 ``` 内存占用估算:一万个文档块(约 10MB 纯文本),索引占用 ~3-5MB。 --- ## 整体架构 ### 在个人 AI 助理中的位置 ``` 用户 ←→ 飞书 / 终端 │ app.py(可选 UI 壳) │ opencode / Claude Code 等任意 AI(大脑,可互换) │ ┌────┴────┐ ← MCP SSE 协议 │ │ 搜笔记 ← Note MCP Server(工具层) 精确搜索 │ │ └────┬────┘ │ .md 文件 ← 数据层(笔记库) ``` ### 本项目的内部架构 ``` NoteMcpApplication(Spring Boot 入口) │ ├── McpConfig ← 读取 NOTES_DIR ├── McpBeanConfig ← 装配 Bm25Retriever │ └── NoteTools(@Service) ├── @Tool searchNotes → 调 Bm25Retriever(懒索引) └── @Tool updateNotes → 调 Bm25Retriever(重建索引) │ Spring AI(自动处理,无需写代码) ├── SSE 端点注册 ├── JSON-RPC 协议解析 ├── Session 管理 └── 工具 schema 生成 ``` ### 设计原则 | 原则 | 说明 | |------|------| | **专注业务** | Spring AI 处理协议层,你只关心搜索的业务逻辑 | | **大脑可互换** | MCP 是标准协议,opencode、Claude Code、Cursor 都支持 | | **零中间件** | 无数据库、无向量库、无搜索服务,一个 jar 包搞定 | | **数据主权** | 笔记存在本地 .md,不绑定任何工具或平台 | | **业务与代码分离** | 笔记目录作为工具参数传入,AI 决定从哪搜,同一 jar 包可服务任意笔记库 | | **壳可选** | app.py 只是飞书遥控器,终端直接 `opencode chat` 就是完整助理 | --- ## 快速上手 ### 前提 - MCP Server 已部署(Docker 或本地 java -jar,见"构建与部署") - opencode / Claude Code 已配置 MCP SSE 连接(见"配置到 AI") ### 使用流程 ```bash # 1. 进入笔记目录 cd ~/Documents/richie_learning_notes # 2. 启动 opencode opencode chat ``` ### 3. 在对话中发送指令 | 你说 | AI 调用 | 效果 | |------|---------|------| | `搜索曲美合同` | `searchNotes(query=..., notesDir=...)` | 全文搜索并返回结果 | | `笔记更新了,重新索引` | `updateNotes(notesDir=...)` | 重建索引 | > `notesDir` 由 AI 从对话上下文中获取路径(如当前工作目录),无需手动输入。 --- ## 两个 MCP 工具 ### 1. searchNotes — 全文搜索 在笔记库中全文搜索相关内容。 | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | query | string | 是 | 搜索关键词 | | domain | string | 否 | 限定搜索领域,如:企业/客户管理、产品、工作/日报、Java、Python、AI | | topK | number | 否 | 返回结果数量,默认 5 | | notesDir | string | 是 | 笔记库根目录路径 | **搜索逻辑**: - **按需懒索引**:首次调用时构建 BM25 索引,之后缓存重用;支持多目录同时缓存 - 按 Markdown 标题切块,中文按单字索引,英文按空格分词 - BM25 算法打分排序 - 支持分域检索:查日程只搜日报目录,查客户只搜客户管理目录 ### 2. updateNotes — 索引更新 当笔记文件发生变更后重建索引。 | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | notesDir | string | 是 | 笔记库根目录路径 | > `searchNotes` 内置懒索引机制,首次搜索时自动建立索引并缓存到内存。**笔记文件变更后**需要手动调 `updateNotes` 刷新缓存。索引存储在内存中(`ConcurrentHashMap`),服务重启后自动由首次搜索重建。 --- ## 数据流示例 ### 完整工作流 ``` 1. 用户进入笔记目录,打开 opencode chat 2. 用户: "曲美合同什么时候到期" → AI 调 searchNotes(query="曲美合同", notesDir=当前工作目录) → 首次搜索自动构建 BM25 索引 → 缓存到内存 → 返回结果 3. 用户: "笔记更新了,重新索引" → AI 调 updateNotes(notesDir=当前工作目录) → 旧索引清除,重建新索引 ``` ### 查询场景 ``` 用户: "曲美合同什么时候到期" │ ├─→ opencode 理解意图 → MCP 调 search_notes(query="曲美合同", domain="企业/客户管理", │ │ notesDir="/Users/qihang/Documents/richie_learning_notes") │ └─→ Spring AI 接收请求 → NoteTools.searchNotes() → Bm25Retriever 懒索引 → 返回文档块 │ └─→ opencode 组织回答 → "曲美合同于2026年8月31日到期..." ``` --- ## 构建与部署 ### 环境要求 - JDK 17+ - Maven 3.6+ - Docker(容器部署需要) ### 本地构建 ```bash cd note-mcp mvn clean package -DskipTests # 输出: target/note-mcp-1.0.0.jar ``` ### Docker 构建 ```bash docker build -t note-mcp:latest . ``` ### 运行 ```bash # 本地开发 java -jar target/note-mcp-1.0.0.jar # Docker docker run -d -p 8080:8080 note-mcp:latest ``` > `notesDir` 由 AI 在调用时传入,同一服务器可服务多个不同笔记库。`NOTES_DIR` 仅在启动时预建索引加速首次搜索。 --- ## 配置到 AI **opencode / Claude Code / Cursor 等任意支持 MCP SSE 的 AI**: ```json { "mcpServers": { "note-mcp": { "type": "sse", "url": "http://宿主机IP:8080/sse" } } } ``` > 宿主机IP:同机用 `localhost`,跨机器填实际局域网 IP。 --- ## 验证 ```bash # 1. 启动服务 NOTES_DIR=/path/to/notes java -jar target/note-mcp-1.0.0.jar & # 2. 建立 SSE 连接获取 sessionId curl -sN http://localhost:8080/sse > /tmp/sse.txt & sleep 2 # 3. 提取 sessionId SESSION_ID=$(grep -oP 'sessionId=\K[^\\/]+' /tmp/sse.txt | head -1) # 4. 发送 tools/list 请求测试 curl -X POST "http://localhost:8080/messages?sessionId=$SESSION_ID" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` --- ## 项目结构 ``` note-mcp/ ├── pom.xml Spring Boot 3.3 + Spring AI 1.0 ├── Dockerfile 默认宿端口 8080 ├── opencode.json.example MCP SSE 配置模板 ├── README.md └── src/main/ ├── resources/ │ └── application.properties 配置(环境变量覆盖) └── java/com/qihang/mcp/ ├── NoteMcpApplication.java @SpringBootApplication(3行) ├── config/ │ ├── McpConfig.java @ConfigurationProperties │ └── McpBeanConfig.java Bean 装配 ├── tools/ │ └── NoteTools.java ★ 四个 @Tool 方法(核心) ├── search/ │ ├── Bm25Retriever.java BM25 引擎(懒索引 + 按目录缓存) │ └── Chunk.java 文档块模型 ``` --- ## 依赖 | 依赖 | 用途 | |------|------| | `spring-boot-starter-web` 3.3.1 | Web 容器 + REST 支持 | | `spring-ai-starter-mcp-server-webmvc` 1.0.7 | MCP 协议自动处理(SSE + JSON-RPC + session) | --- ## 与其他组件的关系 | 组件 | 关系 | |------|------| | **opencode / Claude Code** | MCP 客户端,通过 SSE 调本服务的三个工具 | | **app.py** | 无直接关系,它调 opencode,opencode 调本服务 | | **.md 笔记库** | search_notes 读取并懒建立索引 | --- > 设计版本: 1.0.0 > 最后更新: 2026-05-29