ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MCP+RAG实战:从零搭建内部文档问答机器人

MCP+RAG实战:从零搭建内部文档问答机器人

最近在搭一个内部文档问答机器人,需求本身没什么新鲜的:把团队散落在几十份文档里的知识交给大语言模型,用户直接问,它直接答。但真正上手才发现,单纯把文档丢给大模型的方案根本扛不住真实问题——答得漂亮但全是编的,检索出来一大堆却跟问题对不上。折腾了两周之后,我把检索增强生成(RAG)和模型上下文协议(MCP)串在一起,总算跑通了一条从零到一、可复制的完整链路。

这篇实战记录适合两类人:一是大概知道RAG是什么、但还没动手搭过完整链路的人,二是想搞清楚MCP到底解决了什么问题、值不值得在自己的项目里引入的人。我会把整体设计、环境准备、核心代码、参数调优和踩坑实录都摊开来讲,尽量做到你拿着就能照着搭一遍。先说明一点,我这边用的是本地部署的开源模型做生成、本地向量库做检索,整套链路不依赖任何外部服务,数据不出内网。

1. 内容整体设计与思路拆解:为什么要把RAG做成MCP工具

1.1 RAG最朴素的形态:先看它怎么运转

不绕弯子,RAG这个词听起来高大上,本质就是三件事:把文档切成小块、把小块变成向量、用户提问时先把相关的块捞出来再喂给大模型。

最朴素的实现长这样:读取文档 -> 按固定长度切块 -> 调用embedding模型生成向量 -> 存入向量数据库。用户提问时,把问题也做一次向量化,在库里做相似度检索,取出得分最高的几段文本,拼成一段prompt,连同问题一起交给大模型生成答案。

这套流程只要写过几十行代码的人基本都能搭出来。我当时的第一版就是这个,跑起来也很快,检索、生成都能工作,demo效果看着很不错。但用了两周就发现问题越来越多。

1.2 它的痛点:检索和生成耦合得太死

最让难受的是三个问题。

第一,检索逻辑被写死在生成代码里。用户问“我们的部署流程是什么”,代码就得固定先查向量库,再拼prompt,再调模型。换一套文档、换一种检索策略、加一个外部数据源,都要动主流程代码。这在一个小demo里不算什么,但在真实项目里就是灾难——业务同学说“能不能顺便查一下工单系统”,你发现自己要改的不是一个函数,而是整个对话流程。

第二,模型没有“选择要不要查”的自由。哪怕是用户就问一句“你好”,系统也会老老实实去向量库检索一遍,然后把一堆无关文本塞进上下文。浪费token不说,有时候还会把答案带偏,模型明明知道答案,却因为检索结果里有一段矛盾的内容而开始胡说。

第三,多轮对话的上下文跟前一轮检索结果纠缠在一起。用户先问“我们有哪些部署环境”,再问“那线上的配置在哪份文档里”,理想的情况是模型能基于上一轮的信息,决定这次要不要再去查一次线上配置文档。但在硬编码流程里,每一轮都重新检索、重新拼上下文,模型根本没有合理的上下文结构可用。

1.3 MCP才是解耦的关键

MCP全称是模型上下文协议(Model Context Protocol),它的核心价值在于把“大模型需要的外部能力”统一成一个个标准工具。放到RAG这个场景里,理解起来非常直白:我不再把检索逻辑写死在生成代码里,而是把它封装成一个MCP工具,叫search_knowledge。大模型自己在回答问题的过程中,判断“这个问题需要查知识库”,然后通过MCP协议去调用这个工具,拿到结果之后再继续回答。

整个流程变成这样:用户提问 -> 大模型分析是否需要用检索工具 -> 如果是,通过MCP调用search_knowledge-> 工具执行向量检索并返回结果 -> 大模型把结果和对话历史一起组织成最终答案 -> 返回给用户。

说白了,普通RAG像雇了一个只能背课文回答问题的助手;MCP+RAG是给这个助手配了一个随叫随到的资料管理员,助手觉得需要资料的时候自己去喊,喊完回来自己整理答案。这个转变看起来只是把“检索”从主流程里拆了出去,带来的影响其实非常大。

1.4 可插拔、可观察、可扩展是三个直接收益

把检索做成MCP工具后,第一个收益是可插拔。今天我的知识库放在本地向量库,明天想换成别的检索服务,只要保证MCP Server的对外接口不变,生成侧一行代码都不用动。MCP Server和LLM进程可以通过标准协议通信,跟具体实现完全解耦。

