摘要:大模型什么都懂?别逗了,它连你公司上个季度的财报都没见过。RAG(检索增强生成)就是给AI配一个"私人图书馆",让它回答问题之前先翻资料,而不是一本正经地胡说八道。但理想很丰满,现实很骨感------Embedding API返回404、向量库需要C++编译、HuggingFace被墙下载不了......这篇文章记录了我从零实现RAG的全过程,包括四次连续翻车和最终跑通的方案。
一、AI为什么会"一本正经胡说八道"
你有没有遇到过这种情况------问AI一个专业问题,它回答得头头是道,但全是编的?
arduino
你:"我们公司2025年Q3的营收是多少?"
AI:"根据公开数据,2025年Q3营收为3.2亿元,同比增长15%。"
(实际上你公司根本没公布过这个数据,3.2亿是它编的)
这叫幻觉(Hallucination),是LLM最臭名昭著的问题。模型被训练成"总要给出回答",当它不知道答案时,不会说"我不知道",而是编一个看起来合理的答案。
就像考试遇到不会的题------老实人交白卷,LLM选择编答案,还编得有模有样。
RAG就是解决方案。 它让AI在回答之前,先去"图书馆"查资料,然后根据查到的真实内容来回答。
没有RAG:AI凭记忆回答 → 可能编造
有了RAG:AI先查资料再回答 → 基于事实
二、RAG到底在做什么?
RAG的全称是 Retrieval Augmented Generation(检索增强生成)。拆开看:
- Retrieval(检索):根据用户问题,从知识库中找到相关内容
- Augmented(增强):把找到的内容拼接到提示词中,增强上下文
- Generation(生成):LLM基于增强后的上下文生成回答
整个流程分两大阶段:
阶段一:建库(离线,只做一次)
━━━━━━━━━━━━━━━━━━━━━━━━━━
文档 → 加载 → 分块 → 向量化 → 存入向量库
就像:买书 → 拆章节 → 贴标签 → 上架
阶段二:检索+生成(在线,用户每次提问时)
━━━━━━━━━━━━━━━━━━━━━━━━━━
用户问题 → 检索相关片段 → 拼接到Prompt → LLM生成回答
就像:提问 → 管理员找书 → 把书翻到相关页 → AI照着书回答
这里有几个关键概念需要理解:
2.1 为什么要分块?
你不会把一整本《三国演义》递给AI说"自己找吧"。文档太长,模型处理不了,检索也不精确。切成小块,每次只给最相关的几段,AI才能精准回答。
但分块是个技术活:
- 太大:一个块里塞了太多内容,检索不精确,可能混入无关信息
- 太小:一个块只有半句话,丢失了上下文,模型看不懂
推荐的参数:chunkSize=1000, chunkOverlap=200。overlap是相邻块的重叠部分,防止关键信息恰好在切分边界被截断。
diff
块1: [====重叠区====]
块2: [====重叠区====]
块3: [====重叠区====]
2.2 什么是Embedding?
Embedding是把文本变成数字向量的技术。语义相似的文本,向量在空间中距离近;语义无关的文本,向量距离远。
arduino
"猫是一种宠物" → [0.12, 0.85, 0.33, ...]
"狗是常见的家养动物" → [0.14, 0.82, 0.35, ...] ← 距离近,都是宠物
"量子力学的测不准原理" → [0.91, 0.03, 0.67, ...] ← 距离远,语义无关
就像给每本书贴了一个"内容指纹"------两本书的指纹越相似,内容越相关。
2.3 向量库怎么检索?
用户提问时,先把问题也变成向量,然后在向量库中找距离最近的K个文本块。这叫相似度搜索(Similarity Search)。
就好比你在图书馆问"有没有讲RAG的书",管理员不是一本一本地翻,而是直接去"RAG"那个书架拿。
三、从零实现:代码实战
3.1 项目结构
bash
d:\project\agent
├── .env # API Key 配置
├── data/
│ └── langchain-guide.md # 知识库文档
├── src/
│ ├── index.ts # 第一步:Hello LLM
│ ├── memory.ts # 第二步:对话记忆
│ └── rag.ts # 第三步:RAG(本文重点)
└── package.json
3.2 建库阶段:加载→分块→索引
typescript
// 1. 加载文档
const content = fs.readFileSync("data/langchain-guide.md", "utf-8");
const docs = [
new Document({
pageContent: content,
metadata: { source: "langchain-guide.md" },
}),
];
// 2. 分块
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
const splits = await splitter.splitDocuments(docs);
// 3. 建索引(这里是 TF-IDF,后文解释为什么)
const retriever = new SimpleRetriever();
retriever.addDocuments(splits);
RecursiveCharacterTextSplitter 是 LangChain 提供的分块器,它会按段落→句子→字符的优先级递归切分,尽量保持语义完整性。
3.3 检索+生成阶段
typescript
async function ragAnswer(model, retriever, question) {
// 1. 检索相关文档片段
const relevantDocs = retriever.search(question, 4);
// 2. 把检索结果格式化
const context = relevantDocs
.map(
(doc, i) =>
`[片段${i + 1}] (来源: ${doc.metadata.source})\n${doc.pageContent}`,
)
.join("\n\n---\n\n");
// 3. 构造提示词(关键:把检索结果塞进去)
const messages = [
new SystemMessage(`你是一个知识助手。请基于以下参考内容回答用户的问题。
规则:
- 只基于参考内容回答,不要编造信息
- 如果参考内容中没有相关信息,直接说"根据现有资料无法回答"
参考内容:
${context}`),
new HumanMessage(question),
];
// 4. LLM 生成回答
const response = await model.invoke(messages);
return response.content;
}
注意提示词里的规则:"只基于参考内容回答,不要编造"------这是RAG的纪律。就像开卷考试,资料都给你了,就别瞎编。
3.4 运行效果

