Skip to content

第4章 检索增强入门(RAG)

4.1 为什么需要检索增强

只依赖模型参数做问答,很快会遇到三个核心问题:

  • 回答可能不基于真实业务资料,容易出现幻觉。
  • 模型不知道企业私有知识、最新制度、内部流程和版本变更。
  • 即使回答看起来合理,也很难解释“依据来自哪里”。

检索增强(RAG)的价值,不只是“让回答更准”,更重要的是把回答建立在可追溯证据之上。 对企业级 Agent 来说,这意味着系统终于可以回答“为什么这么说”“这句话出自哪份制度”“资料不足时为什么要拒答”。

很多前端工程师第一次接触 RAG,会先把它理解成“向量库 + 模型接口”的组合。 但在真实业务里,RAG 更像一条完整的数据链路:

  • 文档怎么进入系统。
  • 文档怎么切片。
  • 检索为什么命中或没命中。
  • 为什么最终注入了这些片段。
  • 回答怎么展示引用和风险提示。

前端迁移提示

这一章和前端工程并不远,很多已有经验都能直接迁移:

  • 数据装配思维 -> 检索证据的上下文组织。
  • 列表/卡片展示 -> 回答卡片、引用展开、风险提示。
  • 筛选与排序思维 -> 检索召回、重排和结果裁剪。
  • 状态与反馈设计 -> answer / citations / confidence / need_human_review 结果结构展示。

最小项目目标

本章建议先完成一个“企业知识问答最小闭环”项目,例如:

  • 制度问答助手。
  • 产品文档问答助手。
  • 售后知识问答助手。

如果你想直接拿最小骨架来改,可以先看:

4.2 核心链路总览

RAG 链路图

第4章 RAG 链路图 RAG 不是单个步骤,而是一条由“文档进入 -> 证据召回 -> 结果生成 -> 引用回传 -> 评测验证”组成的完整链路。 如果其中任意一环做得模糊,最终就会出现“看起来像回答对了,但其实无法验证”的问题。

一、文档进入系统

第一步不是调用模型,而是先确定知识源:

  • 哪些文档允许进入知识库。
  • 文档版本是否可靠。
  • 是否有发布时间、文档类型、章节标题等元数据。
  • 是否存在旧文档与新文档冲突。

这一层如果没处理好,后面的检索和生成很难稳定。

二、检索是为了拿证据,不是为了凑上下文

检索的目标不是“尽量多拿一些文本给模型看”,而是“尽量拿到最相关、最可信、最容易被引用的证据”。 因此你要持续思考:

  • 召回的是不是对的文档。
  • 片段是不是太长或太碎。
  • 是否存在很多噪音内容。
  • 最终送进模型的上下文是不是过多。

三、生成必须和引用绑定

RAG 真正和普通问答拉开差距的地方,是回答不仅要有内容,还要有依据。 一个最小响应结构可以像这样:

json
{
  "answer": "根据当前制度,年假申请原则上需至少提前 3 个工作日提交。",
  "citations": [
    {
      "doc_id": "leave_policy_v2",
      "title": "员工休假制度 V2",
      "quote": "年假申请原则上需至少提前 3 个工作日提交。"
    }
  ],
  "confidence": "high",
  "need_human_review": false
}

你可以直接复用:

  • docs/examples/assets/samples/example-02-rag-answer.json

四、前端必须把“证据”和“风险”展示出来

很多 RAG Demo 最大的问题,不在后端,而在前端只展示了一段答案文本。 更合理的方式是至少展示以下信息:

  • 回答正文。
  • 引用来源。
  • 引用片段。
  • 置信度标签。
  • need_human_review 风险提示。
  • 用户反馈入口。

五、评测决定这条链路是否可交付

RAG 项目最容易出现的错觉是“这个问题看起来答对了,所以系统应该没问题”。 真实工程里必须用评测集回答:

  • 命中率是否稳定。
  • 引用是否准确。
  • 不可回答场景是否能拒答。
  • 同一套配置是否可复现。

4.3 文档处理与切片

很多团队一开始会把 RAG 的重点放在模型或向量库上,但真实效果经常更受“文档处理质量”影响。 如果文档混乱、过时、没有层级、切片策略糟糕,即使后面的检索和生成都很努力,最终效果也不会稳定。

一、先处理文档质量,再谈检索质量

建议先检查:

  • 文档是否有清晰标题层级。
  • 是否能区分正式制度与临时说明。
  • 是否存在过期文档。
  • 是否保留版本号和更新时间。