第二个收益是可观察。硬编码流程里,检索发生没发生、检索到了什么,全靠日志里瞎猜。MCP模式下,每次工具调用都是一次完整的请求-响应,入参、出参都走标准协议,我可以清楚地看到模型在什么时候、因为什么、检索到了什么内容。这对于排查“答案为什么不对”太关键了。

第三个收益是可扩展。知识库只是RAG的一种工具,我还可以加search_org_wiki、query_ticket、lookup_config这些工具,模型会在需要时自己决定用哪个。这就像给LLM配了一套“插件系统”,而不是为了每个数据源写一套对话逻辑。

对比项传统硬编码RAGMCP+RAG
检索触发方式用户每次提问都先检索模型判断是否需要检索
代码耦合度检索和生成混在主流程检索以工具形式解耦
多轮对话每一轮强制重检模型按需调用、可复用上下文
增加数据源需要改主流程代码新增MCP Server即可
排查问题靠日志和打断点工具调用链路清晰可见

2. 环境准备与工具选型:搭建前的核对清单

2.1 基础环境与依赖

我这次用的是Python 3.10以上的版本,虚拟环境是必须的,避免依赖打架建议直接上uv或者venv。

python -m venv .venv source .venv/bin/activate pip install --upgrade pip

然后安装核心依赖,这里给一个我实际用下来比较稳的组合:

pip install "mcp[cli]" chromadb sentence-transformers requests

简单解释一下这几个库的分工:

  • mcp[cli]:MCP协议实现,用来写MCP Server,也用来在本地调试工具。
  • chromadb:向量数据库,负责存储文档向量和检索。
  • sentence-transformers:加载本地embedding模型,把文本变成向量。
  • requests:调用本地模型服务的HTTP接口,做最终生成。

生成侧我直接用兼容OpenAI接口格式的本地模型服务,这样在代码里只需要一个requests.post就能完成调用,不引入额外的大包。本地模型的部署方式这里不展开,大家用自己熟悉的方式启动一个兼容接口的服务即可。

2.2 怎么选向量库和embedding模型

选向量库我只有一个建议:第一版不要纠结。Chroma这种文件型向量库足够好用,安装即用、默认支持持久化、自带向量索引。很多人一上来就上分布式向量数据库,实际上几百份文档的数据量根本用不上,反而被运维和部署拖住。先把闭环跑通,后面数据量上来再迁移不迟。

embedding模型的选择会影响整套系统的效果,但第一版也够用就行。我用的是一个本地开源的中文embedding模型,维度在768左右。关键不是追求刷榜,而是稳定、可离线、中文效果好。确定embedding模型后,所有历史向量都要基于同一个模型生成,因为向量只有同一空间里比较才有意义。中途换模型意味着全量重新构建索引,这是个常用但容易忘的坑。

2.3 项目目录长这样

搭建前把目录结构定好,后面不容易乱:

rag-mcp-demo/ ├── .venv/ ├── data/ │ ├── docs/ # 原始文档 │ └── vector_db/ # 向量库持久化目录 ├── server/ │ ├── mcp_server.py # MCP Server │ ├── knowledge.py # 文档处理与检索 │ └── embedder.py # embedding封装 ├── app/ │ └── chat.py # 外层LLM对话脚本 └── scripts/ ├── build_index.py # 构建向量索引 └── test_mcp.py # MCP工具测试

环境准备阶段最容易出问题的其实是版本兼容。遇到过几次MCP SDK升级后API变化,网上搜出来的写法跟当前版本对不上。建议装完依赖后先跑一下mcp命令或者python -c "import mcp; print(mcp.__version__)",确认版本再开始写代码。另外sentence-transformers会默认联网下载模型,第一次构建索引前要确保模型已经下载到本地缓存,否则离线环境会直接卡住。

3. 核心实现:从零写出可运行的MCP+RAG链路

3.1 先写文档处理与向量库部分

整个链路的第一步是构建知识库索引。我不会一上来就引一大堆框架,而是自己写一个干净的文档处理和入库脚本,逻辑完全透明,方便后面调参。

先写embedding封装:

# server/embedder.py from sentence_transformers import SentenceTransformer class LocalEmbedder: def __init__(self, model_name: str = "path/to/local/embedding/model"): self.model = SentenceTransformer(model_name) def embed_texts(self, texts: list[str]) -> list[list[float]]: embeddings = self.model.encode(texts, normalize_embeddings=True) return embeddings.tolist() def embed_query(self, query: str) -> list[float]: embedding = self.model.encode(query, normalize_embeddings=True) return embedding.tolist()

