第 20 章 · 实战一:构建语义搜索知识库问答(RAG)
本章目标:
- 综合运用
embed/embedMany/cosineSimilarity构建一个完整的 RAG 语义搜索问答系统- 掌握「向量化 → 相似度检索 → 引用生成」三段式流水线的代码组织
- 学会用阈值过滤控制检索质量,避免低相关片段污染回答
- 全程使用 AI Gateway 或自定义 OpenAI 兼容 Provider,两种接入方式一键切换
20.1 项目目标与技术方案
我们要构建一个「知识库问答机器人」:给定一组内部文档片段,用户提问后系统先检索最相关的片段,再让 LLM 只基于检索到的内容回答。
三段式架构:
用户提问 ──▶ [1] embed 向量化问题
│
▼
[2] cosineSimilarity 与知识库逐条比对、排序、过滤
│
▼
[3] generateText 把 Top-K 片段作为 context 注入 prompt 生成回答这个模式就是经典的 RAG(Retrieval-Augmented Generation)。本章全部数据放在内存数组中,无需数据库即可运行;生产环境只需把数组换成向量数据库(如 pgvector、Pinecone)。
20.2 环境与 Provider 接入
新建项目并安装依赖:
bash
mkdir rag-demo && cd rag-demo && pnpm init
pnpm add ai zod dotenv @ai-sdk/openai-compatible创建 .env 文件。两种接入方式二选一:
bash
# 方式一:Vercel AI Gateway(推荐)
AI_GATEWAY_API_KEY=your_gateway_api_key
# 方式二:自定义 OpenAI 兼容服务
# OPENAI_COMPATIBLE_BASE_URL=http://localhost:11434/v1
# OPENAI_COMPATIBLE_API_KEY=optional_if_not_needed统一在一个模块里构造模型,后续代码只依赖这里的导出:
ts
import { createGateway } from 'ai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel, EmbeddingModel } from 'ai';
export function getLanguageModel(): LanguageModel {
if (process.env.AI_GATEWAY_API_KEY) {
// 方式一:Vercel AI Gateway
const gateway = createGateway({
apiKey: process.env.AI_GATEWAY_API_KEY,
});
return gateway('openai/gpt-5');
}
// 方式二:自定义 OpenAI 兼容 Provider(同样适用于自定义 provider)
const myProvider = createOpenAICompatible({
name: 'my-provider',
baseURL: process.env.OPENAI_COMPATIBLE_BASE_URL ?? '',
apiKey: process.env.OPENAI_COMPATIBLE_API_KEY ?? '',
});
return myProvider('gpt-4o-mini'); // 按你的服务端实际模型名填写
}
export function getEmbeddingModel(): EmbeddingModel<string> {
if (process.env.AI_GATEWAY_API_KEY) {
const gateway = createGateway({
apiKey: process.env.AI_GATEWAY_API_KEY,
});
return gateway.textEmbeddingModel('openai/text-embedding-3-small');
}
const myProvider = createOpenAICompatible({
name: 'my-provider',
baseURL: process.env.OPENAI_COMPATIBLE_BASE_URL ?? '',
apiKey: process.env.OPENAI_COMPATIBLE_API_KEY ?? '',
});
return myProvider.textEmbeddingModel('text-embedding-3-small');
}💡 官方文档中的字符串写法
'openai/text-embedding-3-small'走默认 provider 解析;换成自定义 provider 时调用其实例的.textEmbeddingModel()即可,其余代码完全不变。
20.3 构建知识库:批量向量化
准备一份内存知识库,并用 embedMany 一次性向量化:
ts
import { embedMany } from 'ai';
import { getEmbeddingModel } from './provider';
// 内存知识库:生产环境替换为向量数据库
const knowledgeBase = [
'公司年假规定:入职满一年享有 10 天带薪年假,满三年 15 天。',
'报销流程:所有发票需在费用发生后 30 天内提交至 OA 系统。',
'VPN 使用:远程办公请连接 vpn.example.com,账号为邮箱前缀。',
'会议室预订:通过飞书日历预订,最长单次预订 2 小时。',
'密码策略:每 90 天更换一次,长度不少于 12 位且含特殊字符。',
];
export interface KnowledgeChunk {
text: string;
embedding: number[];
}
let cache: KnowledgeChunk[] | null = null;
export async function buildKnowledgeBase(): Promise<KnowledgeChunk[]> {
if (cache) return cache;
const { embeddings } = await embedMany({
model: getEmbeddingModel(),
values: knowledgeBase,
});
cache = knowledgeBase.map((text, i) => ({ text, embedding: embeddings[i] }));
return cache;
}要点:
embedMany的返回值embeddings是number[][],顺序与输入一致;- 用模块级
cache避免每次提问都重新向量化整库。
20.4 检索:相似度排序与阈值过滤
用户提问时先用 embed 向量化问题,再与知识库逐条算余弦相似度:
ts
import { cosineSimilarity, embed } from 'ai';
import { buildKnowledgeBase } from './knowledge-base';
import { getEmbeddingModel } from './provider';
const SIMILARITY_THRESHOLD = 0.55; // 过滤低相关片段
export async function retrieve(question: string, topK = 2): Promise<string[]> {
const { embedding: questionVector } = await embed({
model: getEmbeddingModel(),
value: question,
});
const kb = await buildKnowledgeBase();
return kb
.map((chunk) => ({
text: chunk.text,
score: cosineSimilarity(questionVector, chunk.embedding),
}))
.filter((item) => item.score >= SIMILARITY_THRESHOLD) // 阈值过滤
.sort((a, b) => b.score - a.score) // 降序排序
.slice(0, topK)
.map((item) => item.text);
}关键决策点:
| 参数 | 作用 | 取值建议 |
|---|---|---|
SIMILARITY_THRESHOLD | 过滤不相关片段,防止幻觉 | 0.4–0.7,按语料调试 |
topK | 最多注入多少条片段 | 2–5,过多会稀释注意力 |
20.5 生成回答:只基于检索内容作答
把检索结果作为 context 注入 prompt,并要求模型「不知道就说不知道」:
ts
import { generateText } from 'ai';
import { retrieve } from './retriever';
import { getLanguageModel } from './provider';
export async function answerQuestion(question: string): Promise<string> {
const contexts = await retrieve(question);
if (contexts.length === 0) {
return '抱歉,知识库中没有找到与此问题相关的信息。';
}
const { text } = await generateText({
model: getLanguageModel(),
system:
'你是公司内部助手。只能根据提供的参考资料回答问题,' +
'如果资料不足以回答,请明确说明。回答末尾列出引用的资料编号。',
prompt: `参考资料:
${contexts.map((c, i) => `[${i + 1}] ${c}`).join('\n')}
问题:${question}`,
});
return text;
}20.6 完整入口脚本
ts
import 'dotenv/config';
import * as readline from 'node:readline/promises';
import { answerQuestion } from './answer';
const terminal = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
while (true) {
const question = await terminal.question('你: ');
if (!question.trim() || question === 'exit') break;
console.log('助手:', await answerQuestion(question));
}
terminal.close();运行:
bash
npx tsx index.ts
# 你: 年假有几天?
# 助手: 入职满一年享有 10 天带薪年假,满三年 15 天。[1]本章小结
- RAG 三段式:
embed/embedMany向量化 →cosineSimilarity排序过滤 →generateText引用生成; - 通过统一的
provider.ts模块封装模型构造,Vercel AI Gateway 与自定义 OpenAI 兼容 Provider 可一键切换; embedMany返回值顺序与输入一致,适合批量构建知识库;- 阈值过滤 + 「资料不足就说明」的 system prompt 双管齐下抑制幻觉;
- 生产化路径明确:把内存数组替换为向量数据库(pgvector、Pinecone 等)即可。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. 在本章实现中,为什么 embedMany 的结果可以直接按索引与知识库条目对应?
2. 设置 SIMILARITY_THRESHOLD 阈值过滤的主要目的是什么?
3. 若要从 Vercel AI Gateway 切换到自定义 OpenAI 兼容 Provider,需要改动哪里?
4. 当 retrieve 返回空数组时,本章的实现选择直接返回固定话术而不是调用 LLM,原因是?
🛠️ 动手实践
- 给
retrieve增加「混合检索」:先按关键词includes()粗筛,再对候选集做向量精排,对比效果。 - 把知识库扩到 30 条以上,实验
topK从 1 到 5 的回答质量差异,记录你的观察。 - 为
answerQuestion增加流式输出版本:改用streamText并在终端实时打印textStream。