二、切片目标是“既能召回,又能引用”

切片不是越短越好,也不是越长越好。 最小原则是:

  • 一条片段要足够表达一个完整事实。
  • 一条片段又不能长到混入太多无关信息。
  • 切片后仍要保留文档标题、章节路径、版本信息等元数据。

三、推荐的第一版切片策略

对于初学者,建议先用简单可靠的策略:

  • 按标题和段落切片。
  • 控制单片段长度在一个可阅读范围内。
  • 每个片段附带 doc_id / title / section / updated_at 等字段。

四、切片之后要保留可追溯元数据

因为后续回答中的引用、版本判断和前端展示都依赖元数据。 如果切片时把这些信息丢了,后面很难补回来。

4.4 检索与重排

检索增强项目里,最常见的问题不是“模型不够强”,而是“没把最相关的证据送到模型面前”。 因此检索阶段的重点是召回质量,而不是盲目堆更多片段。

一、检索的第一目标是命中对的文档

你要先回答:

  • 用户问题对应的核心资料是否被召回了。
  • 新版本制度是否优先于旧版本。
  • 同义表达或不同问法是否仍能命中同一知识点。

二、重排是减少噪音而不是增加复杂度

当召回结果变多时,常见问题是:

  • 正确文档在前几名之外。
  • 有很多语义相近但不关键的片段。
  • 上下文被低相关证据稀释。

这时重排的价值是把“最值得进入模型上下文的证据”放到前面,而不是让系统更花哨。

三、第一版最值得关注的 3 个参数

对于最小项目,建议优先关注:

  • top_k:召回多少条。
  • 切片粒度:一条片段有多长。
  • 是否启用重排或简单排序策略。

如果一开始同时动 6 到 8 个变量,后面几乎无法判断哪个因素真正生效。

四、前端也应该参与检索质量判断

前端不只是展示结果,也可以帮助发现问题:

  • 引用来源总是跑偏。
  • 引用片段太长,用户看不懂。
  • 相似问题下引用不稳定。
  • 风险提示显示过多或过少。

这些都是检索链路质量的外在表现。

4.5 生成与引用

RAG 不是“检索完就把结果交给模型自由发挥”。 真正稳定的系统,需要对生成层进行清晰约束,确保模型基于证据回答,并把证据带回给前端和用户。

一、回答必须围绕证据生成

推荐约束模型:

  • 只能基于给定证据回答。
  • 证据不足时明确说明无法确认。
  • 回答要简洁,不要为了显得智能而扩写无依据内容。

二、引用展示要对用户有用

引用不是“挂一个文档名”就够了。 更有用的做法是:

  • 显示文档标题。
  • 提供引用片段。
  • 必要时显示章节路径或版本信息。

三、加入置信度和人工复核标记

对于企业场景,建议在最小结构里保留:

  • confidence:高 / 中 / 低。
  • need_human_review:是否建议人工复核。

你可以直接参考:

四、前端回答卡片建议

建议最少包含这些区域:

  • 回答正文。
  • 引用来源列表。
  • 置信度标签。
  • 人工复核提示。
  • “是否有帮助”的反馈入口。

4.6 RAG 管道代码实战(TypeScript)

前面几节讲了 RAG 链路的各个阶段和设计原则,这一节用可运行的 TypeScript 代码把完整流程串起来。 如果你习惯前端技术栈,这些代码可以直接在 Node.js 环境运行,帮助你建立对 RAG 管道的直觉。

前端开发者知识桥梁

在看代码之前,先把 RAG 中几个核心概念映射到你熟悉的前端场景:

RAG 概念前端类比说明
Embedding把文本转成「特征向量」类似图片转 base64,但编码的是语义信息而不是像素
余弦相似度计算两个向量方向是否一致1 = 完全相同,0 = 无关,-1 = 完全相反
语义检索搜索组件的后端实现搜索的是语义而不是关键词匹配
文本切片分页 / 虚拟列表把长文档拆成小块,每块独立处理,避免超出上下文窗口
上下文组装接口聚合 / BFF 层把多个检索结果拼装成一份完整的 Prompt 交给模型

一、最小 RAG 管道(纯内存实现)

这个版本不需要任何数据库,所有向量存在内存里,适合理解流程和本地实验。

前置准备:

bash
# 初始化项目
mkdir mini-rag && cd mini-rag
npm init -y
npm install openai typescript tsx
npx tsc --init

# 设置 API Key(也可以写在 .env 里)
export OPENAI_API_KEY="sk-your-key-here"

# 准备一份知识库文件(用你自己的文档替换)
echo "React Router 支持多种路由模式,包括 BrowserRouter 和 HashRouter。
BrowserRouter 使用 HTML5 History API,适合生产环境。
HashRouter 使用 URL 的 hash 部分,适合静态部署。
配置路由时需要在根组件中包裹 Router 组件,然后用 Route 定义路径映射。
嵌套路由通过在父路由组件中渲染 Outlet 来实现。
动态路由参数使用 :paramName 语法,通过 useParams Hook 获取。" > knowledge-base.txt

完整代码:

typescript
// mini-rag.ts — 最小 RAG 管道,无需数据库
import OpenAI from 'openai'
import { readFileSync } from 'fs'

const client = new OpenAI()

// 步骤 1:文本切片
function splitText(text: string, chunkSize = 500, overlap = 50): string[] {
  const chunks: string[] = []
  for (let i = 0; i < text.length; i += chunkSize - overlap) {
    chunks.push(text.slice(i, i + chunkSize))
  }
  return chunks
}

// 步骤 2:获取 Embedding 向量
async function getEmbeddings(texts: string[]): Promise<number[][]> {
  const res = await client.embeddings.create({
    model: 'text-embedding-3-small',
    input: texts,
  })
  return res.data.map(d => d.embedding)
}

// 步骤 3:余弦相似度计算
function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0, normA = 0, normB = 0
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i]
    normA += a[i] * a[i]
    normB += b[i] * b[i]
  }
  return dot / (Math.sqrt(normA) * Math.sqrt(normB))
}

// 步骤 4:检索最相关的片段
async function retrieve(query: string, chunks: string[], embeddings: number[][], topK = 3) {
  const [queryEmb] = await getEmbeddings([query])
  const scored = chunks.map((chunk, i) => ({
    chunk,
    score: cosineSimilarity(queryEmb, embeddings[i]),
  }))
  return scored.sort((a, b) => b.score - a.score).slice(0, topK)
}

// 步骤 5:组装上下文并生成回答
async function ragAnswer(query: string, context: string[]): Promise<string> {
  const res = await client.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      {
        role: 'system',
        content: `基于以下参考资料回答用户问题。如果资料中没有相关信息,请说明。

参考资料:
${context.map((c, i) => `[${i + 1}] ${c}`).join('\n\n')}`
      },
      { role: 'user', content: query }
    ],
    temperature: 0,
  })
  return res.choices[0].message.content!
}

// 完整流程
async function main() {
  // 加载文档(实际项目中可以是 API 文档、产品手册等)
  const text = readFileSync('knowledge-base.txt', 'utf-8')
  const chunks = splitText(text)
  console.log(`文档切分为 ${chunks.length} 个片段`)

  // 预计算所有片段的 Embedding
  const embeddings = await getEmbeddings(chunks)
  console.log('Embedding 计算完成')

  // 检索 + 生成
  const query = '如何配置 React 项目的路由?'
  const results = await retrieve(query, chunks, embeddings)
  console.log(`检索到 ${results.length} 个相关片段,最高相似度: ${results[0].score.toFixed(3)}`)

  const answer = await ragAnswer(query, results.map(r => r.chunk))
  console.log('\n回答:', answer)
}

main()

运行方式:

bash
npx tsx mini-rag.ts

预期输出:

文档切分为 2 个片段
Embedding 计算完成
检索到 2 个相关片段,最高相似度: 0.847
回答: 配置 React 项目的路由需要以下步骤:...

二、使用 Vercel AI SDK 的 RAG 实现

上面的版本直接调用 OpenAI SDK,下面这个版本使用 Vercel AI SDK,API 更简洁,更贴近前端开发者的使用习惯。

安装依赖:

bash
npm install ai @ai-sdk/openai

完整代码:

typescript
// rag-with-ai-sdk.ts — 使用 Vercel AI SDK,更贴近前端习惯
import { generateText, embed, embedMany } from 'ai'
import { openai } from '@ai-sdk/openai'
import { readFileSync } from 'fs'

// 文本切片(与上一版相同)
function splitText(text: string, chunkSize = 500, overlap = 50): string[] {
  const chunks: string[] = []
  for (let i = 0; i < text.length; i += chunkSize - overlap) {
    chunks.push(text.slice(i, i + chunkSize))
  }
  return chunks
}