向量做完归一化后,相似度计算可以直接用内积,等价于余弦相似度,这一点后面检索阈值判断会用到。

然后是文档切分。很多人图省事直接按固定字符长度切块,结果一句话被劈成两半、语义断裂。我采用的策略是“优先按语义边界切,实在没有边界再按长度兜底”:

# server/knowledge.py import re def split_document(text: str, chunk_size: int = 500, chunk_overlap: int = 50) -> list[str]: # 先把文本按换行和标题切出段落 sections = re.split(r"\n{2,}|(?<=。) ", text) chunks = [] current = "" for section in sections: section = section.strip() if not section: continue # 如果当前块加上下一段还小于chunk_size,就直接拼进去 if len(current) + len(section) <= chunk_size: current = (current + " " + section).strip() else: if current: chunks.append(current) # 如果单段本身就超过chunk_size,强制按固定长度切 if len(section) > chunk_size: part = section while len(part) > chunk_size: chunks.append(part[:chunk_size]) part = part[chunk_size - chunk_overlap:] current = part else: current = section if current: chunks.append(current) return chunks

这个切分思路很简单:能按段落拼就按段落拼,拼不下就重新开一块;单段太长就只能硬切,但通过overlap保留语义衔接。chunk_size=500、chunk_overlap=50是相对稳妥的起点,具体怎么定我在后面单独讲。实际跑下来,用这种切分方式建出来的索引,检索结果比纯固定长度切块要连贯不少。

接着是构建索引入库:

# scripts/build_index.py import os from pathlib import Path import chromadb from chromadb.config import Settings from server.embedder import LocalEmbedder from server.knowledge import split_document DOCS_DIR = Path("data/docs") DB_DIR = Path("data/vector_db") CHUNK_SIZE = 500 CHUNK_OVERLAP = 50 def main(): client = chromadb.PersistentClient( path=str(DB_DIR), settings=Settings(anonymized_telemetry=False), ) collection = client.get_or_create_collection( name="knowledge_base", metadata={"embedding_model": "local"}, ) embedder = LocalEmbedder() # 遍历文档目录,读取并切分 for doc_path in DOCS_DIR.glob("*.md"): text = doc_path.read_text(encoding="utf-8") chunks = split_document(text, CHUNK_SIZE, CHUNK_OVERLAP) chunk_ids = [] chunk_texts = [] for idx, chunk in enumerate(chunks): chunk_ids.append(f"{doc_path.stem}#{idx}") chunk_texts.append(chunk) # 批量embedding embeddings = embedder.embed_texts(chunk_texts) collection.add( ids=chunk_ids, documents=chunk_texts, embeddings=embeddings, metadatas=[ {"source": doc_path.name, "chunk_index": idx} for idx in range(len(chunks)) ], ) print(f"索引 {doc_path.name} 完成,共 {len(chunks)} 个分块") if __name__ == "__main__": main()

注意Chroma在add的时候如果传了embeddings,就不会再对documents做内部向量化,完全用我们自己的embedding结果,这样保证检索query和文档用的是同一个embedding函数。这个细节我就不重复强调两次了,反正我在第一次跑的时候就是因为没注意,结果两边向量来自不同模型,召回率惨不忍睹。

3.2 把retriever暴露成MCP工具

索引构建完成之后,核心工作就是把检索逻辑封装成MCP工具。

# server/mcp_server.py from mcp.server.fastmcp import FastMCP import chromadb from chromadb.config import Settings from server.embedder import LocalEmbedder DB_DIR = "data/vector_db" COLLECTION_NAME = "knowledge_base" DEFAULT_TOP_K = 5 mcp = FastMCP("rag-knowledge-server") client = chromadb.PersistentClient( path=DB_DIR, settings=Settings(anonymized_telemetry=False), ) collection = client.get_or_create_collection(COLLECTION_NAME) embedder = LocalEmbedder() @mcp.tool() def search_knowledge(query: str, top_k: int = DEFAULT_TOP_K) -> list[dict]: """在团队知识库中检索与问题最相关的文档片段。 当用户询问部署流程、项目背景、配置说明、常见问题等需要查阅内部文档时, 调用该工具获取参考依据。 Args: query: 用户问题的核心检索词,尽量提取关键信息。 top_k: 返回结果数量,默认5个。 Returns: 包含文档片段内容和来源的列表。 """ query_vector = embedder.embed_query(query) results = collection.query( query_embeddings=[query_vector], n_results=top_k, include=["documents", "metadatas", "distances"], ) output = [] documents = results["documents"][0] metadatas = results["metadatas"][0] distances = results["distances"][0] for doc, meta, dist in zip(documents, metadatas, distances): # 因为我们做了归一化,distances其实是余弦距离,越大越不相似 # 这里只保留距离小于0.45(即相似度大于0.55)的结果,避免召回一堆垃圾 if dist and dist > 0.45: continue output.append({ "content": doc, "source": meta["source"], "similarity": round(1 - dist, 4), }) return output if __name__ == "__main__": mcp.run(transport="stdio")

