如果你最近在折腾 AI Agent、做个人知识库,或者被各种 RAG 实战教程刷屏,那这几个词一定绕不过去:MCP Server、向量数据库、RAG、Chroma。我经常跟朋友说,MCP Server 是 AI 世界里的“万能插座”,向量数据库是大模型的“记忆体”,RAG 是让大模型学会查资料的方法,而 Chroma 则是把“记忆体”落地的一个轻量选择。今天这篇就用实际使用的视角,把这几样东西串起来聊透。
这篇内容适合两类人:一类是刚接触 AI 应用开发,想给自己搭一个带知识库的 Agent 或问答机器人;另一类是已经写了点 RAG 代码,但被 Milvus、Qdrant、Chroma 一堆名字搞到选择困难,想搞清楚到底该用哪个、怎么用才不踩坑。我会尽量不说废话,直接把关键原理、实操细节、坑位清单都摆出来。
1. MCP Server 和向量数据库,怎么走到了一起
1.1 MCP Server 到底解决了什么问题
MCP(Model Context Protocol)是一个让 AI 模型与外部工具、数据源进行标准化交互的协议,MCP Server 则是这个协议的服务端实现。你可以把它理解成一个适配器:AI 应用不再需要为每个数据源单独写一套调用逻辑,只要数据源提供 MCP Server,AI 就能以统一方式去读取、检索、操作。
我在最初看到 MCP 的时候,第一反应是“这不就是 USB-C 接口吗”。以前电脑要接鼠标、显示器、网线,每种线都不一样;现在一个 USB-C 口搞定。MCP Server 干的事情类似,它把向量数据库、文件系统、数据库、API 这些能力封装成标准化的工具,AI Agent 通过 MCP Host(比如 Claude Desktop、各类 Agent 框架)发现并调用这些工具。
结合我们今天的话题,MCP Server 的意义在于:原本你要写一堆 Python 代码,手动完成“连接数据库 → 构造查询 → 处理结果”的流程;现在只要有一个封装好的 MCP Server,AI Agent 自己就能决定“我需要去 vector store 检索相关资料”,然后直接调用对应工具。这个过程对应用开发者来说省掉了大量胶水代码。
1.2 向量数据库在 AI 应用里的位置
大模型本身是不带实时记忆的,它的知识来自训练数据。但现实中我们需要让它回答私有文档、实时资讯、特定业务数据的问题,这时就不能只靠模型“背诵”,而要把外部资料存起来,在回答前先检索出相关片段,再交给模型生成答案。
向量数据库就是用来解决“从海量文本里快速找到语义相关的片段”这个问题的。它不像传统关系数据库那样按关键字精确匹配,而是把文本转换成一组数字构成的向量表示(Embedding),然后通过计算向量之间的距离来判断两段文本是否语义相近。
放到整个架构里,向量数据库承担的是记忆存储与检索的角色。没有它,RAG 就退化成“把文档硬塞进 Prompt”,上下文窗口一满,效果立刻崩坏。有了它,哪怕你有几万份文档,也能在几百毫秒内找到最相关的内容。
1.3 为什么 RAG 场景特别依赖 MCP 这种连接方式
我在早期做 RAG 项目时,最烦的一件事就是“每换一个数据源,就要重新写一遍检索逻辑”。今天接本地文件,明天接在线网页,后天又要接企业内部的 Wiki 系统,每套东西的存储结构都不一样,Agent 和它们对话全靠手写代码,非常痛苦。
MCP 的价值恰恰在这里。一个设计良好的 MCP Server,可以隐藏掉向量数据库的具体实现细节,对外只暴露几个稳定的工具,比如search_documents、add_document、list_collections。AI Agent 不需要知道 Chroma 还是 Milvus,不需要知道 embedding 模型是哪个,它只需要按照 MCP 协议调用工具就行了。
所以,从工作流的角度看,MCP Server 让 RAG 的链路变得更“可对话化”:用户问问题 → Agent 理解意图 → 调用向量数据库检索工具 → 拿到结果 → 组装上下文 → 生成回答。整条链路里,向量数据库是核心底料,MCP Server 是连接底料和大脑的桥梁。
2. RAG 不是神秘黑盒,拆开看就是三步
2.1 索引阶段:让文档变成可检索的向量
RAG 的第一步是建索引,也就是把原始文档切块、向量化、写入向量数据库。这个过程听起来简单,但里面有两个关键参数直接影响最终效果:切块大小和重叠长度。
我习惯把切块理解为“把一本书拆成可以随身携带的便利贴”。如果一块太大,比如整页 A4 纸的内容,那检索出来的结果可能包含太多无关信息,大模型的上下文被塞满,回答容易偏题;如果一块太小,比如只有一句话,那检索结果往往缺少上下文,模型看到的是零零碎碎的片段,也没法给出完整答案。切块时还要设置一定的重叠,避免一句话被拦腰截断后语义丢失。
向量化则是把文本块变成一组高维数字。选择 embedding 模型时要注意,不同模型的向量维度可能不一样,常见的 768 维、1024 维、1536 维都有。维度越高通常表达越细腻,但计算和存储成本也更高。对于中文知识库,我建议优先尝试中英双语效果都不错的 embedding 模型,否则中文语义表达会打折扣。
写入向量数据库时,不仅要存向量,还要把原文、元数据(来源文件、章节、时间等)一起存进去。这样检索到向量后,才能快速取出原始文本给大模型,也方便做过滤。
2.2 检索阶段:语义相似度怎么算
检索阶段是 RAG 的核心,也是很多人忽略原理的地方。向量数据库会把用户的问题也转成一个向量,然后在库里找“距离最近”的若干条向量。这个“距离”通常用余弦相似度、欧氏距离或内积来衡量。
我做项目时最常用的是余弦相似度。它的思想是看两个向量的方向是否一致:方向越一致,说明语义越接近。理解这一点有助于你做参数调优,比如 Chroma 里的where过滤条件、n_results参数,它们会影响检索的精确度和召回率。
很多人以为 RAG 只要把 topK 调到最大就能拿到更多上下文,实际不是这样。topK 太大,会把不够相关的内容也拉进来,干扰模型判断;topK 太小,可能漏掉关键信息。我在实际项目中通常从 topK=4 或 5 起步,再根据回答质量微调,很少一上来就设到 10 以上。
2.3 生成阶段:上下文拼装和使用限制
检索到相关资料后,还不能直接扔给大模型。你需要把它们组织成一个清晰的上下文块,常见做法是给每段文本加上来源标签,让模型知道这段内容的出处。同时要设置好 Prompt 指令,让模型“只基于提供的资料回答,不要自行发挥”。
这里有个容易被忽略的限制:上下文长度有限,不能说所有检索结果都往里塞。如果你切块大小为 500 字,topK 取 5,那么检索结果就是 2500 字左右,加上问题历史和系统提示,基本还能控制在一个合理范围内。但如果切块 2000 字,topK 取 10,那 2 万字的上下文可能直接把你用的模型窗口打爆。
所以“索引阶段”和“检索阶段”的决策,直接决定了“生成阶段”的质量。整个 RAG 链路就像一个流水线,前面每一步的误差都会传导到最终答案。这也是为什么我不建议刚上手就追求复杂架构,先把切块、检索、拼接这三个环节吃透,比盲目堆技术更有用。
3. 用 Chroma 把语义搜索跑起来
3.1 为什么我推荐先用 Chroma
向量数据库的选择很多,Milvus 适合大规模分布式场景,Qdrant 性能好且功能丰富,但我们日常做原型、做个人知识库、做中小型项目,Chroma 经常会让人觉得“真香”。
我用一张表说明它的定位:
| 特性 | Chroma | Qdrant | Milvus |
|---|---|---|---|
| 部署复杂程度 | 低,可嵌入式运行 | 中,通常要起服务 | 高,组件多,适合集群 |
| 资源占用 | 低,进程内运行 | 中等 | 较高 |
| Python API 友好度 | 高,语法直观 | 中 | 中 |
| 适合规模 | 几十万级向量以内 | 百万级向量 | 千万级以上 |
| 上手速度 | 几分钟就能跑 | 需要理解 collection 和配置 | 学习曲线较陡 |
我不是说 Chroma 能取代 Milvus,而是说在 RAG 项目早期,你还没验证业务效果就上重型数据库,很容易被部署和运维拖住。Chroma 最舒服的地方是:pip 安装后,它能作为一个本地库集成在你的 Python 进程里,也能跑成服务端,后面真要迁移到 Qdrant 或 Milvus,只需替换 MCP Server 或数据访问层即可。
3.2 安装、初始化与持久化
先装依赖,我习惯在一个干净的虚拟环境里操作:
pip install chromadb如果你后续要配合 LangChain 做文档切分,可以顺便装:
pip install langchain langchain-community langchain-text-splitters初始化 Chroma 时,最关键的是设置持久化目录,否则默认情况下数据只存在内存里,程序一退出就全丢了。
import chromadb # 设置持久化目录 ./chroma_db,数据会写到这里 client = chromadb.PersistentClient(path="./chroma_db")我踩过的第一个坑就是忘了设置持久化,结果重启后 collection 里的数据全部蒸发。所以这里强烈建议:从一开始就规划好存储路径,别用临时目录或默认内存模式。
创建 collection 时,可以指定距离计算方式,默认是余弦距离,适合大多数文本检索场景。
collection = client.get_or_create_collection( name="knowledge_base", metadata={"hnsw:space": "cosine"} )3.3 文档切块与向量化写入
实际项目中我们不会只往里塞一句话,而是塞一批文档。我常用 LangChain 的RecursiveCharacterTextSplitter做切块,它基于一组分隔符递归切分,能尽量保留语义完整性。
from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = TextLoader("docs/project_notes.txt", encoding="utf-8") documents = loader.load() text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", " ", ""] ) docs = text_splitter.split_documents(documents)这里chunk_size=500和chunk_overlap=50只是起点参数,具体数值要按文档风格调整。如果文档里有很多小标题,我会把分隔符里的\n\n放最前面,保证先按段落切,再按句子切。
向量化这部分,Chroma 支持自己指定 embedding 函数。如果你有 OpenAI 的 API Key,可以用 OpenAI Embedding;如果是本地环境或者中文文档多,我更喜欢用text2vec或BAAI/bge-small-zh这类本地模型,方便离线,也省调用费用。
Chroma 还提供了一个内置的DefaultEmbeddingFunction,它会在首次使用时下载一个模型,但对中文支持一般。我更建议你先用本地模型生成好向量,再传给 Chroma,这样可控性更高。
写入时要注意:同一个 collection 里所有向量的维度必须一致,否则会报错或检索结果混乱。下面是一个写入示意:
from sentence_transformers import SentenceTransformer embedder = SentenceTransformer("BAAI/bge-small-zh-v1.5") # 生成向量 texts = [doc.page_content for doc in docs] embeddings = embedder.encode(texts).tolist() # 生成 id 和元数据 ids = [f"doc_{i}" for i in range(len(docs))] metadatas = [{"source": doc.metadata.get("source", ""), "index": i} for i in range(len(docs))] collection.add( ids=ids, documents=texts, embeddings=embeddings, metadatas=metadatas )这里有一点容易忽略:如果设置了documents,Chroma 内部也可能再次计算 embedding,除非你显式传入embeddings。所以我建议,尽量保持“要么全传 documents,要么全传 embeddings+documents”,避免重复计算和版本不一致问题。
3.4 语义搜索查询实操
查询就很简单了,你可以传一段问句进去,Chroma 会自动帮你做向量化并返回相似结果。如果你已经自己生成了查询向量,也可以直接传向量。
query = "项目里遇到并发写入问题怎么解决?" results = collection.query( query_texts=[query], n_results=3, include=["documents", "metadatas", "distances"] ) for i, doc in enumerate(results["documents"][0]): distance = results["distances"][0][i] metadata = results["metadatas"][0][i] print(f"距离: {distance:.4f} | 来源: {metadata.get('source')}") print(doc) print("---")返回结果里的distance是距离值,对于余弦距离来说,数值越小通常表示语义越接近。你可以拿这个值做经验阈值,比如只保留距离小于 0.4 的结果,过滤掉无关干扰项。不过阈值没有统一标准,要结合你的 embedding 模型和数据分布来测。
Chroma 还支持元数据过滤,比如先按来源筛选,再检索:
results = collection.query( query_texts=[query], n_results=3, where={"source": {"$eq": "docs/project_notes.txt"}} )这种过滤在文档量大、来源多的时候特别有用。比如公司知识库里同时有产品文档和研发文档,先限定来源,能避免跨领域的内容干扰。
3.5 把 Chroma 接进 MCP Server
这是标题里“每天了解几个 MCP Server”最相关的地方。把 Chroma 封装成 MCP Server,让 AI Agent 可以直接调用检索能力,比手动跑 Python 脚本自然得多。
MCP Python SDK 提供了一套简洁的接口,核心是注册工具函数。下面是一个最小示意:
import chromadb from mcp.server.fastmcp import FastMCP mcp = FastMCP("chroma-mcp") client = chromadb.PersistentClient(path="./chroma_db") collection = client.get_collection("knowledge_base") @mcp.tool() def search_docs(query: str, n_results: int = 3) -> list[str]: """从知识库中检索与 query 最相关的文本片段""" results = collection.query( query_texts=[query], n_results=n_results, include=["documents"] ) return results["documents"][0] @mcp.tool() def add_doc(text: str, source: str) -> str: """向知识库新增一条文本,source 为来源标识""" idx = collection.count() collection.add( ids=[f"manual_{idx}"], documents=[text], metadatas=[{"source": source}] ) return f"added {idx}"上面这段逻辑很直观:MCP Server 暴露了search_docs和add_doc两个工具,AI Agent 收到用户问题后,会自动判断要不要调search_docs去检索知识库,再把结果拼进回答里。你在本地运行时,可以用mcp.run()启动,也可以接入 Claude Desktop 或其他 MCP Host。
这里有个重要的经验:工具的描述信息要写得足够清楚,因为 Agent 是根据描述来决定何时调用这个工具的。比如“检索知识库中与 query 最相关的文本片段”这个描述,就比“search_docs”更明确。你把描述写清楚,Agent 才不会在你问天气的时候去查知识库。
4. 我踩过的坑,直接给你避雷清单
4.1 向量维度不对,检索结果全是乱的
这个问题几乎每个 RAG 新手都会遇到。症状是:程序不报错,但查出来的结果明显和问题无关。原因是同一个 collection 里混入了不同 embedding 模型生成的向量,或者你在写入时用 A 模型生成向量,查询时又用了 B 模型。
Chroma 在创建 collection 时就会锁定向量维度,但如果你手动传embeddings,它不会阻止你传错模型。因此,我建议把 embedding 模型的版本和名称也写入 collection 的元数据里,方便溯源。如果你已经混入脏数据,最简单的办法是删掉 collection 重新建,不要试图“修补”向量数据,因为向量空间不一致的问题修起来非常麻烦。
4.2 切块大小没调,召回率忽高忽低
有一段时间我处理项目周报,chunk_size 用 1000,结果每块都包含好几周的内容。用户问“上周进度如何”,检索到的片段东讲一点西讲一点,模型回答得像总结了半年的工作。后来把 chunk_size 调成 300,并且加了chunk_overlap,召回质量立刻改善。
这告诉我们要根据文档结构选切块参数。可以先用一个简单的可视化工具把切分结果打印出来,人工看一眼切出来的块是不是语义完整。如果每块都像通顺的段落,那参数就比较合理;如果出现半句话、标题和正文断开的状况,就要调整分隔符或缩小 chunk_size。
4.3 Chroma 持久化时的 SQLite 锁和并发问题
Chroma 默认使用 SQLite 做持久化存储,好处是零配置,坏处是并发写能力弱。我在本地跑 MCP Server 时,经常同时启动多个脚本往同一个 collection 写数据,结果报database is locked错误。
解决方法是:尽量让写操作串行,比如通过一个统一的写入脚本批量导入,而不是多个进程并发去 add。如果确实需要高并发,最好把 Chroma 切换成服务端模式,或者考虑 Qdrant 这类专为并发设计的数据库。README 上写着 Chroma 可以“同时读多写”,但在实际工程中,最好别让它承受太高并发。
4.4 MCP Server 调用 Chroma 时的类型与上下文问题
用 MCP 封装 Chroma 后,最常见的问题有两个。第一个是工具函数返回的数据类型不合适。比如你的工具返回的是list[str],但有些 Agent 框架希望返回一个拼接好的字符串,否则它不好放进 Prompt 里。我通常会在工具内部先合并返回结果,而不是让 Agent 拿到一个 Python list 再去处理。
第二个是上下文过长。MCP 工具能把检索结果捞回来,但 Agent 组装 Prompt 时不会自动帮你控制长度。你需要在上层应用里限制工具返回的长度,或者在工具内部就截断过长的文本。比如search_docs返回前,用doc[:800]截断,避免一次检索就把上下文撑爆。
还有一个隐蔽问题:MCP Server 启动时加载的 collection 名字是硬编码的,如果你改过 collection 名字或路径,MCP 工具会一直查旧库。我习惯把 collection 名和路径配置成环境变量,这样换库时不用改代码,重启服务即可。
最后分享一个小技巧:在做 MCP Server 和 Chroma 的联调时,先不要用 Agent 去调工具,而是用 MCP 自带的调试界面直接调工具函数,确认返回结果正常之后,再连上 Agent。这样能把“工具本身的问题”和“Agent 调用方式的问题”分开排查,省掉大量试错时间。
我个人在实际项目里的体会是,Chroma 非常适合作为个人知识库和中小型 RAG 项目的起步选择,而 MCP Server 让这个起步变得更加顺滑。你不需要一开始就设计一个分布式架构,先把数据切好、向量存好、MCP 工具跑通,等规模上来再平滑换到更重的数据库,这才是性价比最高的路径。