// 余弦相似度(与上一版相同)
function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0, normA = 0, normB = 0
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i]
    normA += a[i] * a[i]
    normB += b[i] * b[i]
  }
  return dot / (Math.sqrt(normA) * Math.sqrt(normB))
}

const embeddingModel = openai.embedding('text-embedding-3-small')

async function main() {
  const text = readFileSync('knowledge-base.txt', 'utf-8')
  const chunks = splitText(text)

  // AI SDK 的批量 Embedding 接口,比手动调 REST 更简洁
  const { embeddings } = await embedMany({
    model: embeddingModel,
    values: chunks,
  })

  const query = '如何配置 React 项目的路由?'

  // 单条 Embedding
  const { embedding: queryEmbedding } = await embed({
    model: embeddingModel,
    value: query,
  })

  // 检索
  const scored = chunks
    .map((chunk, i) => ({ chunk, score: cosineSimilarity(queryEmbedding, embeddings[i]) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 3)

  console.log(`最高相似度: ${scored[0].score.toFixed(3)}`)

  // 生成回答 — generateText 是 AI SDK 的核心 API
  const { text: answer } = await generateText({
    model: openai('gpt-4o-mini'),
    system: `基于以下参考资料回答用户问题。如果资料中没有相关信息,请说明。

参考资料:
${scored.map((s, i) => `[${i + 1}] ${s.chunk}`).join('\n\n')}`,
    prompt: query,
    temperature: 0,
  })

  console.log('\n回答:', answer)
}

main()

两个版本的核心流程完全一致:切片 → Embedding → 检索 → 生成。区别在于 AI SDK 把模型调用封装得更简洁,如果你的项目已经在用 Next.js + Vercel 技术栈,推荐直接用这个版本。

三、进阶:接入向量数据库(选读)

上面两个版本把向量存在内存里,适合学习和小规模实验。当文档量增大(几千到几万条片段),内存方案会遇到以下问题:

  • 每次启动都要重新计算 Embedding,浪费时间和 API 费用。
  • 内存占用随文档量线性增长。
  • 无法做持久化和增量更新。

这时候就需要引入向量数据库。以下是几个适合前端开发者上手的选项:

向量数据库适用场景特点
ChromaDB本地开发、原型验证开箱即用,支持 JS/TS 客户端,可嵌入运行
Pinecone生产环境、托管服务全托管,无需运维,按用量计费
Weaviate生产环境、自部署开源可自部署,支持混合检索(向量 + 关键词)

ChromaDB 接入示例

ChromaDB 是本地开发中常见且上手友好的向量数据库选项,以下是从内存方案迁移到 ChromaDB 的关键代码:

安装:

bash
# 安装 ChromaDB JS 客户端
npm install chromadb

# 本地启动 ChromaDB 服务(需要 Docker 或 Python)
# 方式一:Docker(推荐)
docker run -p 8000:8000 chromadb/chroma

# 方式二:pip
pip install chromadb
chroma run --path ./chroma-data

接入代码:

typescript
// rag-with-chroma.ts — 使用 ChromaDB 持久化向量
import { ChromaClient } from 'chromadb'
import OpenAI from 'openai'
import { readFileSync } from 'fs'

const chroma = new ChromaClient({ path: 'http://localhost:8000' })
const openai = new OpenAI()

async function indexDocuments() {
  const text = readFileSync('knowledge-base.txt', 'utf-8')
  const chunks = text.split('\n').filter(line => line.trim().length > 0)

  // 创建或获取集合(类似数据库中的"表")
  const collection = await chroma.getOrCreateCollection({ name: 'knowledge-base' })

  // 添加文档 — ChromaDB 会自动调用内置的 Embedding 模型
  // 也可以传入自己计算的 embeddings 参数
  await collection.add({
    ids: chunks.map((_, i) => `chunk-${i}`),
    documents: chunks,
    metadatas: chunks.map((_, i) => ({ source: 'knowledge-base.txt', index: i })),
  })

  console.log(`已索引 ${chunks.length} 个片段到 ChromaDB`)
}

async function queryWithChroma(question: string) {
  const collection = await chroma.getCollection({ name: 'knowledge-base' })

  // ChromaDB 会自动计算查询的 Embedding 并执行相似度检索
  const results = await collection.query({
    queryTexts: [question],
    nResults: 3,
  })

  const context = results.documents[0] ?? []
  console.log(`检索到 ${context.length} 个相关片段`)

  // 用检索结果生成回答(与之前逻辑相同)
  const res = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      {
        role: 'system',
        content: `基于以下参考资料回答用户问题。如果资料中没有相关信息,请说明。

参考资料:
${context.map((c, i) => `[${i + 1}] ${c}`).join('\n\n')}`
      },
      { role: 'user', content: question }
    ],
    temperature: 0,
  })

  return res.choices[0].message.content!
}