这一步真正做到了“把检索变成模型的一个工具”。MCP Server以stdio方式运行,外层LLM进程通过标准输入输出和它通信,不需要起HTTP服务,部署和调试都很轻。

工具描述写得详细一点,这非常重要。大模型判断“要不要调用某个工具”,主要就看工具的description。如果你只写“搜索知识库”,模型就会犹豫;写清楚“当用户询问部署流程、项目背景、配置说明等需要查阅内部文档时,调用该工具”,模型就能在正确的时候主动触发检索。

3.3 用LLM客户端把“检索—生成”串起来

MCP Server写好了,接下来是外层聊天入口。核心逻辑是:先拿到MCP暴露的工具列表,然后进入对话循环,让大模型在每一轮自己决定是否调用工具。

# app/chat.py import json import requests from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client LLM_URL = "http://127.0.0.1:8080/v1/chat/completions" MCP_SERVER_CMD = ["python", "server/mcp_server.py"] def call_llm(messages, tools=None): payload = { "model": "local-model", "messages": messages, "tools": tools or [], "temperature": 0.2, } resp = requests.post(LLM_URL, json=payload, timeout=180) resp.raise_for_status() return resp.json()["choices"][0]["message"] async def main(): server_params = StdioServerParameters(command=MCP_SERVER_CMD[0], args=MCP_SERVER_CMD[1:]) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools_result = await session.list_tools() tools_desc = [ { "type": "function", "function": { "name": tool.name, "description": tool.description, "parameters": tool.inputSchema, }, } for tool in tools_result.tools ] messages = [] while True: user_input = input("用户: ") if user_input.strip().lower() in ("quit", "exit"): break messages.append({"role": "user", "content": user_input}) # 第一轮:让模型决定是否调工具 response = call_llm(messages, tools=tools_desc) # 如果模型想调用工具 if response.get("tool_calls"): messages.append(response) for call in response["tool_calls"]: tool_name = call["function"]["name"] args = json.loads(call["function"]["arguments"]) # 把MCP工具调用变成shell一样的结果返回给模型 result = await session.call_tool(tool_name, arguments=args) # 这里简化处理:把MCP返回的文本内容提取出来 content = result.content[0].text if result.content else "无结果" messages.append({ "role": "tool", "tool_call_id": call["id"], "content": content, }) # 第二轮:带着工具结果生成最终答案 final = call_llm(messages, tools=tools_desc) assistant_text = final.get("content", "") else: assistant_text = response.get("content", "") print(f"\n助手: {assistant_text}\n") messages.append({"role": "assistant", "content": assistant_text}) if __name__ == "__main__": import asyncio asyncio.run(main())

这里有一个容易弄错的点:第一轮模型返回tool_calls之后,你要把模型这条消息完整追加到messages,再追加role: "tool"的工具结果,最后再让它生成一次。不要把tool_calls的消息丢掉,否则模型无法把工具调用和结果关联起来。

如果你的本地模型服务不完全支持function calling,也有退路:把工具调用改成“对话内指令”。也就是不传tools参数,而是在system prompt里写清楚“如果你觉得需要查知识库,请输出[[search_knowledge(查询词)]]这样的格式”,然后外层的代码去解析这段话。这个做法兼容性极高,但对模型的指令跟随能力有要求。我建议优先用原生的function calling,确实不行再走解析方案。

3.4 关键参数怎么定:chunk、top_k、阈值

这一小节我把几个参数决定性的思路一次性讲完,后面调优时直接照着做就行。

chunk_size的档位直接影响检索效果。定得太小(比如100),语义单元太碎,检索可能召回一些上下文残缺的片段;定得太大(比如2000),单个块里揉杂多个主题,向量语义被稀释,而且拼进prompt时token浪费非常严重。我常用的起步值是500到800,然后拿几个有代表性的问题做测试,看召回的片段是不是正好落在“一个完整小节”上,再微调。

