🔥 面试被问 RAG 只能说出六个字?这篇用 100 行 Node.js 代码帮你彻底搞懂
摘要:面试被问"什么是 RAG",只能说出"检索增强生成"六个字?本文不讲百科罗列,只讲你最该掌握的核心原理。从 Naive RAG 出发,带你用 100 行 Node.js 代码跑通完整流程,再沿着五个优化方向逐步升级到生产级方案。建议先收藏,再慢慢看~
📌 前言:你为什么需要了解 RAG?
上周面试,面试官问:"你了解 RAG 吗?"
我自信回答:"Retrieval-Augmented Generation,检索增强生成!"
面试官点点头:"那它的核心流程是什么?和长上下文有什么区别?生产环境怎么优化检索质量?"
我:阿巴阿巴......
后来我花了两周系统梳理了 RAG 的知识体系,踩了不少坑,也总结出一套从原理到实战的完整路径。这篇文章就是我的学习笔记,希望能帮到和我一样曾经"只会说六个字"的同学。
大模型的三个硬伤
RAG 为什么存在?因为大模型有三个先天缺陷:
硬伤一:知识有截止日期。训练数据有时间边界,问"2026 年最新的框架",AI 可能完全不知道。
硬伤二:会一本正经地胡说八道。这就是"幻觉"(Hallucination)------问一个不存在的 API,AI 编得有模有样,你信了就踩坑了。
硬伤三:缺乏私有知识。问"公司的请假流程",AI 完全答不上来,因为它从来没看过你的内部文档。
RAG 的核心思想就一句话:先搜再答。让 AI 在回答之前先去"翻书"找参考资料,再基于搜到的知识组织答案。就跟开卷考试一样------遇到不会的先翻书,再根据书里的知识答题。
RAG 和长上下文是什么关系?
有同学会问:现在大模型都支持百万 token 上下文了,还需要 RAG 吗?
需要,而且用得比以前更多了! 原因有两个:
- 成本:把所有文档塞进上下文窗口,token 费用会非常高
- Lost in the Middle:大模型对超长上下文中间部分的注意力会明显下降。就像听别人说话,开头和结尾印象深,中间容易忘
最佳实践是两者互补:先用 RAG 提供精确资料,再利用长上下文做分析推理。
Part 1:RAG 核心原理------从 100 行代码说起
不讲伪代码,直接看真实可运行的 Node.js 实现。看完这 100 行代码,你就理解了 RAG 的核心原理。
一个最简单的 RAG 是怎么工作的
整个流程分为两个阶段:
离线索引(提前准备):把文档切成小块 → 每块转成向量 → 存起来
在线查询(用户提问时):问题转成向量 → 找最相似的文档块 → 拼成 Prompt 给 AI
用代码来说就是:
javascript
// ============ 离线索引阶段 ============
// 1. 读取原始数据
const posts = JSON.parse(await fs.readFile('./data/posts.json', 'utf-8'));
// 2. 每篇文章转成向量(调用 Embedding API)
for (const { title, category } of posts) {
const response = await client.embeddings.create({
model: "text-embedding-v4",
input: `标题: ${title},分类: ${category}`, // 拼接分类,语义更丰富
});
postsWithEmbedding.push({
title, category,
embedding: response.data[0].embedding, // 1536 维向量
});
}
// ============ 在线查询阶段 ============
// 1. 用户问题也转成向量
const { embedding } = (await client.embeddings.create({
model: "text-embedding-v4", input: userQuery
})).data[0];
// 2. 计算余弦相似度,找 Top 3
const result = posts
.map(item => ({ ...item, similarity: cosineSimilarity(item.embedding, embedding) }))
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3);
三个关键环节拆解
| 环节 | 做什么 | 关键技术 |
|---|---|---|
| 分块 | 把长文档切成小段 | chunk_size、chunk_overlap |
| 向量化 | 文字变数字 | Embedding 模型(如 text-embedding-v4) |
| 检索+生成 | 找相似文档,拼成 Prompt | 余弦相似度 + LLM |
我踩过的坑
坑 1:API 限流 。批量处理 35 篇文章时,跑到第 10 篇就报 429 Too Many Requests。解决:每处理一条后 sleep(200)。
坑 2:语义不够丰富。一开始 input 只写了标题,搜索"React 组件"返回了不相关的文章。解决:把标题和分类拼在一起,让模型理解更多上下文。
坑 3:向量维度理解错误 。一开始以为向量越短越好,后来才知道 text-embedding-v4 生成 1536 维向量是固定的,维度越高语义表达越精细。
Part 2:从 Naive 到生产级------五个关键优化方向
Naive RAG 能跑通,但离生产环境还差得远。我在实际项目中总结出五个优化方向,每个方向对应 2-3 种具体方案。
方向一:检索质量优化
问题 :纯向量检索无法精确匹配术语(如 ERROR_CODE_4012),传统关键词搜索不理解语义。
方案:Hybrid Search(混合检索)
同时跑两路检索------向量搜索负责语义理解,BM25 负责精确匹配,通过 RRF 算法合并结果:
python
# 两路检索各自召回 Top 20
semantic_results = vector_store.search(embed(query), k=20)
keyword_results = bm25_index.search(query, k=20)
# RRF 融合:score += 1 / (60 + rank)
for each doc in (semantic_results ∪ keyword_results):
if doc in semantic_results: score += 1 / (60 + rank_in_semantic)
if doc in keyword_results: score += 1 / (60 + rank_in_keyword)
💡 几乎所有生产环境都建议用 Hybrid Search 替代纯向量搜索,尤其是技术文档、医疗、法律等术语密集的领域。
方案:Reranking(精排)
检索回来的候选文档里总会混着噪声。Reranking 在检索和生成之间加一个精排步骤:用 Reranker 模型给每对 (query, doc) 重新打分。
Embedding 模型是分别给 query 和 doc 算向量再比较距离,快但粗糙 ;Reranker 是把它们拼在一起送进模型打分,慢但精准。
生产级方案是分层筛选:粗检索(150 条)→ 轻量 Reranker 初筛(20 条)→ Cross-Encoder 精排(Top 5)→ 生成回答。
🔑 Hybrid Search + Reranking 是大多数生产级 RAG 系统的基础配置,我实测后检索准确率从 72% 提升到了 89%。
方向二:查询理解优化
问题:用户提问方式千奇百怪。想知道公司报销流程,有人问"怎么报销",有人问"花了钱怎么找公司要回来"。措辞和文档差距大,向量检索就可能搜不到。
方案:Multi-Query(多查询改写)
让大模型把原始问题改成多种不同的表述,分别去搜,最后合并去重:
python
queries = LLM.generate("请将以下问题改写成 3 个不同的表述:" + user_query)
# 例如: ["报销流程是什么", "费用审批怎么操作", "如何提交报销申请"]
适合面向普通用户的客服、电商等口语化场景。代价是多调用一次 LLM + N 次向量检索。
方案:HyDE(假设性文档嵌入)
用户的问题往往很短(如"KV Cache 是什么?"),但文档里是一大段技术解释。一短一长,在向量空间中可能离得很远。
HyDE 的做法:让大模型先凭空写一个"假答案"(不必完全准确),然后用假答案的向量去检索。因为假答案和真文档的文体更接近,命中率更高。
风险:如果 LLM 编的方向跑偏,检索结果会更差。冷门领域慎用。
方向三:生成质量优化
问题:搜到了垃圾文档,大模型还是会一本正经地基于垃圾内容生成答案。就像开卷考试带错了书,还照着抄了上去。
方案:CRAG(纠错 RAG)
在检索和生成之间插一个质检员,逐个审查文档是否和问题相关:
- 打分高 → 资料靠谱,直接生成
- 打分低 → 内部知识库没找到,回退到 Web 搜索兜底
- 打分模糊 → 两边结果合一起送进去
方案:Self-RAG(自反思 RAG)
CRAG 只在检索阶段做质检,但生成阶段也可能产生幻觉。Self-RAG 设置四个检查点:
- 这个问题需要检索吗?
- 检索到的文档相关吗?
- 我的回答有文档支撑吗?(价值最高)
- 这个答案对用户有用吗?
第三个检查点最实用------能在生成阶段拦截幻觉。
方向四:数据结构优化
问题:前面的方法都假设答案落在某一个文档块里。但有些问题需要跨文档多跳推理。
文档 A:"张三是 AI 部门的负责人" 文档 B:"AI 部门属于技术中心" 问:"张三属于哪个中心?" → 需要串联推理
方案:GraphRAG(知识图谱 RAG)
微软 2024 年提出。用 LLM 从文档中抽取实体和关系,构建知识图谱,再基于图谱检索和推理。
⚠️ 我曾尝试用 GraphRAG 做企业知识库,结果发现图谱构建成本远超预期(每篇文档都要调 LLM 抽取实体),最终放弃了。简单事实查询没必要用,只有需要多跳推理时才值得投入。
方案:Text-to-SQL RAG
如果数据本身就是结构化表格,对表格做 Embedding 非常低效。让 LLM 直接把自然语言翻译成 SQL,执行查询,再把结果作为上下文回答。
方向五:架构扩展优化
问题:每种方案都有擅长的场景,但真实系统中这些场景可能同时存在。
方案:Agentic RAG
让一个 AI Agent 自主调度,配备一组检索工具(向量检索、Web 搜索、SQL 查询),它会自己决定用什么方式搜集信息。知名 AI 编程工具 Cursor 用的就是这种方式。
方案:Multi-Agent RAG
单个 Agent 同时兼顾理解意图、选择策略、验证质量,Prompt 太复杂。拆分为多个专职 Agent------Router Agent 负责分发、各 RAG Agent 负责对应领域、Verification Agent 负责质检。
Part 3:方案选型决策树
看完五个方向,你可能已经晕了。我到底该用哪个?
别慌,我帮你梳理了一个决策路径:
sql
你的数据是什么类型?
├── 非结构化文本(文档、文章、手册)
│ ├── 追求基本可用 → Naive RAG
│ ├── 用户提问口语化 → + Multi-Query / HyDE
│ ├── 术语密集 → + Hybrid Search
│ └── 不能容忍幻觉 → + CRAG / Self-RAG
├── 结构化表格(销售数据、报表)
│ └── Text-to-SQL RAG
├── 关系网络(需要多跳推理)
│ └── GraphRAG
└── 多种类型混合
└── Agentic RAG
方案对比速查表
| 方案 | 实现难度 | 适合场景 | 我的推荐指数 |
|---|---|---|---|
| Naive RAG | ⭐ | 入门学习、MVP 验证 | ⭐⭐⭐⭐⭐ |
| Multi-Query | ⭐⭐ | 客服、电商等口语化场景 | ⭐⭐⭐⭐ |
| HyDE | ⭐⭐ | 短问题查长文档 | ⭐⭐⭐ |
| Hybrid Search | ⭐⭐⭐ | 生产环境必备 | ⭐⭐⭐⭐⭐ |
| Reranking | ⭐⭐⭐ | 语料库 > 10 万条 | ⭐⭐⭐⭐ |
| CRAG / Self-RAG | ⭐⭐⭐⭐ | 医疗、法律等高准确率场景 | ⭐⭐⭐⭐ |
| GraphRAG | ⭐⭐⭐⭐⭐ | 跨文档多跳推理 | ⭐⭐⭐ |
| Agentic RAG | ⭐⭐⭐⭐⭐ | 多数据源混合 | ⭐⭐⭐⭐ |
🔑 给初学者的建议:先跑通 Naive RAG,发现哪个环节出了问题,就针对性地加入对应的优化方案。千万别一上来就搞 Multi-Agent + GraphRAG + 多模态全家桶,不仅实现成本高,效果也不一定更好。
Part 4:完整代码实现
技术栈选型
| 技术 | 选择 | 原因 |
|---|---|---|
| 运行时 | Node.js | 前端工程师友好,ES Modules 原生支持 |
| AI 模型 | 通义千问 text-embedding-v4 | 国内访问稳定,免费额度充足 |
| API 协议 | OpenAI SDK 兼容 | 一套代码切换多个模型服务商 |
| 数据存储 | JSON 文件 | Demo 简单够用,生产环境换向量数据库 |
项目结构
bash
posts-demo/
├── app.service.mjs # OpenAI 客户端封装(复用)
├── create-embedding.mjs # 步骤1:将文章数据向量化
├── semantic-search.mjs # 步骤2:实现语义搜索
├── data/
│ ├── posts.json # 原始文章数据(35 篇)
│ └── posts-embedding.json # 向量化后的数据
├── .env # API Key 配置
└── package.json
第一步:初始化项目
bash
mkdir rag-demo && cd rag-demo
pnpm init
pnpm add openai dotenv
创建 .env 文件:
env
# 通义千问 API Key(去 https://dashscope.console.aliyun.com/ 申请)
DASHSCOPE_API_KEY=sk-xxxx你的key
# 兼容 OpenAI 的接口地址
DASHSCOPE_API_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
第二步:封装 OpenAI 客户端
javascript
// app.service.mjs
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();
// 导出 client 供其他模块复用
export const client = new OpenAI({
apiKey: process.env.DASHSCOPE_API_KEY,
baseURL: process.env.DASHSCOPE_API_BASE_URL,
});
第三步:将文章向量化
javascript
// create-embedding.mjs
import fs from 'fs/promises';
import { client } from './app.service.mjs';
const posts = JSON.parse(await fs.readFile('./data/posts.json', 'utf-8'));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const postsWithEmbedding = [];
for (const { title, category } of posts) {
const response = await client.embeddings.create({
model: "text-embedding-v4",
input: `标题: ${title},分类: ${category}`,
});
postsWithEmbedding.push({
title, category,
embedding: response.data[0].embedding,
});
await sleep(200); // 防止 API 限流
}
await fs.writeFile('./data/posts-embedding.json',
JSON.stringify(postsWithEmbedding, null, 2));
bash
node create-embedding.mjs
第四步:实现语义搜索
javascript
// semantic-search.mjs
import fs from 'fs/promises';
import { client } from './app.service.mjs';
import readline from 'readline';
const posts = JSON.parse(await fs.readFile('./data/posts-embedding.json', 'utf-8'));
// 余弦相似度:衡量两个向量的"方向"是否一致
const cosineSimilarity = (v1, v2) => {
const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
return dotProduct / (lengthV1 * lengthV2);
};
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const handleInput = async (answer) => {
console.log(`\n🔍 搜索: "${answer}"\n`);
// 1. 问题向量化
const { embedding } = (await client.embeddings.create({
model: "text-embedding-v4", input: answer
})).data[0];
// 2. 相似度排序,取 Top 3
const result = posts
.map(item => ({ ...item, similarity: cosineSimilarity(item.embedding, embedding) }))
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3)
.map((item, i) => `${i + 1}. [${item.similarity.toFixed(4)}] ${item.title} (${item.category})`)
.join('\n');
console.log(result);
rl.question('\n请输入你要搜索的内容: ', handleInput);
};
rl.question('\n请输入你要搜索的内容: ', handleInput);
bash
node semantic-search.mjs
运行效果
ini
请输入你要搜索的内容: Vue状态管理
🔍 搜索: "Vue状态管理"
1. [0.9234] 如何在 Vue.js 中使用 Vuex 进行状态管理 (前端开发)
2. [0.8567] 如何使用 React Hooks 构建可复用的组件 (前端开发)
3. [0.8123] 如何使用 Nuxt.js 进行服务器端渲染 (前端开发)
请输入你要搜索的内容: Python后端接口
🔍 搜索: "Python后端接口"
1. [0.9012] 使用 Flask 和 Python 构建 RESTful API (后端开发)
2. [0.8876] 使用 Django 和 Python 构建一个简单的博客应用 (后端开发)
3. [0.8234] 使用 Node.js 和 Express 构建一个简单的 RESTful API (后端开发)
💡 用户输入"Vue状态管理",虽然标题里没有这四个字,但系统找到了"Vuex 进行状态管理"------这就是语义搜索的威力。
💡 一句话速查卡片
| 概念 | 一句话解释 |
|---|---|
| RAG | 先搜再答,让 AI 基于检索到的资料回答问题 |
| Embedding | 把文字变成一组数字(向量),数字之间的距离代表语义相似度 |
| 余弦相似度 | 衡量两个向量"方向"的一致性,越接近 1 越相似 |
| Hybrid Search | 向量搜索(语义)+ BM25(关键词)双路检索,RRF 算法融合 |
| Lost in the Middle | 大模型对超长上下文中间部分注意力下降,所以不能全塞进去 |
| RAGAS | RAG 评估框架:忠实度、答案相关性、上下文精确率、上下文召回率 |
🔗 参考资料
💬 交流讨论
你用过 RAG 吗?在实际项目中遇到过哪些坑?欢迎在评论区分享你的经验!
后续我会分享更多 AI 实战内容:
- 🔜 《用 RAG + ChatGPT 打造智能技术问答机器人》
- 🔜 《向量数据库选型指南:Milvus vs pgvector vs Pinecone》
觉得有用?点个赞👍收藏⭐关注👆,下次更新更多 AI 实战玩法!