把文档检索接入Claude Code的MCP实践
作者将已有RAG管线的搜索能力封装为小型MCP服务器,让Claude Code跨文档检索并获取相关片段。文章介绍服务实现、客户端连接与测试,减少逐个读取长文档带来的上下文开销。
AI 深度解读
Claude Code 能读项目文件,但面对一堆长 PDF,逐个打开既消耗上下文,也容易找错入口。作者把已有 RAG 聊天机器人的检索函数包装成 MCP 工具,让 Claude Code 先搜索文档片段,再组织答案,检索逻辑几乎没改。
这篇最有用的地方,是展示了一个足够小的接入方案:本地建库、两个工具、服务端不调用 LLM。同时,一次实际提问也暴露了关键问题:工具接通了,模型未必知道什么时候该用。
为什么只把检索交给 MCP,回答仍由客户端完成
RAG 是检索增强生成。放到这个项目里,就是先从文档中找到相关段落,再让模型根据这些材料回答。
作者已有 RAG 聊天机器人,因此没有重做检索,而是让聊天机器人和 MCP 服务共同调用 search_vectorstore。MCP(Model Context Protocol)负责向客户端声明工具,Claude Code 决定何时调用。相比自己提供 REST API,这种方式可以复用 MCP 客户端已有的工具接入能力,减少逐个客户端编写适配代码的工作。
这个服务只提供证据,答案由客户端的模型生成。
服务端不调用 LLM,也不需要生成模型的 API key。用户可以在客户端选择回答问题的模型。不过,原文所说的「每次请求没有成本」,应理解为没有额外的服务端 LLM 调用费用;本地计算、部署和客户端模型的成本仍然存在。
作者只提供两个工具:search_documents 搜索片段,list_documents 查看知识库有哪些文件。传输方式选择 Streamable HTTP,启动时使用 mcp.run(transport="streamable-http")。它让服务独立运行,多个客户端可以连接同一个服务;而原文中的 stdio 方案由每个客户端分别启动服务进程。
文档怎么入库,搜索结果为什么要带页码
知识库来自固定文件夹,支持 PDF、TXT 和 Markdown。作者通过 PyPDFDirectoryLoader 按页读取 PDF,通过 DirectoryLoader 和 TextLoader 读取文本,再用 RecursiveCharacterTextSplitter 切成约 500 字符的片段。
这里是字符数,不是 token 数。代码使用 CHUNK_SIZE 和 CHUNK_OVERLAP,但原文没有给出重叠长度。
入库命令是 uv run python -m mcp_server.ingest path/to/documents。向量由 Hugging Face 在本地运行的 BAAI/bge-base-en-v1.5 生成,存入 Chroma,通过 persist_directory=KNOWLEDGE_BASE_DIR 持久化,并设置 collection_metadata={"hnsw:space": "cosine"}。
作者保留文件名和 PDF 页码,去掉完整路径。这样返回内容既能追溯出处,也不会暴露本机目录。但同名文件如何区分,原文没有处理。
每次入库都会先用 shutil.rmtree 删除旧知识库,再全量重建。好处是文件夹里删除的文件不会残留在索引中,代价是无法增量更新。
共享检索函数负责筛选片段,并把来源整理成模型可直接使用的文本:
def search_vectorstore(vectorstore: VectorStore, query: str) -> str:
"""返回带文件名和页码的相关片段,由智能体与 MCP 服务共用。"""
results = vectorstore.similarity_search_with_relevance_scores(query, k=5)
relevant = [doc for doc, score in results if score >= SIMILARITY_THRESHOLD]
if not relevant:
return NO_DOCUMENTS_FOUND
output = []
for doc in relevant:
source = doc.metadata.get("source", "Unknown")
page = doc.metadata.get("page", None)
ref = f"[{source}" + (f", page {page + 1}]" if page is not None else "]")
output.append(f"{ref}\n{doc.page_content}")
return "Found in documents:\n\n" + "\n\n".join(output)
要看的是 k=5 和阈值过滤:先取最相近的 5 个片段,再丢弃分数低于 0.2 的结果。page + 1 则把加载器从 0 开始的页码转换成人能直接查找的页码。
相似度用于排序和筛选,不能当成答案正确的概率。原文没有提供证据证明 0.2 是普遍适用的阈值。
为什么工具接好了,Claude 还是没有搜索
服务使用官方 MCP Python SDK 的 FastMCP。@mcp.tool() 将 Python 函数注册成工具,类型标注生成输入 schema,docstring 提供模型可见的工具描述。
get_knowledge_base() 使用 @cache,首次调用工具时才加载数据库和 embedding 模型,之后复用。启动更快,但每次重新入库后,需要重启服务才能读取新知识库。
下面两条命令分别启动服务、接入 Claude Code:
uv run python -m mcp_server.server # serves http://127.0.0.1:8001/mcp
claude mcp add --transport http knowledge-base http://127.0.0.1:8001/mcp
注意第二行的 --transport http 和 /mcp 地址,它们要与服务端配置对应。
作者导入了一份 Nordheide Solar Park 报告,询问贷款银行和利率。第一次,Claude 没有调用工具,反而提出搜索网页。因为工具描述只写了搜索公司知识库,没有告诉模型这份报告就在里面。
补上 Search my documents. 后,Claude 才调用 search_documents,取得 5 个带来源的片段,并使用第 2 页的贷款信息回答:贷款来自 Regionalbank Lüneburg,固定利率为 3.2%,期限 18 年,覆盖 €14.5M 总投资的 60%。
工具描述能帮助模型选择工具,但接入成功不保证模型会主动调用。
作者推测,把知识库包含哪些文档写进描述可能有帮助;这还只是改进方向,没有对照实验。
测试也要分层理解。作者用 create_connected_server_and_client_session 创建内存中的真实 MCP 客户端会话,通过 client.call_tool 验证协议调用。server_with 用内存 Chroma 和 DeterministicFakeEmbedding 替换真实知识库,避免下载模型,5 个测试几秒内完成。
它们覆盖两个工具是否注册、搜索是否返回文件名和页码、空库是否返回 NO_DOCUMENTS_FOUND、文件列表是否去重,以及入库是否只读取三类文件。测试中的 page: 2 应输出 [report.pdf, page 3]。
这些测试验证了接口与数据处理,却没有验证真实 embedding 的检索质量,也没有覆盖真实 HTTP 部署或 Claude 的工具选择行为。
我的判断
我认可作者把已有检索函数单独暴露出来的做法。对于已经有 RAG 的团队,MCP 可以成为新的调用入口,不必再复制一套搜索逻辑。返回文件名和页码,也方便程序员核查模型引用的材料。
但「比逐个读文件更好」在这里主要是经验判断。作者没有给出 token 消耗、响应时间或回答准确率的对照数据。文档少、文件短、路径明确时,直接让 Claude Code 读取文件仍然省事;长文档多、问题跨文件时,集中检索才更有吸引力。
检索配置同样需要重新验证。原文没有测试中文文档效果,不能直接把 BAAI/bge-base-en-v1.5 和 0.2 阈值视为国内团队的默认组合。只取 5 个片段,也未必覆盖需要跨章节汇总的问题。
生产边界则很明确:示例没有认证,只监听 127.0.0.1。作者提到 MCP 支持 OAuth,但没有实现;团队部署还需要容器化、从 Confluence 或 SharePoint 定期同步,以及解决入库后重启的问题。HTTP 支持共享连接,并不自动补齐这些能力。
能拿来做什么
- 如果团队已有文档搜索或 RAG 服务,可以先复用检索函数,只暴露搜索和文件列表两个 MCP 工具,减少两套实现产生行为差异的机会。
- 如果日常用 Claude Code 查询长篇技术手册,可以先选一小批资料建库,要求结果附文件名和页码,再用真实问题检查是否能找到正确证据。
- 在 AI 编程任务中,可以明确写出
Search my documents.,并在工具描述中说明资料范围;同时观察模型是否实际调用工具,不能只检查服务是否连接。 - 接入团队共享环境前,应分别验证真实文档检索、MCP 协议调用和客户端工具选择,并补齐认证与知识库更新流程。