chunk_overlap不用太大,50到100就够。它的意义只是保证跨切边界的上下文不丢,多一点浪费token,少一点可能切断句子。

top_k也不是越大越好。top_k=3到8是比较常见的区间。我默认5,但在代码里留了参数,调工具时由模型根据问题复杂程度来决定。比如用户问“部署流程分几步”,5条已经足够;问“列出所有环境的配置参数”,可能需要8条甚至更多。

相似度阈值是我踩过不少坑的环节。用了归一化的向量之后,1 - distance就是余弦相似度。我的经验:不要设置一个太高(比如0.8)的硬门槛,因为embedding模型对某些专业领域的表述天生得分就偏低。更合理的做法是设一个低保底阈值(0.5到0.6),再结合top_k双条件过滤。也就是“最多取k条,且只保留相似度超过阈值的”,万一用户问的是库里完全没有的内容,宁可让模型说不知道,也不要硬塞无关内容。

4. 常见问题与排查技巧实录

4.1 五个翻车现场

这套系统我第一次完整跑通花了不少时间,中间踩过的坑基本可以归类成五个,每一个都有很强的代表性。

第一个坑:工具注册成功了,但模型就是不调用。现象是用户问“部署流程是什么”,模型直接凭训练时的记忆开始答,完全不理检索工具。排查后发现,原因是工具描述太笼统,只写了“检索知识库”。后来把描述改成“当用户询问部署流程、项目背景、配置说明、版本变更等内部文档内容时,调用该工具获取准确信息”,模型立刻就学乖了。这个问题的本质是:大模型靠描述做工具选择的意图识别,描述写得越具体,触发越准确。

第二个坑:检索结果全被阈值过滤掉了。代码里加了0.6的阈值,结果查询响应为空。逐个打印相似度才发现,embedding模型对技术文档里的缩写和英文混排文本评分普遍只有0.5到0.7,阈值设太高反而导致明明有相关内容却拿不到。把阈值降到0.5,并在工具返回里附带相似度,我就能在测试时人工判断边界是否合理。

第三个坑:MCP连接一直卡住不返回。启动聊天程序后,第一次调用工具就hang住。最后查出来是LocalEmbedder在MCP Server进程里被初始化,而该环境加载embedding模型需要较长时间,MCP默认没有做初始化超时容错。解决办法是在启动MCP Server时把embedding模型预加载一遍,并打印一条“embedding ready”的日志,确认进程真正就绪之后再进入服务循环。部署时也要注意,MCP进程的启动耗时不像普通HTTP接口那么直观,外层client要留足初始化时间。

第四个坑:答案依然来自旧文档。文档更新过,但向量库里的旧内容还在。因为构建索引时只是add,没有考虑覆盖删除。后来我在build_index.py里加了“全量重建”逻辑:每次更新索引前删除整个collection再重建。对于小规模知识库这是最简单的正确方案,避免删除逻辑出错导致留了一堆过期内容。

第五个坑:多轮对话里我自己的问题被反复检索。用户问完“线上环境怎么部署”之后,接着说“那如果部署失败呢”,模型又把“那如果部署失败呢”当成新的检索query,检索出来一堆重复片段。解决办法是对query做简单改写:把最近两轮对话交给一个轻量prompt,让模型把“当前问题”扩写成“结合上下文的独立检索词”,然后再去调用工具。这个优化对多轮问答非常有效。

4.2 排查速查表

现象可能原因处理办法
模型不调用检索工具工具描述太含糊 / 模型不支持function calling重写description;或者改用对话内指令解析方案
检索结果为空或答非所问阈值过高 / embedding模型不一致打印相似度检查分布;确认向量由同一模型生成
MCP调用卡死启动慢 / transport异常预加载模型;先单独启动MCP Server验证连通性
回答用了过期内容索引未更新 / 旧的chunk还在库里全量重建索引;确认metadata里的文档版本
多轮检索重复或跑偏没有做query改写用上一轮上下文改写检索词
上下文过大导致模型报错chunk太多 / top_k太大减小top_k;检索结果只保留content和source
相似度整体偏低领域术语密集 / 向量空间不一致降低阈值;重建索引时保证统一embedding模型

这里再补一条非常实用的操作习惯:开发阶段在MCP工具里临时把检索到的每一条结果都带上similarity字段,客户端打印出来。这样你一眼就能判断当前问题的相似度分布在什么水平,阈值、top_k该往哪个方向调就一目了然。等调参完毕,再把这个字段去掉或者只在日志里输出,而不是直接暴露给用户。