async function main() {
  await indexDocuments()
  const answer = await queryWithChroma('如何配置 React 项目的路由?')
  console.log('\n回答:', answer)
}

main()

从内存方案迁移到向量数据库的核心变化:

  1. 存储number[][] 数组 → ChromaDB collection,向量持久化到磁盘。
  2. 检索:手写 cosineSimilarity 循环 → collection.query(),数据库内部做高效近似最近邻搜索。
  3. 更新:每次全量重算 → 增量 collection.add() / collection.update()
  4. 元数据:手动维护 → 和向量一起存储在 metadatas 字段,检索时可按元数据过滤。

建议:先用内存方案跑通流程,确认检索效果符合预期后,再迁移到向量数据库。不要一开始就引入额外基础设施。

4.7 评测与验收

RAG 一旦进入真实业务,就必须从“可演示”走向“可验证”。 因此这一节的核心不是怎么写一个更漂亮的回答,而是怎么证明当前链路是否站得住。

一、最小评测集应该覆盖三类样本

建议至少准备:

  • 正常问答样本。
  • 边界问答样本。
  • 应该拒答的样本。

你可以直接从:

  • docs/guide/assets/ch04-eval-template.jsonl 开始。

二、最小评测结果至少包含三项指标

  • 命中率。
  • 引用准确率。
  • 不可回答场景拒答率。

如果你要展示结果结构,可以直接复用:

  • docs/examples/assets/samples/example-02-eval-result.json

三、最小执行命令应该可落地

这一章保留一个可执行命令,是为了让章节和工程实践真正挂钩:

  • python3 scripts/eval_rag.py --input docs/guide/assets/ch04-eval-template.jsonl --output outputs/ch04-eval-result.json

四、评测不只是给后端看的

前端也应该使用评测结果来优化体验,例如:

  • 哪类问题经常需要人工复核。
  • 哪类问题引用展示不清晰。
  • 哪些拒答场景应该给更好的提示文案。

实战案例:企业财务制度问答

  • 样例 JSON:docs/guide/assets/ch04-case-sample.json
  • 输入:用户提问“差旅住宿报销上限是多少?”
  • 流程:上传文档 -> 切片 -> 向量检索 -> 重排 -> 生成答案并携带引用来源
  • 输出:答案文本 + 文档名 + 段落片段 + 置信度

证据材料

  • 成功回答样本:docs/examples/assets/samples/example-02-rag-answer.json
  • 评测结果样本:docs/examples/assets/samples/example-02-eval-result.json
  • 截图清单:docs/examples/assets/screenshots/README.md
  • 图解配图:docs/examples/assets/diagrams/chapter-04-rag-flow.svg
  • 章节代码片段索引

4.8 练习任务

  1. 运行 mini-rag.ts,用你自己的文档替换 knowledge-base.txt,观察不同切片大小(200 / 500 / 1000)对检索结果的影响。
  2. 选一个最小知识场景,定义至少 10 条正常问答和 5 条拒答问答样本。
  3. 基于 docs/guide/assets/ch04-eval-template.jsonl 增补一份属于你场景的评测样本。
  4. 设计一个最小回答结构,至少包含 answer / citations / confidence / need_human_review 四个字段。
  5. 参考 示例2 RAG接口与评测,实现一个最小 RAG 接口返回结构。
  6. 设计前端回答卡片,并说明引用、置信度和人工复核提示分别展示在哪里。
  7. (进阶)尝试用 ChromaDB 替换内存存储,对比启动时间和检索效果。

4.9 验收清单

  • 命中率 >= 80%
  • 引用准确率 >= 85%
  • 不可回答场景拒答率 >= 90%
  • 至少完成 1 个带引用的最小 RAG 问答闭环。
  • 至少保留 1 份回答样本、1 份评测结果样本和 1 组截图占位清单。
  • 至少能说明“为什么这个系统不是普通聊天,而是建立在证据上的问答系统”。

