Skip to content

第 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 的返回值 embeddingsnumber[][]顺序与输入一致
  • 用模块级 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. retrieve 增加「混合检索」:先按关键词 includes() 粗筛,再对候选集做向量精排,对比效果。
  2. 把知识库扩到 30 条以上,实验 topK 从 1 到 5 的回答质量差异,记录你的观察。
  3. answerQuestion 增加流式输出版本:改用 streamText 并在终端实时打印 textStream