在知识库问答、企业文档助手、产品文档搜索这些场景里,大模型本身并不会自动知道我们的私有资料。更麻烦的是,它不知道时仍可能给出看似合理的回答。
RAG(Retrieval-Augmented Generation,检索增强生成)解决的正是这个问题:在模型回答之前,先从知识库检索相关片段,再把片段作为上下文交给模型生成答案。
本文结合 rag-demo/src/index.mjs 这个最小可运行项目,完整拆解一次 RAG 请求从"文档入库"到"答案输出"的过程,并说明从 Demo 走向实际业务时要补齐什么。
一、先建立正确的心智模型
一条 RAG 链路可以概括为:
text
原始文档
-> 文档切块(Document)
-> Embedding 向量化
-> 向量库
用户问题
-> Embedding 向量化
-> 相似度检索 Top K 文档
-> 将文档拼入 Prompt
-> LLM 生成基于资料的回答
这里有两个模型角色:
- Embedding 模型:负责把文档和问题转换成向量,用于寻找语义相近的内容。
- 聊天/生成模型(LLM):负责阅读检索到的资料,组织最终自然语言答案。
它们的职责不同。Embedding 模型不直接写答案,LLM 也不应该承担"在大量资料中逐页找答案"的工作。
二、项目依赖和入口
项目使用的关键依赖是:
json
{
"@langchain/openai": "^1.5.5",
"@langchain/core": "^1.2.3",
"@langchain/classic": "^1.0.40",
"dotenv": "^17.4.2"
}
其中:
@langchain/openai提供ChatOpenAI和OpenAIEmbeddings。@langchain/core提供标准化的Document类型。@langchain/classic提供MemoryVectorStore内存向量库。dotenv将.env中的模型配置加载到process.env。
入口文件一开始创建了两个客户端:
js
const model = new ChatOpenAI({
temperature: 0,
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
temperature: 0 适合知识问答。它的目标是忠实、稳定地复述检索资料,而不是创作多种不同版本的回答。
说明:这段 Demo 假定聊天模型和嵌入模型共享 OpenAI 兼容的配置。实际项目中,两种模型可能来自不同平台,应分别配置 API Key、Base URL 和模型名。
三、Document:知识库的最小单位
项目没有直接把整篇故事放进 Prompt,而是拆成了 7 个 Document。一个文档由正文和元数据组成:
js
new Document({
pageContent: "东东和光光从幼儿园就认识了,他们一起度过了无数个快乐的时光。",
metadata: {
chapter: 2,
character: "东东",
type: "角色介绍",
mood: "温馨",
},
});
pageContent 是会被向量化、被检索、也会进入 Prompt 的内容。
metadata 不参与向量语义计算,但在真实业务中非常重要:
- 可以按
chapter、产品版本、部门或时间范围过滤; - 可以在答案中展示来源;
- 可以根据权限字段避免检索到无权查看的资料;
- 可以在文档更新时定位并删除旧向量。
当前示例的每一段故事天然就是一块语义完整的文本。真实 PDF、网页和长文档通常还需要额外的加载器和文本切分器,按标题、段落和句子边界切块,并保留少量 overlap,避免关键上下文被切断。
四、Retrieval:把文档变成可语义搜索的知识库
项目的关键代码如下:
js
const vectorStore = await MemoryVectorStore
.fromDocuments(documents, embeddings);
这一行并不只是"创建一个对象",它内部完成了以下事情:
text
documents 中每一个 pageContent
-> embeddings.embedDocuments(...)
-> 得到一组数值向量
-> 连同原文和 metadata 写入 MemoryVectorStore
向量可被理解成文本的语义坐标。比如"从幼儿园就认识"和"怎么成为朋友的"字面不完全相同,但嵌入模型会让它们在向量空间中相对接近。
这就是向量检索相对纯关键词匹配的价值:用户换一种自然表达,系统仍然有机会找到正确资料。
4.1 从向量库得到检索器
js
const retriever = vectorStore.asRetriever({
k: 3,
});
检索器将"向量库怎么查"的细节包装成统一接口。k: 3 表示每个问题最多返回语义最相近的 3 个文档。
真正执行检索时只需要:
js
const question = "东东和光光是怎么成为朋友的";
const docs = await retriever.invoke(question);
它的内部流程是:
text
question
-> embeddings.embedQuery(question)
-> 与已入库的文档向量计算相似度
-> 从高到低排序
-> 返回前 3 个 Document
针对这个问题,第二章"从幼儿园就认识"的片段应当是重要召回结果。
4.2 Top K 应该取多少?
k 没有通用最优值:
k太小,答案所需资料可能没有被召回;k太大,噪声更多、Prompt 更长、模型更容易被无关信息干扰,成本也更高。
实际项目通常从 3 到 5 开始,用真实用户问题组成的评测集调整,而不是凭感觉固定一个数字。
4.3 如何看待相似度分数
项目额外调用了:
js
const scoredResults = await vectorStore
.similaritySearchWithScore(question, 3);
这样做适合调试:可以把"模型为什么找到了这些文档"展示出来。
需要注意,当前版本的 MemoryVectorStore 默认使用余弦相似度,返回的 score 是相似度,数值越大表示越相近 。因此展示时可以直接输出 score;不能把它一概当成"距离"再计算 1 - score。不同向量数据库和 API 对 score 的定义可能不同,接入新存储时必须先确认指标语义。
五、Augmented:把检索结果变成模型上下文
检索器返回的是 Document[]。项目将其拼接成上下文:
js
const context = docs
.map((doc, i) => `[片段${i}]\n${doc.pageContent}`)
.join("\n\n-----\n\n");
然后放入 Prompt:
js
const prompt = `你是一个讲友情故事的老师。基于以下故事片段回答问题,
用温暖生动的语言。如果故事中没有提到,就说"这个故事里还没有提到这个细节"。
故事片段:
${context}
问题:${question}
老师的回答:`;
这一步就是 Augmented(增强):通过外部资料增强当前请求,而不是期待模型"记住"所有文档。
其中最重要的约束是:资料没有提到时明确说不知道。这不能彻底消除幻觉,但能给模型划清回答边界。生产场景还应要求模型返回引用的文档 ID 或链接,让用户能核验答案。
六、Generation:调用 LLM 生成最终答案
最后一步非常直接:
js
const response = await model.invoke(prompt);
console.log(response.content);
模型现在看到的不是一个孤立问题,而是"问题 + 检索到的故事片段 + 回答规则"。它可以据此输出类似下面的答案:
东东和光光从幼儿园就认识。虽然一个爱运动,一个喜欢读书画画,但他们一起经历了许多快乐时光,并在互相帮助中成为了最好的朋友。
到这里,Retrieval、Augmented、Generation 三部分全部闭环。
七、Demo 到生产:缺的不是一行模型调用
这个项目很好地展示了核心流程,但生产化还需要补齐以下能力:
| Demo 做法 | 生产化方向 |
|---|---|
| 手写 7 个 Document | 接入 PDF、网页、数据库、对象存储,并建立增量同步流程 |
| 内存向量库 | 使用 pgvector、Milvus、Qdrant、Pinecone 等持久化存储 |
| 仅向量召回 | 加入关键词检索、元数据过滤、查询改写、rerank |
| 仅输出文本 | 返回引用来源、置信提示、结构化答案 |
| 无评测 | 用真实问题评测召回率、忠实度、引用正确性和延迟 |
| 无权限控制 | 按用户权限在检索前过滤文档 |
特别是"答案不准"时,不要立刻换一个更大的生成模型。应先定位问题:
text
最终答案错误
-> 检索到的资料是否正确?
否:检查文档质量、切块、Embedding、Top K、过滤、rerank
是:检查 Prompt、上下文是否超长、模型是否遵循约束
-> 知识库是否包含正确资料?
否:补充或更新文档,并重新建立索引
这套排查顺序能避免"模型答错了就盲目换模型"的低效做法。
八、面试中如何讲这个项目
可以这样概括:
我实现过一个最小 RAG 问答链路:使用 LangChain 的 Document 承载文本和元数据,通过 Embedding 将文档写入向量库;用户提问后将问题向量化,召回 Top K 相关片段;再把片段作为上下文传给 LLM,并约束模型在资料不足时明确拒答。这个 Demo 使用内存向量库验证流程;若进入生产,我会增加文档切分、持久化向量库、权限过滤、混合检索、重排序、引用和离线评测。
这段回答体现的重点不是"会调用一个 SDK",而是你理解 RAG 是一个需要持续评估的检索与生成系统。
总结
RAG 的价值不在于把文档简单塞进 Prompt,而在于建立一套可检索、可追踪、可评估的知识访问流程:
- 用合适粒度组织文档;
- 用 Embedding 建立语义索引;
- 用检索器找出最相关证据;
- 用 Prompt 约束模型基于证据回答;
- 用日志、引用和评测持续优化。
掌握这条链路后,再替换模型、向量数据库或框架,核心的工程判断仍然成立。