前端也能搞懂 RAG:用 JS 手写一条最小检索增强链路
不调框架、不碰向量数据库只用 100 来行原生 JS把 RAG 从听过这个词变成我亲手跑通过。全程 Node.js 原生fetch一个硅基流动的免费 API Key 就能复现。写在前面这篇文章想帮你解决什么很多前端同学对 RAG 的认知停留在两句话“不就是把文档喂给大模型嘛”“那是后端/算法的活”。但真到面试被追问RAG 的检索是怎么做的“为什么不直接 fine-tune”“相似度阈值怎么定”就答不上来了——因为没亲手拆过。这篇文章的目标是带你用最朴素的方式手写一遍 RAG 的核心链路。每一章只解决一个问题并且会明确告诉你这一章要达成什么、为什么做完这一章会自然引出下一章。读完你应该能用自己的话讲清 RAG 是什么、解决什么问题知道一条 RAG 链路由哪几个零件组成每个零件为什么不可省略手里有一份能跑、能改、能在面试时打开演示的代码。第 1 章RAG 是什么 前端为什么值得学本章目标建立为什么需要 RAG的直觉而不是背定义。先看一个大模型答不好的问题直接问模型我们店的珍珠煮好后能保存多久它会给你啰嗦一大段甚至自相矛盾——一会儿说 2 小时、一会儿说 24 小时、还能编出个每锅 4 小时冷藏。原因很简单这是你们店的内部规范模型训练时根本没见过。它不是不会说是没有这个知识只能猜。这种一本正经地编就是所谓的幻觉。RAG 干的就是这件事RAG 全称 Retrieval-Augmented Generation检索增强生成。拆成大白话先去你的资料库里搜出最相关的几段把它们塞进 prompt再让模型照着资料回答。这里其实藏着一个不简单的问题“搜出最相关的几段”计算机凭什么知道哪段相关比如用户问退货论意思最贴近的其实是退款可它俩只共享一个退字而共享了退货两个字的退货流程意思反而没那么贴。字面上重叠多少根本不等于意思上有多近——可关键词搜索偏偏只会数字面。这个问题先记在心里它是后面整条链路的起点第 3 章会专门回收它。一句话总结它的本质RAG 不改模型的脑子只改喂给模型的那段输入。这也是 RAG 和 fine-tune 最关键的区别——fine-tune 是改权重重新训练成本高、更新慢RAG 是改输入拼上下文随时换知识库、随时生效。对绝大多数让模型懂我的私有知识的需求RAG 才是性价比之选。前端为什么值得学三个非常实际的理由它本质是一条数据流不是黑盒算法。取文档 → 算向量 → 比相似度 → 拼 prompt → 调接口每一步都是你熟悉的输入输出 数组操作 fetch。没有梯度、没有反向传播。它正在变成前端的活。AI 应用的检索 拼上下文这层越来越多落在 BFF / Node 层前端工程师离用户最近最适合做这层编排。它是面试高频考点且容易讲出深度。只要你亲手跑过“高分≠能回答”“阈值怎么定”chunk 切多大这些追问你都能用自己的实测数据回答。承上启下现在你知道了 RAG 值得学但很容易一上来就扎进Embedding 模型怎么训练Transformer 怎么推导里出不来。动手之前得先回答一个更现实的问题前端到底要学到哪一层、学到什么程度才够用先把边界划清楚再动手才不会迷路。第 2 章应该学到什么程度本章目标在动手前先划好边界避免一头扎进算法细节里出不来。前端学 RAG不是要你去训练 embedding 模型、推导 Transformer。你要掌握的是工程链路这一层。给个明确的分层层次要不要深入学到什么程度Embedding 模型内部怎么训练的❌ 不用知道它把文本变成一串数字、语义近的数字也近即可调用 embedding 接口、理解它的输入输出✅ 必须能独立调通、知道维度是什么、为什么要分批余弦相似度、Top-K、阈值过滤✅ 必须能手写、能解释为什么除以模长、阈值怎么定chunk 切分、prompt 注入、拒答兜底✅ 必须踩过坑、能讲出切太长会稀释相似度这类实测结论向量数据库Milvus/pgvector调优 了解知道生产上用它替换内存数组原理是同一套一句话标准链路里的每一步你都能手写一个最小版并解释它为什么存在。达到这个程度框架LangChain、LlamaIndex对你就只是把这些步骤封装了一下而不是黑魔法。承上启下边界划清了——核心就是 Embedding、相似度、链路编排这三块。那我们就从最核心的 Embedding 开始先把文本变数字这一步亲手跑通。第 3 章Embedding 调用——为什么需要它以及怎么调本章目标理解 embedding 是整条 RAG 的地基并跑通第一个接口调用。为什么一定要 Embedding回到第 1 章留下的问题用户问退货计算机怎么知道退款才是意思最近的那个关键词匹配会失败它只会数字面重叠。退货流程和退货共享两个字、退款只共享一个字按字面排序退货流程会被排在前面——可论意思退款才更贴。字面多 ≠ 语义近关键词搜索从根上就抓错了维度。Embedding 不会它把每段文本映射成一个高维向量这里是 1024 维语义相近的文本向量在空间里也靠得近。所以 embedding 的作用就是把语义相关这个模糊的人类概念翻译成向量距离这个计算机能算的数字。没有这一步后面所有的检索都无从谈起。这就是它是地基的原因。怎么调真实可跑的代码我用的是硅基流动的BAAI/bge-m3模型OpenAI 兼容接口原生 fetch 就能调// embed.jsimportdotenv/configconstAPI_KEYprocess.env.SILICONFLOW_API_KEYconstEMBED_URLhttps://api.siliconflow.cn/v1/embeddingsconstBATCH_SIZE16// 每批最多发多少条避免单请求体过大被服务端重置连接// 发一批带一次重试应对 ECONNRESET 等瞬时网络错误asyncfunctionembedBatch(input,retry1){try{constresawaitfetch(EMBED_URL,{method:POST,headers:{Authorization:Bearer${API_KEY},Content-Type:application/json,},body:JSON.stringify({model:BAAI/bge-m3,input}),})if(!res.ok)thrownewError(Embedding 失败:${res.status}${awaitres.text()})constdataawaitres.json()returndata.data.map(itemitem.embedding)}catch(err){if(retry0){awaitnewPromise(rsetTimeout(r,1000))returnembedBatch(input,retry-1)}throwerr}}// 把一段或多段文本转成向量自动分批exportasyncfunctionembed(texts){constinputArray.isArray(texts)?texts:[texts]constout[]for(leti0;iinput.length;iBATCH_SIZE){constvecsawaitembedBatch(input.slice(i,iBATCH_SIZE))out.push(...vecs)}returnout}// 直接 node embed.js 时跑个小测试被 import 时不执行if(import.meta.urlfile://${process.argv[1]}){constvecsawaitembed([hello])console.log(维度:,vecs[0].length)// 应该是 1024console.log(前 5 个数:,vecs[0].slice(0,5))}跑一下node embed.js你会看到维度: 1024 前 5 个数: [ -0.013, 0.042, -0.006, 0.038, 0.011 ]这一串 1024 个数字就是这段文本的语义坐标。这是整篇文章最关键的一步——理解了这串数字RAG 就不再神秘。这里的BATCH_SIZE 16和重试不是凑数的。我第一次把 30 段较长的文本一次性发出去body 太大直接ECONNRESET连接被重置。分批 重试是踩坑后加的——这段故事我放在另一篇《踩坑实录》里展开。承上启下现在每段文本都有了自己的 1024 维向量。但两个向量摆在面前怎么判断它俩近不近我们需要一把量语义距离的尺子。第 4 章余弦相似度——给像不像一个分数本章目标手写一把度量语义距离的尺子理解它为什么长这样。两个向量像不像最常用的是余弦相似度算它俩夹角的余弦值范围 [-1, 1]越接近 1 越像。先把公式摆出来其实就一行余弦相似度 点积 ÷ (a 的模长 × b 的模长) (a·b) / (|a| × |b|)拆开两个名词就全懂了点积对应位置相乘再求和a[0]*b[0] a[1]*b[1] …模长向量各元素平方和再开根号√(a[0]² a[1]² …)几何上就是这个向量的长度。对着这行公式看代码每一项都一一对得上// similarity.jsexportfunctioncosineSimilarity(vecA,vecB){if(vecA.length!vecB.length)thrownewError(向量长度不一致)letdotProduct0,normA0,normB0for(leti0;ivecA.length;i){dotProductvecA[i]*vecB[i]// 点积normAvecA[i]*vecA[i]// A 的模长平方normBvecB[i]*vecB[i]// B 的模长平方}returndotProduct/(Math.sqrt(normA)*Math.sqrt(normB))}为什么要除以两个模长这是公式的灵魂。点积a·b其实等于|a| × |b| × cos(θ)——方向信息夹角 θ和长度信息是乘在一起的。除以两个模长正好把长度约掉只剩纯粹的cos(θ)。这一步等价于先把两个向量都归一化成单位长度再做点积。为什么是余弦而不是点积或欧氏距离这是面试高频追问值得单独记一句我们要比的是语义方向不是向量长度。度量在比什么为什么 embedding 不爱用点积方向 长度长度会干扰长向量哪怕方向偏分也可能虚高欧氏距离两点的直线距离同样受长度影响高维下还容易距离都差不多维度灾难余弦相似度只比方向夹角✅ 天然剔除长度只留语义关键在于在 embedding 里长度几乎不携带语义方向才携带语义。举个秒懂的例子——“退货” 和 “我想申请退货麻烦了”后者更长、模长更大但方向都指向同一片售后语义区。用余弦两句因方向一致照样判高分用点积或欧氏长的那句就会被长度带偏。这样我们比的就是纯粹的语义朝向不被文本长短干扰。来看一组我实测的数据基准句退货候选句分数说明退款0.9322只共享退字语义却最近分数最高退货流程0.9165共享退货两个字分数反而略低苹果手机0.5152完全无关退货流程明明比退款多共享一个字分数反而更低——字面重叠骗不了 embedding它比的是意思、不是字。这就是第 3 章那串数字的威力也是 RAG 比关键词搜索强的根本原因。承上启下现在我们有了文本变向量第 3 章和向量算相似度第 4 章两个零件。把它俩组装起来就能做一件正经事给一个问题从一堆文档里捞出最相关的几段。这就是迷你向量库。第 5 章迷你向量库——把零件组装成可检索本章目标用一个数组 两个零件搭出 RAG 里检索这一环。所谓向量数据库剥开看最小内核就是存的时候把每段文本连同它的向量存起来搜的时候把问题也转成向量逐个算相似度排序取前几名。// store.jsimport{embed}from./embed.jsimport{cosineSimilarity}from./similarity.jsexportclassMiniVectorStore{constructor(){this.items[]// 每项: { text, vector }}// 批量存入文档片段asyncadd(texts){constvectorsawaitembed(texts)texts.forEach((text,i)this.items.push({text,vector:vectors[i]}))console.log(已存入${texts.length}段库内共${this.items.length}段)}// 语义检索返回最相关的 topK 段threshold 以下的直接过滤asyncsearch(query,topK3,threshold0){const[queryVec]awaitembed(query)returnthis.items.map(item({text:item.text,score:cosineSimilarity(queryVec,item.vector)})).sort((a,b)b.score-a.score)// 按相似度从高到低.filter(itemitem.scorethreshold)// 先按阈值过滤.slice(0,topK)// 再取前 K 个}}就这么点代码。生产环境用 Milvus、pgvector无非是把内存数组 暴力遍历换成专门的索引结构让百万级数据也能毫秒检索——原理和你这 30 行一模一样。理解了这个最小版向量数据库对你就不再是黑盒。注意search多出来的threshold参数它是用来拒答的——问一个库里根本没有的问题所有段落分数都很低被阈值滤光检索结果为空。这个伏笔第 7 章会用到。承上启下“检索这一半通了。但 RAG 叫检索增强生成”还差生成——把检索到的资料塞进 prompt让模型照着回答。把这最后一棒接上链路就闭环了。第 6 章拼接 prompt 调用模型——闭合整条链路本章目标把检索结果注入 prompt跑通从问题到带出处的回答的完整流程。检索到的几段资料要喂给模型。关键全在 system prompt 的两条约束// chat.jsexportasyncfunctionchat(question,contexts){constcontextcontexts.map((c,i)[资料${i1}]${c.text}).join(\n)constsystemPrompt你是一个严谨的客服助手。请只根据下面提供的资料回答用户问题。 如果资料里没有相关信息直接说根据现有资料无法回答不要编造。 回答时如果用到了某条资料标注它的编号。 可用资料${context}constresawaitfetch(https://api.siliconflow.cn/v1/chat/completions,{method:POST,headers:{Authorization:Bearer${process.env.SILICONFLOW_API_KEY},Content-Type:application/json,},body:JSON.stringify({model:deepseek-ai/DeepSeek-V3,messages:[{role:system,content:systemPrompt},{role:user,content:question},],temperature:0.3,// 低温度减少自由发挥}),})constdataawaitres.json()returndata.choices[0].message.content}两条约束是 RAG 不幻觉的命门“只根据资料回答”—— 把模型从什么都敢答框回照着材料答“没有就说无法回答”—— 给它一条体面的退路宁可拒答不要编。再加上temperature: 0.3压低自由发挥。把三个文件串起来就是完整的rag.js// rag.jsimport{MiniVectorStore}from./store.jsimport{docs}from./knowledge.jsimport{chat}from./chat.jsconststorenewMiniVectorStore()awaitstore.add(docs)// 1. 建库constquestionprocess.argv.slice(2).join( )||退货要多久能拿到钱consthitsawaitstore.search(question,5)// 2. 检索console.log(\n检索到的资料)hits.forEach(hconsole.log(${h.score.toFixed(3)}${h.text}))console.log(\nAI 回答)console.log(awaitchat(question,hits))// 3. 生成建库 → 检索 → 生成三行注释就是 RAG 的全貌。跑node rag.js 退货要多久能拿到钱你会看到它先打印命中的资料分数再给出一段带[资料N]出处的回答。承上启下链路通了但能跑不等于靠谱。RAG 真正的难点不在拼接而在检索质量——搜错了后面再好的模型也白搭。最后一章我们用实验逼问这条链路的边界。第 7 章边界与陷阱——RAG 不是银弹本章目标通过对比实验认清 RAG 的能力边界这也是面试最能讲出深度的地方。我用compare.js把直接问模型和走 RAG并排打印拿三类问题做对照问题类型直接问模型走 RAG结论① 私有知识珍珠保存多久自相矛盾、编数字精准答4 小时并标[资料1]RAG 完胜② 库里没有怎么修电脑风扇一本正经编一篇教程检索为空老实拒答RAG 更安全③ 公开常识咖啡因的影响全面准确被文档边界限制答得更窄甚至拒答RAG 反而更差三条能直接写进简历的认知私有知识是 RAG 的主场。注入私有知识 可溯源标出处这两个核心价值在问①同时体现。不懂就拒答是 RAG 治幻觉的命门。问②里直接问会硬编教程RAG 因为检索为空 prompt 约束老实认怂——这正是第 5 章那个threshold和第 6 章那两条约束共同促成的。RAG 不是万能常识题反成短板。问③戳破RAG 一定更好的错觉——能主动说出这条比只夸优点成熟得多。还有一个更隐蔽的陷阱叫**“高分 ≠ 能回答”**。基准句怎么退货候选句分数是不是答案退货政策是什么0.8081✅ 是怎么换货0.8051❌ 同领域但不同事换货和真答案只差0.003但换货 ≠ 退货。这意味着光靠相似度排序会把干扰项也召回阈值还特别难一刀切。一句话收尾检索质量是 RAG 的天花板。模型再强喂错了料也救不回来。这也是为什么前端做 RAG功夫不在调模型而在把对的资料、干净地、按合适的粒度搜出来。结语RAG 没有魔法它只是一条你能看懂的数据流如果这篇文章只能让你记住一句话我希望是这句RAG 不是什么算法黑科技它是一条前端完全 hold 得住的数据流——把对的资料、按合适的粒度、干净地搜出来再让模型照着答。回头看这一路第 3 章把文本变成向量第 4 章用余弦给像不像打分第 5 章把这两步组装成能检索的库第 6 章拼好 prompt 闭合链路。没有一步是黑盒每一步都是你早就会的 fetch、数组和数学。所谓的框架LangChain、LlamaIndex无非是把这几步包了一层——你现在拆开看过里面它们对你就不再神秘。而真正分高下的地方第 7 章已经点破检索质量是 RAG 的天花板。模型再强喂错了料也救不回来相似度再高也可能是换货 ≠ 退货那种答非所问。这就是为什么前端做 RAG功夫不在调模型参数而在切块、阈值、拼接这些把资料伺候干净的工程活——而这些恰恰是离用户最近的前端最该接住的一层。至于这些工程活具体怎么踩坑chunk 切太碎检索就废、ECONNRESET 怎么扛、阈值高 0.1 低 0.1 差在哪我把真刀真枪的过程写在了下一篇《前端手写 RAG 踩坑实录》。原理篇让你看懂链路踩坑篇让你扛得住生产。最后还是那句老话代码总共一百来行别只读自己敲一遍跑起来。当你亲眼看到退款和退货靠语义而非字面被打出 0.93 的高分时这条链路才真正长在你脑子里。原创声明本文首发于我的个人博客 https://rjy92.github.io/同步发布在掘金与 CSDN。如需转载请注明出处。如果这篇文章帮到了你欢迎在以下平台关注、交流掘金https://juejin.cn/spost/7654510531215179785CSDNhttps://blog.csdn.net/u012565530/article/details/162273891?sharetypeblogdetailsharerId162273891sharereferPCsharesourceu012565530spm1011.2480.3001.8118