5. 进阶优化与经验沉淀:让系统真正可用

5.1 召回质量优化:从“能用”到“好用”

闭环跑通之后,你很快会发现“能答”和“答得好”之间还差很远。我实际体验下来,效果提升最明显的优化动作有三个。

第一是query改写。上面提到的多轮改写只是最基础的一层,更进一步的做法是让模型在调用工具前,把用户的自然语言问题转换成更适合检索的“关键词组合”。比如用户问“如果线上服务挂了该怎么处理?”,改写后的检索词可以是“线上服务故障 处理流程 应急预案”。这个优化面向向量检索尤其有效,因为embedding模型对短句中的关键名词更敏感。

第二是元数据过滤。在索引阶段给每个chunk打上source、doc_type、version、department这些标签,检索时先按条件过滤再算相似度。比如用户明确说“看运维手册”,就可以只检索doc_type=ops的chunk。这一步能把相似度计算的范围缩小,同时显著提升准确性,工程成本很低,收益非常直接。

第三是混合检索。向量检索擅长语义匹配,但对精确词条反而可能漏掉,比如用户搜一个特定的错误码,向量检索可能因为它出现在一个很长的chunk里而排名靠后。可以加一层关键词匹配(比如BM25),把两路结果合并去重后统一排序。这个优化我是在第二版才加的,效果立竿见影。如果你的知识库以内部技术文档为主,混合检索几乎是必选项。

5.2 工程化细节:日志、缓存与并发

系统要交付给别人用,不能只在命令行跑demo。下面这几个工程点是我实际体验下来最值得加固的地方。

日志方面,MCP Server的每次工具调用我都打印一行结构化日志,包含时间、query、top_k、返回条数和平均相似度。配上简单的统计脚本,就能知道哪些问题反复来检索但返回为空,这些就是知识库里缺失的内容,可以定向补充文档。这个思路其实就是一个轻量版的数据驱动知识库迭代机制。

缓存方面,可以用一个很简单的内存缓存:把“规范化后的query + 时间窗口”作为key,命中相同的检索需求时直接返回上次结果。用户经常在短时间内问多个高度相似的问题,比如“部署分几步”和“部署流程”,虽然完全相同不至于,但缓存仍能省掉不少embedding计算。实现时注意给缓存加一个失效时间,文档更新后旧缓存不能留太久。我的做法是:索引重建成功后清空一次缓存。

并发方面,要注意MCP Server内部如果有全局的embedding模型,默认encode方法在并发场景下会互相阻塞。第一版我没考虑这个问题,结果多人同时使用的时候,检索延迟明显上升。后来在embedding函数里加了推理批处理,或者直接用concurrent.futures控制并发度,实测下来吞吐好很多。如果你的使用人数很少,这个问题可以先不管,但迟早要面对。

5.3 安全与隐私:这个不能省

内部文档往往比想象的敏感得多。我搭这套系统的第一天,就把“数据不出内网”列为硬性要求,所以在选型上坚持全部组件本地化。如果你用的是外部模型API,务必确认文档内容不会作为训练语料,同时做好脱敏,比如提前把文档中的手机号、内部系统地址替换成占位符。

即使全部本地化,权限控制也不能跳过。MCP工具本身只知道“用户问了个问题”,并不清楚提问者的权限范围。我的做法是:在检索前,为不同用户组做元数据过滤,按部门标签限制可检索的文档集合。这一步虽然会让系统复杂一点,但放到真实业务场景里是必须的。demo可以不管权限,上线必须管。

最后再分享一点个人经验

从零到一搭完MCP+RAG,我最深的体会是:做这套系统,最难的不是模型、不是向量库,而是把“工具调用”这件事想清楚。传统RAG把检索写死在代码里,其实是在替模型做决定;MCP把工具交给模型,才真正让模型变成了一个会“查资料再回答”的人。

如果你也准备动手做,我建议你先不要追求高级特性,就按我前面写的标准三步走:构建索引、封装MCP工具、实现对话循环。什么时候一个最小闭环能稳定跑通了,再回去做query改写、混合检索、缓存优化。先跑通链路,再迭代效果,这是我在这个项目里最重要的一条经验。

最后再送一个小技巧:MCP Server别着急改成HTTP模式。stdio模式调试起来最快,环境变量、初始化日志都直接可见,等你完全确定协议和交互稳定了,再考虑换传输方式。很多问题其实在stdio模式下几分钟就能定位,改成网络传输之后反而多了一层排查负担。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进