关键验证:问知识库有的问题能准确回答,问知识库没有的问题能坦诚说"无法回答"------这就是RAG的价值,从"编造"到"诚实"。
四、踩坑实录:四次翻车的血泪史
这才是本文的重点。RAG的原理谁都懂,但真正动手实现时,踩的坑远比代码多。
翻车①:FAISS需要C++编译环境
期望 :npm install faiss-node,然后直接用FAISS向量库。
现实:
arduino
npm error OMG Could not find any Visual Studio installation to use
FAISS是Facebook开源的高效向量检索库,但它的Node.js绑定faiss-node需要本地编译C++代码,Windows上必须安装Visual Studio的"Desktop development with C++"工作负载。
我的开发机上没有Visual Studio,也不想为了一个向量库装几个G的编译环境。
解决 :改用MemoryVectorStore------LangChain内置的纯JS内存向量库,零编译依赖。
| 方案 | 依赖 | 适合场景 |
|---|---|---|
| FAISS | C++编译环境 | 大规模生产数据 |
| Chroma | 需启动服务端 | 需持久化的项目 |
| MemoryVectorStore | 零依赖 | 开发测试(我的选择) |
翻车②:Embedding API返回404
期望 :用OpenAIEmbeddings调OpenAI的Embedding接口,向量化文档。
现实:
css
BadRequestError: 400
error: { message: "no any schema route found", code: "ase_embedding_req_failed" }
原因:我用的API服务(兼容OpenAI协议的Coding Plan)只支持聊天补全(/v1/chat/completions),不支持Embedding(/v1/embeddings)。就像你的手机卡能打电话但不能发短信------套餐没包含这个功能。
教训:不是所有"兼容OpenAI协议"的服务都实现了全部接口。聊天和Embedding是两个独立的端点,前者是标配,后者很多服务商不提供。
翻车③:HuggingFace被墙
期望 :用@huggingface/transformers在本地运行Embedding模型,绕过API限制。
现实:
yaml
ConnectTimeoutError: Connect Timeout Error
(attempted address: huggingface.co:443, timeout: 10000ms)
HuggingFace是全球最大的AI模型托管平台,但在国内经常无法访问。模型文件下载不下来,本地Embedding方案也泡汤了。
尝试 :设置了国内镜像HF_ENDPOINT=https://hf-mirror.com,仍然失败(镜像服务也不稳定)。
翻车④:依赖冲突
期望 :安装@langchain/community和faiss-node。
现实:
vbnet
npm error ERESOLVE unable to resolve dependency tree
npm error Could not resolve dependency: peer dotenv@"^16.4.5" from @browserbasehq/stagehand@1.14.0
LangChain的@langchain/community引入了@browserbasehq/stagehand作为peer dependency,而它要求的dotenv版本和项目中的不一致。
解决 :npm install --legacy-peer-deps,忽略peer dependency版本检查。这不是最优解,但在学习阶段够用。
五、最终方案:TF-IDF纯本地检索
四次翻车后,我决定换一个思路:不用神经网络Embedding,用TF-IDF实现纯本地检索。
TF-IDF是什么?
TF-IDF(Term Frequency - Inverse Document Frequency)是一种经典的信息检索算法:
- TF(词频):一个词在文档中出现的频率越高,越重要
- IDF(逆文档频率):一个词在所有文档中越罕见,区分度越高
- TF-IDF = TF × IDF:综合衡量一个词对某篇文档的重要性
就像判断一篇文章的主题------"的"字出现很多但没区分度(IDF低),"量子计算"出现不多但很有区分度(IDF高)。
为什么TF-IDF能替代Embedding?
| 对比 | TF-IDF | Embedding |
|---|---|---|
| 原理 | 关键词匹配 | 语义理解 |
| 依赖 | 零,纯数学计算 | 需下载模型/调API |
| 网络 | 完全离线 | 需联网或本地模型 |
| 效果 | 能匹配同关键词的内容 | 能理解同义词和语义 |
| 启动速度 | 毫秒级 | 首次需加载模型 |
| 类比 | 书后索引查关键词 | 找一个读懂全书的人帮你查 |
关键区别在"语义理解":
arduino
问题:"检索增强是什么?"
知识库中的原文:"RAG(Retrieval Augmented Generation)是一种..."
TF-IDF:搜"检索增强" → 找不到(因为文档里写的是"RAG",不是"检索增强")
Embedding:搜"检索增强" → 能找到(因为它理解"检索增强"和"RAG"是同义的)
TF-IDF是"死板但靠谱的索引员",Embedding是"灵活但需要先培训的助手"。学习阶段用索引员,生产环境换助手------检索接口不变,只换实现。
实现核心代码
typescript
class SimpleRetriever {
private documents: Document[] = [];
private docTokens: string[][] = [];
private idf: Map<string, number> = new Map();
// 中文分词:提取连续中文字符和英文单词
function tokenize(text: string): string[] {
const matches = text.match(/[\u4e00-\u9fa5]{2,}|[a-zA-Z]{2,}/g);
return matches ? matches.map(t => t.toLowerCase()) : [];
}
// 计算IDF:log(总文档数 / 包含该词的文档数)
private computeIDF() {
for (const [token, freq] of docFreq) {
this.idf.set(token, Math.log((totalDocs + 1) / (freq + 1)) + 1);
}
}
// 计算余弦相似度
private cosineSimilarity(a: Map<string, number>, b: Map<string, number>): number {
// 两个向量的点积 / (向量A的模 × 向量B的模)
// 值域 [0, 1],1 表示完全相同,0 表示完全无关
}
// 检索:把问题也变成TF-IDF向量,找最相似的文档
search(query: string, topK: number = 4): Document[] {
const queryVector = this.tfidfVector(tokenize(query));
// 计算问题和每个文档的余弦相似度,返回topK
}
}
余弦相似度是核心------它衡量两个向量方向的相似程度,不管长度。就像比较两个人的兴趣方向是否一致,不管谁投入的时间更多。
六、踩坑总结与经验
经验一:先跑通,再优化
RAG的真正难点不在检索算法,而在整个流程的串联。TF-IDF虽然效果不如Embedding,但能让你在最短时间内跑通完整流程。流程跑通了,再换检索器只是改一个类的事情。
不要在第一步就追求最优解。 先让飞机飞起来,再优化引擎。
经验二:API兼容≠功能完整
"兼容OpenAI协议"不等于"支持OpenAI所有接口"。很多服务只兼容聊天补全(/chat/completions),不支持Embedding(/embeddings)、Function Calling等高级功能。选API之前,先确认你需要哪些端点。
经验三:国内开发AI应用的三大拦路虎
| 问题 | 原因 | 解决方案 |
|---|---|---|
| HuggingFace被墙 | 网络限制 | 用国内镜像或本地方案 |
| C++编译环境缺失 | 很多AI库需要native编译 | 用纯JS替代方案 |
| API功能不完整 | 兼容协议≠完整功能 | 先确认端点支持情况 |
经验四:RAG的提示词很关键
同样的检索结果,不同的提示词,回答质量天差地别:
arduino
❌ 差的提示词:"回答用户问题"
→ AI可能无视检索结果,自己编答案
✅ 好的提示词:"只基于参考内容回答,不要编造。如果参考内容中没有相关信息,直接说无法回答"
→ AI严格基于检索结果回答,该说不知道就说不知道
RAG不只是"检索+拼接",提示词的纪律性约束同样重要。就像开卷考试也需要规定"不许带小抄"。
七、总结
核心知识点
| 知识点 | 一句话总结 |
|---|---|
| RAG | 让LLM先查资料再回答,从"编造"到"诚实" |
| 分块 | 文档太大要拆,chunkSize=1000, chunkOverlap=200 |
| Embedding | 文本→向量,语义相似则距离近 |
| TF-IDF | 基于关键词的检索,零依赖但不懂语义 |
| 余弦相似度 | 衡量两个向量方向相似程度的数学方法 |
| 提示词纪律 | "只基于参考内容回答"是RAG的底线 |
技术选型路线
markdown
学习阶段:TF-IDF + MemoryVectorStore(零依赖,秒启动)
↓ 理解流程后
生产阶段:Embedding + Chroma/Pinecone(语义检索,持久化存储)
换检索器只需要改一行代码------因为检索的输入输出接口是固定的(问题→文档片段),内部实现是TF-IDF还是神经网络,上层代码不需要关心。这就是抽象的价值。
本文是「LLM应用开发」系列第二篇。上一篇:给失忆的大模型装上"脑子"------对话记忆从零实战。下一篇:让AI"动手干活"------Tool Calling工具调用实战。