评测执行说明(三步):

  1. 读取 docs/guide/assets/ch04-eval-template.jsonl,逐行解析为评测输入(case_idquestionexpected_answerexpected_sourceshould_reject)。
  2. 运行评测命令并产出逐条结果,例如:python3 scripts/eval_rag.py --input docs/guide/assets/ch04-eval-template.jsonl --output outputs/ch04-eval-result.json
  3. 汇总命中率、引用准确率、不可回答场景拒答率,按 80/85/90 阈值判定是否通过(分别需 >=80%、>=85%、>=90%)。

4.10 面试表达模板

我们在企业知识问答场景中引入检索增强,让答案建立在可追溯证据上,而不是只依赖模型参数记忆。 通过文档处理、切片、检索、重排、引用回传和评测闭环,我们把系统从“会回答问题”升级成“能基于证据回答并可验证”的工程系统。

推荐答题框架

如果面试官问“你做的 RAG 和普通问答有什么区别”,可以按下面顺序回答:

  1. 先讲为什么企业知识不能只靠模型参数。
  2. 再讲文档处理、检索、生成和引用是如何串起来的。
  3. 再讲你怎么验证:命中率、引用准确率、拒答率。
  4. 最后讲前端如何把证据和风险展示给用户。

4.11 💡 自托管 RAG — BGE-M3 + ChromaDB 本地全链路

🏠 自托管替代方案

本章 RAG 管道使用 OpenAI Embedding API,但企业场景下数据往往不能发送到第三方。以下介绍如何用开源模型搭建完全本地化的 RAG 管道。

为什么 Embedding 也要本地化

风险说明
数据泄露发送到 OpenAI 的文本可能包含敏感业务数据
合规要求金融/医疗/政务数据不允许出境
持续成本大规模文档 Embedding 的 API 费用不可忽视
离线需求内网环境无法访问外部 API

本地 Embedding 选型

模型维度多语言部署方式推荐场景
BGE-M31024Ollama通用首选
Nomic-Embed-Text768Ollama轻量快速
jina-embeddings-v31024Docker长文本

本地 RAG 全链路代码

typescript
// Step 1: 本地 Embedding
async function localEmbed(text: string): Promise<number[]> {
  const res = await fetch('http://localhost:11434/api/embed', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ model: 'bge-m3', input: text }),
  });
  const data = await res.json();
  return data.embeddings?.[0] ?? [];
}

// Step 2: 存入 ChromaDB
import { ChromaClient } from 'chromadb';
const chroma = new ChromaClient({ path: 'http://localhost:8000' });
const collection = await chroma.getOrCreateCollection({ name: 'docs' });

// 批量存储文档
for (const doc of documents) {
  const embedding = await localEmbed(doc.content);
  await collection.add({
    ids: [doc.id],
    embeddings: [embedding],
    documents: [doc.content],
    metadatas: [{ source: doc.source }],
  });
}

// Step 3: 检索 + 本地模型生成
async function ragQuery(question: string) {
  const queryEmbedding = await localEmbed(question);
  const results = await collection.query({
    queryEmbeddings: [queryEmbedding],
    nResults: 3,
  });
  
  const context = results.documents?.[0]?.join('\n\n') ?? '';
  
  const res = await fetch('http://localhost:11434/v1/chat/completions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'qwen3:8b',
      messages: [
        { role: 'system', content: `基于以下资料回答问题:\n${context}` },
        { role: 'user', content: question },
      ],
    }),
  });
  return res.json();
}

本地 RAG vs 云端 RAG trade-off

维度云端 RAG本地 RAG
延迟网络延迟 + API 处理纯本地,通常更快
成本按 token 计费硬件一次投入
精度取决于模型与任务需基于你的评测集实测,常见场景可接近
运维零运维需要维护 Ollama + ChromaDB
扩展自动扩展受限于本地硬件

🖥️ 前端迁移提示:本地 RAG 就像 SSG(静态站点生成)vs SSR(服务端渲染)——把计算从云端搬到本地,换取更低延迟和更好的数据控制。

前置准备

bash
# 拉取 Embedding 模型
ollama pull bge-m3

# 启动 ChromaDB
docker run -p 8000:8000 chromadb/chroma

深入阅读

完整的自托管 RAG 架构设计和生产优化,请阅读 专题 11:自托管 AI Agent 全栈指南项目线 D:全栈自托管 Agent