本文是「从零搭建私人 RAG 实战」专栏的第四篇。上一篇完成了 Markdown 决策文档的加载、分块、向量化和 LanceDB 入库;本篇进入查询链路,介绍用户问题如何经过 Claude Code、Skill 和 MCP 到达 CorpRAG,再被转换成查询向量,从 BADR、FADR 向量表中检索出相关知识片段。
前言:向量写入数据库之后,怎样真正用起来?
上一篇完成的是 RAG 的索引链路:
Markdown 文档
↓
加载与分块
↓
文档向量化
↓
写入 LanceDB
完成索引并不代表系统已经能够回答问题。
当用户提问时,系统还要执行另一条查询链路:
用户问题
↓
选择知识库查询工具
↓
把问题转换成查询向量
↓
在 LanceDB 中执行相似度搜索
↓
返回 Top-K 个相关片段
↓
大语言模型结合片段组织答案
索引链路与查询链路使用相同的 Embedding 模型,但处理对象不同:
| 链路 | 输入 | Embedding 方法 | 结果 |
|---|---|---|---|
| 索引链路 | 文档片段 | embedDocuments() | 文档向量,长期保存在 LanceDB |
| 查询链路 | 用户问题 | embedQuery() | 查询向量,通常只用于本次搜索 |
本文将结合 CorpRAG 当前的以下模块展开:
src/stdio.ts:启动 MCP Server,并通过 stdio 与客户端通信;src/mcp/server.ts:注册ext_query、fr_query、ga_query等 MCP 工具;src/rag/retriever.ts:选择文档类型、执行检索、合并结果;src/infrastructure/vector-store.ts:连接 LanceDB 并打开指定向量表;src/infrastructure/embeddings.ts:创建查询阶段使用的 Embedding 客户端;src/config.ts:管理模型、数据库路径、Top-K和表名规则。
一、先看完整的 RAG 查询链路
在 CorpRAG 的使用场景中,最终回答不是由 MCP Server 直接生成的,而是由具备对话能力的外部大语言模型生成。
完整链路可以表示为:
用户向 Claude Code 提问
↓
Claude Code 理解问题和 Skill 规则
↓
选择 ext_query / fr_query / ga_query
↓
通过 MCP 协议发送 question、docType
↓
CorpRAG MCP Server 接收工具调用
↓
querySystem() 选择 BADR/FADR 向量表
↓
openVectorStore() 打开已有 LanceDB 表
↓
similaritySearch() 将 question 转为查询向量
↓
LanceDB 搜索距离最近的文档向量
↓
返回 Document[]
↓
CorpRAG 整理正文、来源和文档类型
↓
MCP 将知识片段返回 Claude Code
↓
外部大语言模型结合问题和片段生成答案
↓
回答用户,并尽可能标注知识来源
这里有三个容易混淆的职责边界。
1. Skill 负责告诉客户端“何时以及怎样调用工具”
Skill 可以描述:
- 哪类问题需要查询 CorpRAG;
- EXT、FR、GA 分别对应哪个系统;
- 业务决策优先使用
BADR; - 技术决策优先使用
FADR; - 获取结果后应基于片段回答并保留来源。
Skill 本身不执行向量计算,也不读取 LanceDB。
2. MCP 负责连接 AI 客户端和 CorpRAG
MCP Server 对外暴露工具的名称、说明和参数结构,再把工具调用转交给检索模块。
MCP 解决的是“如何调用能力”,不是“如何计算相似度”。
3. 外部大语言模型负责生成最终答案
CorpRAG 当前返回的是相关知识片段,并没有在服务端调用聊天模型生成答案。
因此:
Ollama + nomic-embed-text → 生成向量
LanceDB → 搜索知识片段
MCP → 传递工具调用和检索结果
Claude Code 中的大模型 → 理解问题并生成最终答案
Embedding 模型不负责聊天,大语言模型也不应该凭空替代知识库检索。
二、查询链路为什么仍然需要 Embedding?
向量数据库中保存的不是用户问题,而是文档片段及其向量。
假设数据库里有三个片段:
片段 A:EXT 系统使用 Redis 保存登录会话。
片段 B:订单支付成功后可以申请电子发票。
片段 C:管理员可以配置成员的数据权限。
用户提问:
EXT 为什么选择 Redis 管理 Session?
如果只做字符串匹配,“登录会话”和“Session”可能无法直接匹配。但 Embedding 模型可以把文本映射到一个语义向量空间:
“EXT 为什么选择 Redis 管理 Session?”
↓ embedQuery()
查询向量 Q
“EXT 系统使用 Redis 保存登录会话。”
↓ 索引阶段已经完成 embedDocuments()
文档向量 A
查询时,LanceDB 比较查询向量与文档向量之间的距离,并优先返回语义上更接近的片段。
可以把这个过程抽象为:
Q = Embedding(用户问题)
D1 = Embedding(文档片段 1)
D2 = Embedding(文档片段 2)
D3 = Embedding(文档片段 3)
分别计算 Q 与 D1、D2、D3 的距离
↓
按照距离排序
↓
返回前 K 个结果
查询向量通常只服务于本次请求,不需要写回向量数据库。
三、索引和查询必须使用同一个 Embedding 模型
CorpRAG 把 Embedding 客户端集中在 src/infrastructure/embeddings.ts 中创建:
import { OllamaEmbeddings } from "@langchain/ollama";
import { config } from "../config";
export function createEmbeddings(): OllamaEmbeddings {
return new OllamaEmbeddings({
model: config.embeddingModel,
baseUrl: config.ollamaBaseUrl,
});
}
默认配置位于 src/config.ts:
export const config = {
ollamaBaseUrl: process.env.OLLAMA_BASE_URL ?? "http://localhost:11434",
embeddingModel: process.env.EMBEDDING_MODEL ?? "nomic-embed-text",
topK: parseInt(process.env.TOP_K ?? "3", 10),
// ...
};
索引阶段和查询阶段都调用 createEmbeddings(),这样可以避免两套配置发生偏差。
正确关系是:
文档片段 → nomic-embed-text → 文档向量
用户问题 → nomic-embed-text → 查询向量
下面这种方式是错误的:
文档片段 → 模型 A → 文档向量
用户问题 → 模型 B → 查询向量
不同 Embedding 模型生成的向量通常不在同一个语义空间。即使向量维度碰巧相同,直接计算距离也没有可靠意义。
如果修改了 EMBEDDING_MODEL,通常需要重新执行:
npm run index
不能只在查询阶段切换模型,却继续使用旧模型建立的索引。
四、MCP Server 如何接收用户问题?
1. 使用 stdio 启动 MCP Server
项目入口位于 src/stdio.ts:
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./mcp/server";
async function main(): Promise<void> {
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[INFO] server connected");
}
StdioServerTransport 使用标准输入和标准输出传递 MCP 协议消息。
因此 stdio 模式下需要注意:
stdin / stdout → MCP 协议通信
stderr → 运行日志和错误日志
不能随意使用 console.log() 向标准输出写调试内容,否则可能污染协议数据。项目使用 console.error() 输出启动日志正是出于这个原因。
开发环境可以通过以下脚本启动:
npm run stdio
构建后则可以运行:
npm run build
npm run start
2. 为每个系统注册一个查询工具
src/mcp/server.ts 会遍历 SYSTEMS:
export const SYSTEMS = ["ext", "fr", "ga"] as const;
然后动态注册三个 MCP 工具:
ext_query
fr_query
ga_query
核心代码如下:
for (const system of SYSTEMS) {
const toolName = `${system}_query` as const;
server.registerTool(
toolName,
{
description: `Query the ${system.toUpperCase()} knowledge base and retrieve relevant text chunks`,
inputSchema: {
question: z.string().describe("The question to ask about the system"),
docType: z
.enum(["BADR", "FADR"])
.optional()
.describe(
"Optional document type filter: BADR (business decisions) or FADR (technical decisions). Leave empty to search both.",
),
},
},
async ({ question, docType }) => {
const results = await querySystem(system as System, question, { docType });
return {
content: [{ type: "text", text: formatResults(results) }],
};
},
);
}
工具接收两个参数:
| 参数 | 是否必填 | 作用 |
|---|---|---|
question | 是 | 用户的自然语言问题 |
docType | 否 | 限定查询 BADR 或 FADR,不传时查询两类文档 |
例如,用户询问 EXT 系统的技术选型时,AI 客户端可以发起类似调用:
{
"name": "ext_query",
"arguments": {
"question": "EXT 系统为什么使用 LanceDB 保存向量?",
"docType": "FADR"
}
}
这里的 Zod Schema 不只是参数校验,也会把工具能力描述给 AI 客户端,帮助大语言模型决定传递什么参数。
五、Claude Code、Skill 与 MCP 如何协作?
MCP Server 只负责暴露工具,不会主动监听自然语言并猜测应该查询哪个系统。
在完整交互中,调用决策通常发生在客户端:
用户:EXT 系统为什么选择某项技术?
↓
Claude Code 判断这是 EXT 技术决策问题
↓
Skill 建议调用 ext_query,并设置 docType=FADR
↓
Claude Code 发起 MCP 工具调用
如果问题是业务规则:
用户:FR 系统为什么限制某类业务操作?
↓
调用 fr_query
↓
docType=BADR
如果无法提前判断属于业务决策还是技术决策,可以不传 docType,让 CorpRAG 同时查询 BADR 和 FADR。
因此,Skill 的价值主要是把团队约定显式化,例如:
- EXT 相关问题使用 ext_query
- FR 相关问题使用 fr_query
- GA 相关问题使用 ga_query
- 业务规则优先查询 BADR
- 技术实现优先查询 FADR
- 类型不明确时不传 docType
- 回答必须以检索片段为依据,并保留来源
是否使用 Skill 不会改变底层向量检索算法,但会影响工具选择和参数质量。
六、querySystem() 如何选择知识库?
MCP 工具拿到参数后,会调用 src/rag/retriever.ts 中的 querySystem():
export interface QueryOptions {
docType?: "BADR" | "FADR";
}
export async function querySystem(
system: System,
question: string,
options?: QueryOptions,
): Promise<RetrievalResult[]> {
const docType = options?.docType;
if (docType) {
return searchDocType(system, docType, question, config.topK);
}
// 未指定 docType 时继续查询 BADR 和 FADR
}
这里有两条分支。
1. 指定 docType:只查一张表
例如:
await querySystem("ext", question, { docType: "FADR" });
只会查询:
ext_fadr_kb
优点是检索范围更精确、返回内容更集中,也只需要执行一次问题向量化和一次向量搜索。
2. 不指定 docType:分别查询两张表
项目会依次查询:
const docTypes: ("BADR" | "FADR")[] = ["BADR", "FADR"];
对应两张表:
{system}_badr_kb
{system}_fadr_kb
例如 EXT 系统会查询:
ext_badr_kb
ext_fadr_kb
需要注意,当前实现是“每张表分别取 Top-K”,而不是两张表全局只取 K 条。
当默认配置为:
TOP_K=3
且两张表都有足够数据时,最终最多可能返回:
BADR 3 条 + FADR 3 条 = 6 条
这是多表查询中很容易忽略的细节。
七、向量表名是怎样确定的?
src/config.ts 使用 tableNameFor() 统一生成表名:
export function tableNameFor(
system: System,
docType?: "BADR" | "FADR",
): string {
return docType
? `${system}_${docType.toLowerCase()}_kb`
: `${system}_kb`;
}
当前按“系统 + 文档类型”拆分物理表:
| 系统 | BADR 表 | FADR 表 |
|---|---|---|
| EXT | ext_badr_kb | ext_fadr_kb |
| FR | fr_badr_kb | fr_fadr_kb |
| GA | ga_badr_kb | ga_fadr_kb |
索引和查询必须使用相同的表名规则:
索引:tableNameFor("ext", "FADR") → ext_fadr_kb
查询:tableNameFor("ext", "FADR") → ext_fadr_kb
如果任意一侧手写了不同名称,就会出现“索引已经建立,但查询找不到表”的问题。
向量数据库路径也必须一致。项目默认使用:
path.resolve(__dirname, "../data/vector-db")
也可以通过环境变量覆盖:
VECTOR_DB_PATH=/absolute/path/to/vector-db
MCP Server 作为子进程运行时,也必须能够读取到相同的环境变量。
八、openVectorStore() 如何打开已有向量表?
查询阶段不能再次调用 LanceDB.fromDocuments()。
fromDocuments() 用于写入文档并建立向量表;查询阶段应该连接现有数据库,检查表是否存在,再把表和 Embedding 客户端包装成 LangChain 的 LanceDB Vector Store。
完整逻辑应为:
export async function openVectorStore(
system: System,
docType: "BADR" | "FADR",
): Promise<LanceDB> {
const embeddings = createEmbeddings();
const tableName = tableNameFor(system, docType);
const conn = await connect(config.vectorDbPath);
const tableNames = await conn.tableNames();
if (!tableNames.includes(tableName)) {
throw new Error(
`Knowledge base for system "${system}" docType "${docType}" not found. ` +
`Please run "npm run index" first.`,
);
}
const table = await conn.openTable(tableName);
return new LanceDB(embeddings, { table });
}
这段代码分为五步:
createEmbeddings()
↓
tableNameFor(system, docType)
↓
connect(config.vectorDbPath)
↓
conn.openTable(tableName)
↓
new LanceDB(embeddings, { table })
这里传入 embeddings 的原因是:Vector Store 不只要读取数据,还要在执行 similaritySearch(question, topK) 时把自然语言问题转换成查询向量。
当前代码需要注意的实现缺口
当前项目的 src/infrastructure/vector-store.ts 已经完成了数据库连接和表存在性检查,但函数在检查后结束,尚未执行:
const table = await conn.openTable(tableName);
return new LanceDB(embeddings, { table });
如果保持现状,openVectorStore() 会返回 undefined,后续代码无法调用:
store.similaritySearch(question, topK)
因此,在实际运行本文查询链路前,需要补齐上面的“打开表并返回 Vector Store”代码。这不是可选优化,而是查询链路能够工作的必要步骤。
九、similaritySearch() 如何把问题转换成查询向量?
真正触发问题向量化的是 src/rag/retriever.ts 中的这行代码:
const docs = await store.similaritySearch(question, topK);
项目没有显式调用:
embeddings.embedQuery(question)
但不代表查询问题没有被向量化。LangChain 的 LanceDB 封装会在内部完成这一步。
其逻辑可以简化为:
async similaritySearch(query: string, k: number) {
const queryEmbedding = await this.embeddings.embedQuery(query);
return this.similaritySearchVectorWithScore(queryEmbedding, k);
}
随后将向量交给 LanceDB:
const results = await table
.query()
.nearestTo(queryEmbedding)
.limit(k)
.toArray();
所以这行看似简单的调用:
store.similaritySearch(question, topK)
实际完成了以下工作:
自然语言 question
↓ OllamaEmbeddings.embedQuery()
number[] 查询向量
↓ LanceDB nearestTo()
近邻搜索
↓ limit(topK)
最相关的 K 条记录
↓ LangChain 转换
Document[]
similaritySearch() 返回的是 Document[],每个元素包含:
{
pageContent: "命中的知识片段正文",
metadata: {
system: "ext",
docType: "FADR",
source: "data/EXT-FADR.md"
}
}
当前检索代码不需要了解 Ollama HTTP 请求格式,也不需要手动遍历数据库中的所有向量。这些细节分别由 OllamaEmbeddings 和 LanceDB 封装。
十、searchDocType() 如何整理检索结果?
单表检索集中在 searchDocType():
async function searchDocType(
system: System,
docType: "BADR" | "FADR",
question: string,
topK: number,
): Promise<RetrievalResult[]> {
const store = await openVectorStore(system, docType);
const docs = await store.similaritySearch(question, topK);
return docs.map((doc) => ({
content: doc.pageContent,
source: (doc.metadata.source as string) ?? "unknown",
docType: (doc.metadata.docType as string) ?? docType,
}));
}
Vector Store 返回的是 LangChain Document,而 MCP 层不需要知道完整数据库记录结构,因此项目将其映射为更简单的业务类型:
export interface RetrievalResult {
content: string;
source: string;
docType: string;
}
三个字段各有作用:
| 字段 | 作用 |
|---|---|
content | 提供给大语言模型的知识正文 |
source | 告诉用户知识来自哪个文件 |
docType | 区分业务决策 BADR 和技术决策 FADR |
RAG 不应该只返回正文而丢弃来源。保留来源有助于:
- 用户核对原始文档;
- 判断回答依据是否可靠;
- 调试检索是否命中了错误文件;
- 后续实现引用、溯源和审计。
十一、同时查询 BADR 和 FADR 时怎样合并结果?
当没有传入 docType 时,querySystem() 会分别查询两类文档:
for (const type of docTypes) {
try {
const typeResults = await searchDocType(
system,
type,
question,
config.topK,
);
resultsByType.push(typeResults);
} catch (err) {
// ...
}
}
随后使用交错方式合并:
const merged: RetrievalResult[] = [];
const maxLen = Math.max(...resultsByType.map((r) => r.length));
for (let i = 0; i < maxLen; i++) {
for (const results of resultsByType) {
if (i < results.length) {
merged.push(results[i]);
}
}
}
假设两张表分别返回:
BADR:B1、B2、B3
FADR:F1、F2、F3
最终结果为:
B1、F1、B2、F2、B3、F3
这种策略有两个特点:
- 保留每类文档内部的相似度顺序;
- 避免一类文档完全占满返回列表。
但它不等于全局相关度排序。
由于当前使用的是 similaritySearch(),返回结果中没有保留分数,所以系统无法直接比较:
BADR 第 1 名 与 FADR 第 1 名,谁在全局更相关?
如果后续需要跨表全局排序,可以改用:
similaritySearchWithScore(question, topK)
再统一处理不同表中的距离或分数。使用前必须先确认当前库中“分数越大还是越小表示越相关”,不能仅凭字段名称猜测。
十二、缺少某类知识库时如何处理?
如果用户明确指定:
{ docType: "FADR" }
但对应表不存在,错误会直接向上抛出,提示先建立索引。
如果用户没有指定文档类型,项目会同时尝试 BADR 和 FADR。此时某一张表不存在并不应该阻止另一张表返回结果:
catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("not found")) {
console.warn(
`[WARN] knowledge base not found for ${system}/${type}, skipping`,
);
continue;
}
throw err;
}
例如:
ext_badr_kb 存在
ext_fadr_kb 不存在
不指定 docType 时仍然可以返回 BADR 结果。
这体现了两种不同语义:
- 单表精确查询:用户明确要求某类文档,缺失就应该报错;
- 多表尽力查询:允许跳过缺失表,返回其他可用知识。
不过,当前实现通过错误文本是否包含 not found 判断错误类型。更稳健的工程实现可以定义专用错误类型,避免数据库的其他错误信息恰好包含相同文本。
十三、检索结果如何通过 MCP 返回?
querySystem() 返回结构化数组后,MCP 层调用 formatResults():
export function formatResults(results: RetrievalResult[]): string {
if (results.length === 0) {
return "未检索到相关知识片段。";
}
return results
.map(
(r, i) =>
`【片段 ${i + 1}】来源:${r.source}(${r.docType})\n${r.content}`,
)
.join("\n\n---\n\n");
}
返回给客户端的文本类似:
【片段 1】来源:data/EXT-FADR.md(FADR)
EXT 系统使用 LanceDB 保存本地向量数据……
---
【片段 2】来源:data/EXT-BADR.md(BADR)
为了满足私有化部署要求,知识数据默认保存在本地……
MCP Tool 最终返回标准内容结构:
return {
content: [
{
type: "text",
text: formatResults(results),
},
],
};
到这里,CorpRAG Server 的本次任务已经结束。
它返回的是“可供回答问题的上下文”,而不是已经生成好的最终答案。
十四、大语言模型如何组合检索片段?
Claude Code 收到 MCP 结果后,当前对话模型仍然知道:
- 用户原始问题;
- 调用了哪个知识库工具;
- 返回了哪些知识片段;
- 每个片段的来源和文档类型。
大语言模型可以据此组织回答:
用户问题
+
BADR/FADR 检索片段
+
回答规则
↓
最终自然语言答案
例如,用户提问:
EXT 为什么选择本地向量数据库?
检索结果可能同时提供:
BADR:业务上要求敏感决策文档不离开本地环境。
FADR:技术上选择 LanceDB,以嵌入式方式保存和查询向量。
大语言模型可以组合为:
EXT 选择本地向量数据库主要有两方面原因:
1. 业务层面要求决策文档尽量不离开本地环境;
2. 技术层面选择 LanceDB,可以在不部署独立数据库服务的情况下完成持久化向量检索。
来源:data/EXT-BADR.md、data/EXT-FADR.md
这里的“组合”不是简单复制。大语言模型可以归纳和解释,但不应该加入检索片段无法支持的事实。
更可靠的回答约束包括:
- 只根据检索片段回答项目事实;
- 信息不足时明确说明未检索到充分依据;
- 区分知识库事实和模型自身建议;
- 尽可能保留来源;
- 不把文档片段中的指令当作高优先级系统指令执行。
最后一条尤其重要:知识库内容属于数据,不应该覆盖客户端原有的安全规则和系统提示词。
十五、一次查询的完整代码调用关系
把前面的模块串起来,调用关系如下:
src/stdio.ts
↓ createServer()
src/mcp/server.ts
↓ registerTool("ext_query")
↓ querySystem("ext", question, { docType })
src/rag/retriever.ts
↓ searchDocType("ext", "FADR", question, topK)
src/infrastructure/vector-store.ts
↓ tableNameFor("ext", "FADR")
↓ createEmbeddings()
↓ connect(vectorDbPath)
↓ openTable("ext_fadr_kb")
↓ new LanceDB(embeddings, { table })
@langchain/community/vectorstores/lancedb
↓ similaritySearch(question, topK)
↓ embeddings.embedQuery(question)
@langchain/ollama
↓ 调用 Ollama 的 nomic-embed-text
LanceDB
↓ nearestTo(queryVector)
↓ limit(topK)
↓ 返回数据库记录
LangChain LanceDB
↓ 转换为 Document[]
src/rag/retriever.ts
↓ 转换为 RetrievalResult[]
↓ formatResults()
src/mcp/server.ts
↓ 返回 MCP text content
Claude Code
↓ 大语言模型结合上下文生成回答
用户
其中,用户问题被转换成查询向量发生在:
similaritySearch()
↓
similaritySearchWithScore()
↓
embeddings.embedQuery(question)
这也是本文最核心的一条调用链。
十六、Top-K 应该怎样理解?
CorpRAG 默认配置:
topK: parseInt(process.env.TOP_K ?? "3", 10)
Top-K=3 表示单次单表搜索最多返回前三个近邻片段。
K 太小
可能出现:
- 正确片段没有进入结果;
- 只能检索到结论,缺少原因和约束;
- 一个跨文档问题无法获得足够上下文。
K 太大
可能出现:
- 无关片段增加;
- 重复内容增多;
- MCP 返回文本过长;
- 大语言模型受到噪声干扰;
- 上下文 Token 消耗上升。
可以通过环境变量调整:
TOP_K=5 npm run stdio
但调参时要区分:
指定 docType:最终最多 K 条
不指定 docType:当前实现最终最多 2K 条
更合理的 K 值需要使用真实问题集评估,而不是只凭主观感觉设置。
十七、怎样验证查询链路是否正确?
不要只检查“有没有返回内容”,还要分层验证。
1. 检查 Ollama 和 Embedding 模型
ollama list
确认存在:
nomic-embed-text
如果 Ollama 地址不是默认值,需要保证 MCP Server 子进程能够读取:
OLLAMA_BASE_URL=http://localhost:11434
2. 检查索引是否已建立
先执行:
npm run index
然后确认 data/vector-db 中已经生成向量数据库数据。
3. 检查数据库路径和表名
确认索引和查询使用相同的:
VECTOR_DB_PATH
EMBEDDING_MODEL
并确认目标表符合命名规则:
ext_badr_kb
ext_fadr_kb
fr_badr_kb
fr_fadr_kb
ga_badr_kb
ga_fadr_kb
4. 检查 openVectorStore() 返回值
确认函数不只检查表是否存在,还真正执行了:
const table = await conn.openTable(tableName);
return new LanceDB(embeddings, { table });
5. 使用能在原文中核对的问题
测试问题应该来自已有文档,例如:
EXT 系统为什么选择某项已经记录的技术方案?
然后检查:
- 是否调用正确的系统工具;
docType是否合理;- 返回片段是否真的支持问题;
source是否指向正确 Markdown;- 最终回答是否引入知识库中不存在的事实。
十八、常见问题排查
1. 提示知识库不存在
错误类似:
Knowledge base for system "ext" docType "FADR" not found.
Please run "npm run index" first.
依次检查:
- 是否执行过
npm run index; VECTOR_DB_PATH是否一致;- 表名是否符合
tableNameFor()规则; - 对应 Markdown 是否为空或不存在;
- 索引时是否因为错误跳过了该文档。
2. similaritySearch 无法调用
如果出现类似:
Cannot read properties of undefined
重点检查 openVectorStore() 是否返回了 LanceDB 实例。
3. Ollama 连接失败
检查:
OLLAMA_BASE_URL
Ollama 进程是否运行
nomic-embed-text 是否已拉取
MCP 客户端启动的子进程环境可能与终端不同,因此“终端中可用”不一定代表“客户端子进程中可用”。
4. 能返回结果,但内容不相关
可能原因包括:
- 文档分块过大或过小;
- 用户问题缺少系统或业务上下文;
- BADR/FADR 类型选择错误;
Top-K太小;- 文档本身没有记录答案;
- 索引和查询使用了不同模型;
- 修改原始文档后没有重新索引。
5. 最终回答与片段不一致
这通常不属于向量搜索本身的问题,而是回答生成阶段没有严格使用检索上下文。
可以在 Skill 或提示规则中明确:
- 项目事实必须来自 CorpRAG 返回片段;
- 没有依据时说明信息不足;
- 回答末尾列出引用来源。
十九、当前基础实现还可以补充什么?
本文完成的是最小可用语义检索链路。为了让系统从“能查询”走向“查询更可靠”,后续可以继续补充以下能力。
1. 返回相似度分数
当前使用:
similaritySearch(question, topK)
可以评估改为:
similaritySearchWithScore(question, topK)
这样便于观察命中质量、设置阈值和实现跨表排序。
2. 设置最低相关性阈值
当前只要表中存在数据,就会返回最靠前的 K 条,即使它们与问题关系很弱。
增加阈值后,可以在依据不足时返回:
未检索到足够相关的知识片段。
3. 查询改写
用户问题可能包含代词、省略或口语表达。可以在检索前生成更适合搜索的查询,但必须避免改写后偏离原意。
4. 混合检索
向量检索擅长语义匹配,但对错误码、接口名、类名等精确词可能不够稳定。后续可以结合:
向量检索 + 关键词检索
5. 重排序
先召回更多候选片段,再使用重排序模型选出真正适合回答的问题上下文:
初次召回 Top-20
↓
Reranker 重排序
↓
保留 Top-5
6. 缓存和并行查询
当前未指定 docType 时依次查询 BADR、FADR,两次搜索会重复生成同一问题的查询向量。后续可以考虑:
- 只生成一次查询向量;
- 对两张表并行执行向量搜索;
- 对高频问题缓存检索结果。
这些属于性能和检索质量优化,不应掩盖当前最重要的基础:先保证模型、表名、数据库路径和返回结构完全一致。
二十、本篇小结
这一篇完成了 CorpRAG 的查询链路梳理:
用户问题
↓ Claude Code + Skill
选择 MCP 查询工具
↓
querySystem()
↓
选择系统和 BADR/FADR 表
↓
openVectorStore()
↓
打开 LanceDB 已有向量表
↓
similaritySearch()
↓
embedQuery() 生成查询向量
↓
LanceDB 近邻搜索
↓
Document[]
↓
RetrievalResult[]
↓
formatResults()
↓ MCP
外部大语言模型组合答案
↓
返回用户
需要记住的核心结论有六点:
- 用户问题也必须转换成向量,才能与文档向量比较;
- 索引和查询必须使用同一个 Embedding 模型及兼容配置;
- MCP 是调用接口,不负责向量计算,也不直接生成最终答案;
similaritySearch()内部会调用embedQuery(),再让 LanceDB 执行近邻搜索;- CorpRAG 按“系统 + BADR/FADR”拆表,不传
docType时会分别检索并交错合并; openVectorStore()必须真正打开表并返回LanceDB实例,只有表存在性检查还不足以完成查询。
到这里,我们已经具备了 RAG 的两个核心基础环节:
索引:文档 → 向量数据库
查询:问题 → 相关知识片段
下一篇将介绍如何在 Claude Code 中配置并调用 CorpRAG MCP 服务,根据用户问题选择对应系统的查询工具,分别检索 FADR 技术决策和 BADR 业务决策,并结合返回的知识片段与来源生成可靠回答。