写这篇东西的起因特别朴素我在 TRAE 里写代码遇到内部接口问题顺手问一句 AI结果它的回答要么是拿开源项目的经验硬套要么就停在我建议你去看一下文档这种正确废话上。我们团队的知识沉淀全在 Markdown 文档、排障手册和 FAQ 里但这些内容 TRAE 根本看不见。MCP 协议其实就是干这个的——把 AI 应用外接数据源这件事标准化让我可以把私有知识库接成 TRAE 的一个检索工具。这篇文章记录的就是从零搭完文档分块 → 向量化 → pgvector 入库 → TRAE 实时检索这条完整链路的过程整条链路跑通之后AI 写代码遇到内部问题时会先查资料再回答至少在翻文档这一步上体验是真的不一样。1. 为什么绕这么大一圈从给 TRAE 喂文件到给它接上检索接口先交代背景。我们公司内部的知识库散落在一堆地方GitLab 里的 Markdown 文档、在线 Wiki、排障手册、甚至某个老同事本地磁盘里的接口说明。写代码时最崩溃的时刻不是不会写而是这个内部接口的返回结构到底是什么——你翻文档要花十分钟等翻到了思路早断了。TRAE 这类 AI 编程 IDE 给了一个新的可能性让它直接去读这些文档。但问题也随之而来第一个是上下文窗口有限你不可能把几十万字的知识库全部塞进对话第二个是知识更新频繁今天塞进去的文档明天可能就过时了第三个是权限问题很多知识库内容不该被发送到线上 API。所以核心矛盾其实只有一个如何让 AI 在需要的时候精准地从私有知识库里捞到一小段相关上下文。1.1 MCP 不是魔法它只是一个标准接口MCP全称 Model Context Protocol通俗理解就是AI 世界的 USB 接口。在 USB 出现之前打印机、键盘、鼠标各有各的接口换设备要配不同的线USB 出现之后所有外设即插即用。MCP 干的也是这件事它定义了一套统一的协议让 AI 应用比如 TRAE可以外接工具和数据源而不用为每个数据源单独定制对接方案。在这条链路里我把知识库封装成一个 MCP Server暴露一个检索工具。TRAE 只需要知道有一个工具叫 search_knowledge传入 query 和 top_k返回相关文本片段就能在对话中实时调用。至于这个工具背后是 MySQL、PostgreSQL、Elasticsearch 还是纯内存数组TRAE 完全不需要关心。这里要澄清一个常见的误解MCP 不是向量数据库也不是检索算法它只是接口规范。真正干活的是我后面要讲的分块、向量化和相似度检索那套东西。很多人一听到MCP 接入知识库就觉得是魔法实际上协议本身非常简单难点全在知识库这侧怎么做结构化。1.2 为什么选 pgvector 而不是专门的向量数据库市面上专用的向量数据库不少Milvus、Qdrant、Weaviate 都很成熟但我最后选了 pgvector。原因很简单大多数团队的 PostgreSQL 实例是现成的多维护一套向量数据库的成本远大于 pgvector 的性能差距。pgvector 是 PostgreSQL 的一个扩展插件它给 PG 增加了 vector 数据类型和相似度检索算子支持 HNSW 索引和 IVF 索引。在数据量到百万级之前pgvector 的检索性能完全够用。而且它有一个巨大的优势向量数据可以和业务元数据放在同一个数据库里用 SQL 直接 JOIN、过滤、做权限控制不需要在两个系统之间来回搬运数据。大厂经常吹的千万级向量毫秒级检索当然是真的但绝大多数团队的知识库规模根本达不到这个量级。我见过很多团队为了上向量数据库专门招人运维一套分布式系统结果数据量连十万条都没有——这是典型的用牛刀杀鸡还把自己累得半死。技术选型要对着实际问题来不是对着概念的热度来。1.3 这套方案的适用范围和边界这套方案适合什么场景我自己的判断是文档量在几千到几十万篇之间、内容以中文为主、已经有 PostgreSQL、团队希望让 AI 编程助手能参考内部资料。它不适合大规模非结构化数据检索也不适合对检索质量要求极高的场景比如搜索引擎级应用。还有一条很重要的边界知识库检索解决的是信息不在上下文里的问题不是信息不存在的问题。如果你的团队根本没有文档沉淀那这套方案的输入就不成立。先有知识再谈知识库。2. 分块是个手艺活既要上下文完整又要检索精准很多人搭建知识库时最容易忽略的就是分块觉得不就是把文档切成一段段的吗。但实际情况是分块策略决定了向量化的粒度而向量化的粒度直接决定了检索的精准度。这一步做不好后面用什么模型、用什么数据库都救不回来。分块的核心矛盾是块太大embedding 会把整段的语义平均掉检索出来一堆泛泛而谈的内容块太小上下文被切碎检索到的片段可能缺少关键背景。比如一段接口说明前半部分是鉴权方式后半部分是参数列表如果从中间切开两个块都变成残废。2.1 先想清楚知识库的类型再决定分块方式我给几个常见策略分块方式适用场景优点缺点固定字符数切分无结构的纯文本实现简单容易切断语义按 Markdown / 标题结构切结构化文档接口说明、规范、FAQ语义边界自然检索命中率高对无结构文档无效递归字符分割混合类型文档兼顾长段和短段参数多需要调按语义切如 embedding 聚类长文章、论文语义完整计算量大实现复杂实际落地时我强烈建议优先用结构切分。因为大部分企业内部知识库是有结构的Markdown 标题、段落、列表天然就是语义边界。按标题切分看起来不如智能分块高大上但效果往往更好而且逻辑透明出了问题一眼就能看出来。2.2 我的 Markdown 分块实现我写了一个轻量的分块器逻辑是先按 Markdown 的一二级标题定位大章节然后把每个大章节内部再按段落切分。如果某个段落还是太长超过最大长度再按句子边界切。import re from dataclasses import dataclass, field dataclass class Chunk: text: str metadata: dict field(default_factorydict) class MarkdownChunker: def __init__(self, max_chunk_size: int 800, min_chunk_size: int 100): self.max_chunk_size max_chunk_size self.min_chunk_size min_chunk_size def _extract_title(self, line: str) - str: return re.sub(r^#\s*, , line).strip() def split(self, md_text: str, doc_id: str) - list[Chunk]: lines md_text.splitlines() chunks: list[Chunk] [] current_title buf: list[str] [] def flush(): if not buf: return text \n.join(buf).strip() if len(text) self.min_chunk_size: return prefix f# {current_title}\n\n if current_title else chunks.append(Chunk( textprefix text, metadata{doc_id: doc_id, title: current_title} )) for line in lines: if line.startswith(## ): flush() buf [] current_title self._extract_title(line) elif line.startswith(### ): # 三级标题作为小节也作为一个自然断点 flush() buf [line] else: buf.append(line) flush() return chunks这个代码的思路是二级标题是主要的语义边界三级标题作为小节内容的一部分。切出来的每个 chunk 会把一级标题作为前缀拼上去这样即使这一块被单独检索到模型也能知道它属于哪个模块——这个细节非常关键后面 TRAE 在回答时会自动使用当前知识范围来解释问题有没有标题上下文答案的准确度差别很大。2.3 参数调整和代码类文档的特殊处理chunk 长度建议根据文档类型来。我们的文档以接口说明和排障手册为主我设的是 max_chunk_size800差不多是中文 800 字符对照 BGE-M3 的 8192 token 上限绰绰有余。还有两个参数值得一说。第一个是重叠率如果追求召回稳定可以在相邻 chunk 之间保留 10%~15% 的重叠但代价是存储膨胀我不建议一开始就开。第二个是预处理代码块和正文要分开处理代码块按行号切分不要让一个长函数被切成两段——这在对 API 文档做 RAG 时特别容易踩切碎了之后检索到的代码片段没法直接执行或理解。实践中有个原则分块宁可让人看懂也不要让机器省事。一个 chunk 如果人读起来都莫名其妙那这个 chunk 基本就是垃圾数据检索出来了模型也没法用。3. 向量化选型本地服务、OpenAI 兼容接口和召回率验证分块做完文档变成了一堆纯文本片段。下一步是向量化——把这些文本片段映射成高维向量让语义相近的文本在向量空间里距离更近。embedding 模型的选型是这条链路里影响检索质量最大的单点。我见过有人偷懒直接用某个大模型的 embedding 接口结果中文领域术语识别一塌糊涂也有人为了一个几千条数据量的库专门训练模型纯属过度设计。这里分享的是我的选型和验证方法。3.1 选 embedding 模型的四个检查项我选 embedding 模型时只看四点第一中文效果。很多开源模型在英文 benchmark 上跑分很高但中文长文本、中文术语上表现平庸。优先看中文语料上的效果。第二维度大小。向量维度越高存储和计算成本越高但效果未必更好。比如 BGE-M3 是 1024 维text2vec-large-chinese 是 1024 维MiniLM 是 384 维。对私有知识库场景1024 维是性价比比较均衡的档位。第三是否支持本地部署。数据安全敏感的团队把文档内容发到外部 API 是合规风险所以我建议选开源模型自己起一个向量化服务。第四是否支持长文本。有些模型只支持 512 token超过上限就截断。而我们的 chunk 有标题前缀可能超过这个限制。BGE-M3 支持 8192 token是目前比较稳的选择。3.2 用 FastAPI 封装一个本地向量化服务器考虑到 TRAE 侧通过 MCP 实时检索每次查询都要对 query 做向量化这个向量化服务必须是一个常驻进程。我用 FastAPI 写了一个 OpenAI 兼容的 embedding 接口这样后面换模型只需要改一行代码TRAE / MCP Server 完全无感知。from fastapi import FastAPI from pydantic import BaseModel from sentence_transformers import SentenceTransformer app FastAPI() model SentenceTransformer(BAAI/bge-m3, devicecpu) class EmbeddingRequest(BaseModel): input: list[str] model: str bge-m3 class EmbeddingResponse(BaseModel): data: list[dict] model: str bge-m3 app.post(/v1/embeddings) def embeddings(req: EmbeddingRequest): texts req.input vecs model.encode(texts, normalize_embeddingsTrue) return EmbeddingResponse( data[ {object: embedding, index: i, embedding: vec.tolist()} for i, vec in enumerate(vecs) ] ) if __name__ __main__: import uvicorn uvicorn.run(app, host0.0.0.0, port8000)注意这里有个细节normalize_embeddingsTrue。做余弦相似度时把向量归一化到单位长度可以让后续计算更稳定而且 pgvector 里用余弦距离时效率也更好。这个开关很多人会忽略我也是踩了一次检索结果排序不稳定的坑才发现的。3.3 用召回率测试判断模型好坏而不是排行榜模型选型不要只看社区那个跑分表。我的做法是从真实知识库里挑 50 个典型的查询问题手动给每个问题标注期望召回的文档片段然后依次用候选模型向量化、检索、统计 Top-5 召回率。这个过程要不了 20 分钟但比任何榜单都更贴近你的真实场景。举个例子。我们知识库里有一个文档叫登录态失效排查手册里面讲的是 token 过期后的表现、常见误区和处理流程。我用三个模型分别测登录态失效怎么办这个 queryA 模型 Top-5 没召回这篇文档B 模型排在第三C 模型排在第二。如果只看 benchmarkA 模型得分并不低但在我们的语料上它就不好使。embedding 模型的效果高度依赖语料分布没有哪个模型是万能最优的。4. pgvector 入库建表、索引、写入与更新策略向量化之后的数据要落到存储层。我用 pgvector 的原因前面已经说了复用现有 PostgreSQL少维护一套系统SQL 还能直接操作向量数据。4.1 安装 pgvectorLinux、Docker 和 Windows 的差异pgvector 的安装其实不算复杂几个平台我都试过每家的坑不太一样。Linux 下的编译安装比较标准先装 PostgreSQL 的开发头文件然后 clone 源码 make install。更省事的方式是直接用 Docker 镜像很多镜像比如pgvector/pgvector:pg16已经预装好了扩展启动就能用。Windows 下要麻烦一点。热词里我看到windows 安装 pgvector这个确实是个高频问题。Windows 不能像 Linux 那样编译需要在 PostgreSQL 安装完成后去 pgvector 的 GitHub Releases 页下载与 PostgreSQL 版本号严格对应的 Windows 扩展包解压后把vector.control和vector.dll分别放到 PostgreSQL 安装目录的share/extension和lib目录下。注意版本必须严格匹配比如 PG 16.4 就要下载对应 16.4 的包不匹配的话CREATE EXTENSION会报错。装完之后验证CREATE EXTENSION IF NOT EXISTS vector; SELECT vector([1,2,3]);能正常输出向量值就说明安装成功。4.2 表结构与 HNSW 索引设计建表语句如下CREATE TABLE knowledge_chunks ( id BIGSERIAL PRIMARY KEY, doc_id TEXT NOT NULL, chunk_index INT NOT NULL, title TEXT, content TEXT NOT NULL, embedding vector(1024), created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_knowledge_chunks_embedding ON knowledge_chunks USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);表结构里的doc_id是为了后面做增量更新用的chunk_index保存 chunk 在原文中的顺序title保存章节标题。这些元数据在检索阶段很有用比如 TRAE 返回结果时我们可以额外提供score和title让它有依据地判断这个片段靠不靠谱。索引用 HNSW距离类型用vector_cosine_ops就是余弦距离。文本语义检索比较适合余弦而不是 L2因为余弦对向量长度不敏感更关注方向而 embedding 向量的长度本身有时候会受文本长度影响用 L2 会把字数多误判成语义近。HNSW 的两个参数m和ef_construction官方文档建议默认值是m16, ef_construction64。数据量不大时这两个参数可以调大一点点换召回率比如m32, ef_construction128代价是索引体积增大、构建变慢。实际测下来几十万条数据的场景区别不大。4.3 批量写入和 doc_id 级别的增量更新入库这一步很直接但有一个细节很多人会忽略插入之前要按 doc_id 清理旧数据。知识库文档会更新你不希望同一个文档的旧 chunk 和新 chunk 同时存在那样检索时会返回互相矛盾的内容。我当时的入库脚本逻辑是读取文档分块对每个 chunk 调用向量化服务拿到向量开一个事务DELETE FROM knowledge_chunks WHERE doc_id $1批量INSERT新的 chunks提交事务。批量写入用 psycopg2 的executemany或者execute_values后者性能更好几千条数据几百毫秒就能写完。import psycopg2 from psycopg2.extras import execute_values def insert_chunks(conn, doc_id: str, chunks: list[dict], model: str): # chunks: [{content, title, chunk_index, embedding}] rows [ (doc_id, c[chunk_index], c[title], c[content], c[embedding], model) for c in chunks ] with conn.cursor() as cur: cur.execute( DELETE FROM knowledge_chunks WHERE doc_id %s, (doc_id,) ) execute_values( cur, INSERT INTO knowledge_chunks (doc_id, chunk_index, title, content, embedding, embedding_model) VALUES %s , rows, ) conn.commit()还有一个容易踩的坑文末的元数据比如最后修订时间审核人入库时应该单独存不要混进正文一起向量化。元数据里经常有日期、数字、名字会稀释正文的语义而且这些信息在检索时通常不需要——用户问的是登录态失效怎么办不会关心这篇文档是 2024 年 3 月改的。5. TRAE 侧配置 MCPstdio 启动、环境变量和检索链路验收前面几章的核心工作全部在知识库侧完成。现在到了最关键的一步让 TRAE 真正能用上它。这一步就是写一个 MCP Server把检索能力暴露出来。5.1 用 FastMCP 把检索函数封装成工具我用 FastMCP 这个 Python 库来写 MCP Server代码非常简洁from fastmcp import FastMCP import psycopg2 import requests mcp FastMCP(knowledge-server) EMBEDDING_URL http://127.0.0.1:8000/v1/embeddings PG_DSN postgresql://user:passlocalhost:5432/knowledge_db TOP_K_DEFAULT 5 def embed_text(text: str) - list[float]: resp requests.post(EMBEDDING_URL, json{input: [text]}) resp.raise_for_status() return resp.json()[data][0][embedding] mcp.tool() def search_knowledge(query: str, top_k: int TOP_K_DEFAULT) - list[dict]: Search the private knowledge base and return relevant text snippets. vec embed_text(query) with psycopg2.connect(PG_DSN) as conn: with conn.cursor() as cur: cur.execute( SELECT content, title, doc_id, chunk_index, 1 - (embedding %s::vector) AS score FROM knowledge_chunks ORDER BY embedding %s::vector LIMIT %s , (vec, vec, top_k), ) rows cur.fetchall() return [ { content: r[0], title: r[1], doc_id: r[2], chunk_index: r[3], score: float(r[4]), } for r in rows ] if __name__ __main__: mcp.run(transportstdio)注意我返回的结果里带了title和doc_id这很重要。TRAE 的 Agent 在看到检索结果时如果只有一段裸文本它未必能判断这段文本的权威性和来源带上标题它至少知道这是来自《登录态失效排查手册》的内容回答的准确性会明显提升。1 - (embedding %s::vector)这行是把余弦距离转换为余弦相似度。pgvector 的返回距离距离为 0 表示完全相似距离为 2 表示完全相反。为了让 TRAE 更好理解我直接把相似度分数算好了给它从 0 到 1越大越相关。5.2 在 TRAE 里添加 MCP Serverstdio 与 sse 的取舍TRAE 的 MCP 管理面板里可以添加 Stdio Server 和 SSE Server 两种类型。这里直接给结论同一台机器用 stdio 模式。TRAE 直接拉起 Python 进程通过标准输入输出通信最简单。服务器在远端用 sse 模式。MCP Server 跑在内网服务器上TRAE 通过 HTTP 地址连接。实际使用中我选择 stdio 模式因为知识库入库和检索都在同一台开发机上不需要走网络。Stdio 模式的配置大概是这样{ mcpServers: { knowledge: { command: /path/to/venv/bin/python, args: [/path/to/knowledge_mcp_server.py], env: { PG_DSN: postgresql://user:passlocalhost:5432/knowledge_db, EMBEDDING_URL: http://127.0.0.1:8000/v1/embeddings } } } }TRAE 内置的 MCP 配置 UI 也是类似的字段填写命令、参数和环境变量就行。这个过程中最容易踩的坑有三个第一个是 Python 路径。TRAE 在启动 stdio 服务时会用你填的command去执行命令如果你在终端里用python没事是因为终端里激活了虚拟环境但 TRAE 里不一定有这个环境变量。所以一定填绝对路径指向包含fastmcp、psycopg2等依赖的 Python 解释器。第二个是环境变量。如果你的 MCP Server 代码里把数据库连接串硬编码了那没问题但如果你像我一样用os.environ.get(PG_DSN)就要在 TRAE 配置里把环境变量带上。很多次 MCP Server 启动失败最后发现都是环境变量没传。第三个是启动顺序。向量化服务必须比 MCP Server 先启动。MCP Server 本身可以延迟重试连接 embedding 服务但我建议先启动向量化服务再启动 TRAE省得排队重试。5.3 检索链路验收与日志排查配置好以后第一步不是直接写代码测效果而是先确认链路通不通。我在 MCP Server 里加了--verbose日志参数把每一步都打印出来/path/to/venv/bin/python /path/to/knowledge_mcp_server.py --verbose然后从 TRAE 端发起一个简单查询比如问登录态失效怎么排查。如果链路正常TRAE 的 Agent 会自动调用search_knowledge工具返回几个片段然后基于这些片段生成回答。排查时我经常遇到的几个问题TRAE 调用了工具但回答没有任何知识库痕迹很可能是content太长被 Agent 截断了。调小top_k或者调整分块参数。返回的片段和查询完全无关优先怀疑 embedding 模型和 query 预处理。检查 query 是否前后一致如果每次 query 向量化结果不同可能是 embedding 服务有随机性排查归一化开关。MCP Server 直接崩溃通常是数据库连接失败日志里的PG_DSN环境变量没有生效。检查 TRAE 配置。还有一个非常实用的小技巧在 MCP Server 里把每次检索的 query 和 top 结果都写进一个日志文件。这样你可以复盘TRAE 这次回答为什么错因为你能清楚地看到它到底检索到了什么。很多时候问题不在模型生成而在检索阶段就歪了——这个经验在你迭代分块策略时特别值钱。6. 实测数据、日志观察和迭代经验链路全部跑通之后我做了一次比较完整的验证。这里记录一下真实的测试数据和我目前的一些体会给大家做个参考。6.1 一次真实的入库与检索测试当时入库的文档一共 80 多篇主要是接口文档、排障手册和 FAQ分块后得到 4100 多个 chunk。分块用了我的 MarkdownChunkermax_chunk_size 设为 800。向量化服务跑在一台不带 GPU 的 8 核云服务器上用 BGE-M3 CPU 推理入库阶段跑了不到两分钟。入库之后我在 TRAE 里测试了几个典型问题登录态失效怎么排查 能正确召回《登录态失效排查手册》中的相关段落回答给出三步排查路径。某内部接口的 createUser 返回结构 能召回对应接口文档并且因为 chunk 带有标题前缀TRAE 能准确说出这是哪个模块的文档。线上服务报警频繁怎么办 召回结果比较杂部分召回来自 FAQ 里的通用条款准确率一般。第三个问题说明检索质量还有提升空间。我的下一步是优化 FAQ 类文档的分块粒度把每个 FAQ 条目单独作为一个 chunk而不是和相邻条目拼接这样查询时更容易精准命中。单次查询的耗时大概在 300~500ms其中一半是 embedding query 的推理时间一半是 pgvector 的检索时间。这个延迟在 TRAE 对话场景完全可接受。6.2 日志观察带来意识提升的几个小发现观察查询日志一段时间后有几个发现值得记录其一TRAE 在调用工具时query 的表述和用户原话差别很大。比如用户问为什么登录老失败TRAE 会检索登录和身份验证失败原因。这是个好消息说明 Agent 在帮你改写 query但也有风险——它改写后的 query 可能偏离你的原始意图导致召回结果不理想。其二随着对话进行TRAE 有时会连续调用多次搜索语义上相当于在同一个问题上做追问。如果第一次搜索结果不理想第二次它会换一种说法再搜一次。这说明 MCP 工具的返回质量会直接影响 Agent 的策略如果返回结果信息量太低Agent 会进入反复搜索的死循环。其三score这个字段比我想象的更有用。TRAE 似乎会参考相似度分数来决定引用力度分数高的片段会被直接引用分数低的片段只作为背景参考。所以入库时一定要把分数算准别偷懒返回不带分的结果。6.3 后续可以怎么扩展这套链路目前能跑但离完美还有距离。如果继续做我会从三个方向扩展第一个是权限。现在的 MCP Server 对所有查询一视同仁没有做用户身份区分。真实团队场景里不同角色能查的知识范围应该不同。pgvector 本身支持标准 SQL通过元数据字段做权限过滤并不复杂只是 MCP 协议层需要传入用户标识。第二个是引用溯源。我希望检索结果能直接返回原文链接让 TRAE 回答时附上引用来源。现在的doc_id字段有这个基础但还没归档成 URL。第三个是多路召回融合。目前只用了向量相似度这一路检索对于术语密集的文档可以考虑加一个关键词/全文检索路两路结果做 RRFReciprocal Rank Fusion融合召回质量通常会有提升。我个人的建议是如果你也要搭这套系统先不要追求大而全。只要 100 篇文档跑通分块 → 向量化 → pgvector → TRAE闭环你就能体会到这条链路的价值也自然会发现你自己的知识库到底有哪些独特的坑。工具链可以换但文档结构化 → 向量检索 → 大模型引用的思路是通用的。踩过几次坑之后你会觉得这套链路跟搭积木差不多每一块都清清楚楚没有什么是不能拆开重来的。