写这篇东西的起因特别朴素:我在 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_factory=dict) 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( text=prefix + 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_size=800,差不多是中文 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", device="cpu") 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_embeddings=True) return EmbeddingResponse( data=[ {"object": "embedding", "index": i, "embedding": vec.tolist()} for i, vec in enumerate(vecs) ] ) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)注意这里有个细节:normalize_embeddings=True。做余弦相似度时,把向量归一化到单位长度可以让后续计算更稳定,而且 pgvector 里用<=>余弦距离时效率也更好。这个开关很多人会忽略,我也是踩了一次"检索结果排序不稳定"的坑才发现的。
3.3 用召回率测试判断模型好坏,而不是排行榜
模型选型不要只看社区那个跑分表。我的做法是:从真实知识库里挑 50 个典型的查询问题,手动给每个问题标注"期望召回的文档片段",然后依次用候选模型向量化、检索、统计 Top-5 召回率。这个过程要不了 20 分钟,但比任何榜单都更贴近你的真实场景。
举个例子。我们知识库里有一个文档叫"登录态失效排查手册",里面讲的是 token 过期后的表现、常见误区和处理流程。我用三个模型分别测"登录态失效怎么办"这个 query,A 模型 Top-5 没召回这篇文档,B 模型排在第三,C 模型排在第二。如果只看 benchmark,A 模型得分并不低,但在我们的语料上它就不好使。embedding 模型的效果高度依赖语料分布,没有哪个模型是万能最优的。
4. pgvector 入库:建表、索引、写入与更新策略
向量化之后的数据要落到存储层。我用 pgvector 的原因前面已经说了:复用现有 PostgreSQL,少维护一套系统,SQL 还能直接操作向量数据。
4.1 安装 pgvector:Linux、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,官方文档建议默认值是m=16, ef_construction=64。数据量不大时,这两个参数可以调大一点点换召回率,比如m=32, ef_construction=128,代价是索引体积增大、构建变慢。实际测下来,几十万条数据的场景区别不大。
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 侧配置 MCP:stdio 启动、环境变量和检索链路验收
前面几章的核心工作全部在"知识库侧"完成。现在到了最关键的一步:让 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:pass@localhost: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(transport="stdio")注意我返回的结果里带了title和doc_id,这很重要。TRAE 的 Agent 在看到检索结果时,如果只有一段裸文本,它未必能判断这段文本的权威性和来源;带上标题,它至少知道"这是来自《登录态失效排查手册》的内容",回答的准确性会明显提升。
1 - (embedding <=> %s::vector)这行是把余弦距离转换为余弦相似度。pgvector 的<=>返回距离,距离为 0 表示完全相似,距离为 2 表示完全相反。为了让 TRAE 更好理解,我直接把相似度分数算好了给它,从 0 到 1,越大越相关。
5.2 在 TRAE 里添加 MCP Server:stdio 与 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:pass@localhost: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。分块用了我的 MarkdownChunker,max_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。
第三个是多路召回融合。目前只用了向量相似度这一路检索,对于术语密集的文档,可以考虑加一个关键词/全文检索路,两路结果做 RRF(Reciprocal Rank Fusion)融合,召回质量通常会有提升。
我个人的建议是:如果你也要搭这套系统,先不要追求大而全。只要 100 篇文档跑通"分块 → 向量化 → pgvector → TRAE"闭环,你就能体会到这条链路的价值,也自然会发现你自己的知识库到底有哪些独特的坑。工具链可以换,但"文档结构化 → 向量检索 → 大模型引用"的思路是通用的。踩过几次坑之后,你会觉得这套链路跟搭积木差不多,每一块都清清楚楚,没有什么是不能拆开重来的。