RAG 完整链路补充:从向量存储到智能问答
前言
之前的文章我们详细讲解了 RAG 的前半段——爬虫抓取和文档分块。本文作为补充,聚焦 index.mjs 中新增的后半段:向量化存储、相似度检索、以及如何将检索结果注入 LLM 生成最终答案。这是 RAG 真正发挥威力的部分。
一、模型层的初始化:让 LLM 跑起来
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,
},
});
这里初始化了两种不同用途的模型:
| 模型 | 类型 | 作用 |
|---|---|---|
ChatOpenAI | LLM(大语言模型) | 理解上下文 + 生成自然语言回答 |
OpenAIEmbeddings | Embedding 模型 | 将文本转换为向量(一串数字),用于数学计算相似度 |
为什么要分开?
这是 RAG 架构的一个关键设计决策:
- Embedding 模型负责"理解"文本的语义,并把它编码成高维向量。语义相近的文本,向量在空间中的距离也近。
- Chat 模型负责"表达"——它接收检索到的上下文和用户问题,组织语言生成流畅的回答。
- 两者各司其职,不能互相替代。
💡
temperature: 0意味着模型每次回答都选择概率最高的词,输出稳定、可复现,适合知识问答场景而非创意写作。
💡 代码中的
baseURL配置支持自定义 API 端点,这意味着你可以接入任何 OpenAI 兼容的服务(如本地部署的模型),不必绑定官方服务。
二、向量存储:把文字变成可以"计算距离"的数学对象
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments,
embeddings
);
这行代码背后发生了什么?
切分后的 Chunk 1 ──→ embeddings.embedQuery() ──→ [0.23, -0.15, 0.78, ...] (768维向量)
切分后的 Chunk 2 ──→ embeddings.embedQuery() ──→ [0.19, -0.21, 0.82, ...]
切分后的 Chunk 3 ──→ embeddings.embedQuery() ──→ [0.31, -0.11, 0.75, ...]
... ...
↓
MemoryVectorStore(内存中存储)
MemoryVectorStore.fromDocuments() 做了两件事:
- 遍历每一个 Document,调用
embeddings将其pageContent转为向量 - 将 向量 + 原始 Document 的映射关系存储在内存中
MemoryVectorStore 是一个内存向量库,适合开发调试和小规模场景。生产环境中通常会替换为 Pinecone、Milvus、Chroma 等持久化向量数据库。
三、检索器(Retriever):从向量空间中找到最相关的文档
const retriever = vectorStore.asRetriever({ k: 3 });
asRetriever 将向量存储包装成一个标准的检索器接口,k: 3 表示每次检索返回最相似的 3 个文档。
const docs = await retriever.invoke(question);
invoke 的内部逻辑
这是整个检索流程的核心,每一步都很精妙:
用户问题: "fs模块有哪些api"
│
▼
┌─────────────────────────┐
│ 1. embeddings.embedQuery() │ ← 将问题也转成向量
│ "fs模块有哪些api" │
│ → [0.18, -0.09, 0.81...]│
└──────────┬──────────────┘
│
▼
┌──────────────────────────┐
│ 2. 在向量空间中计算距离 │ ← 与所有存储的 Chunk 向量比对
│ cos(问题向量, Chunk1向量) │
│ cos(问题向量, Chunk2向量) │
│ cos(问题向量, Chunk3向量) │
│ ... │
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ 3. 排序 + Top-K │ ← 返回距离最近的 3 个 Document
│ 第1名: Chunk_7 距离 0.12│
│ 第2名: Chunk_3 距离 0.23│
│ 第3名: Chunk_12 距离 0.31│
└──────────────────────────┘
整个过程的本质是:把语义搜索变成了数学计算。你不需要做关键词匹配,"fs模块"和"文件系统"会自动因为语义相近而被检索到——这就是向量检索相比传统全文搜索的革命性优势。
四、相似度评分:不只是"找到了",还要知道"有多像"
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);
similaritySearchWithScore 与 retriever.invoke 的区别在于——它额外返回了距离分数。
分数怎么读?
const score = scoredResult[1]; // 原始分(距离值)
const similarity = (1 - score).toFixed(4); // 转为相似度
这里的核心公式是:
相似度 = 1 - 距离
| 原始分数(距离) | 相似度 | 含义 |
|---|---|---|
| 0.12 | 0.88 | 非常相关 ✅ |
| 0.23 | 0.77 | 比较相关 ✅ |
| 0.45 | 0.55 | 一般 🤔 |
| 0.80 | 0.20 | 不太相关 ❌ |
💡 距离越小,相似度越高。这是余弦距离(Cosine Distance)的语义——值越接近 0,两个向量方向越一致,文本语义越接近。
实际应用场景
知道分数有什么用?可以做很多事情:
- 过滤低质量结果:设置相似度阈值(如 < 0.5),低于阈值的结果直接丢弃
- Re-rank(重排序) :结合其他信号(如时间衰减、来源权重)对结果重新打分
- 溯源展示:在回答中附带"参考来源"及其相关度,增强可信度
- 调试优化:观察哪些 Chunk 的分数偏低,反向优化分块策略
五、RAG 的"Augmented":把检索结果注入 Prompt
这是整个链路中最具创造性的环节:
const context = docs
.map((doc, i) => `[片段${i}]\n ${doc.pageContent}`)
.join("\n\n-----\n\n");
const prompt = `
你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容:
${context}
问题:${question}
你的回答:`;
Prompt 工程的三层结构
┌─────────────────────────────────────┐
│ 系统角色设定 │
│ "你是一个文章辅助阅读助手..." │
├─────────────────────────────────────┤
│ 检索到的上下文(Augmented) │
│ [片段0] ... 文章相关段落 ... │
│ ----- │
│ [片段1] ... 文章相关段落 ... │
│ ----- │
│ [片段2] ... 文章相关段落 ... │
├─────────────────────────────────────┤
│ 用户问题 │
│ 问题:fs模块有哪些api │
├─────────────────────────────────────┤
│ 输出引导 │
│ "你的回答:" │
└─────────────────────────────────────┘
这个结构的精妙之处:
- 角色设定限定了 LLM 的行为边界——"你是助手"而非"你是百科全书",防止模型越界编造
- 上下文注入(Augmented)是 RAG 的灵魂——把外部知识"喂"给模型,弥补它的知识盲区
- 问题 + 引导让模型聚焦,产出精准答案而非泛泛而谈
六、完整数据流全景图
把两篇文章串起来,就是一条完整的 RAG 链路:
┌─ 离线阶段(建库)────────────────────────────────────┐
│ │
│ URL ──→ CheerioWebBaseLoader ──→ Document │
│ │ │
│ ▼ │
│ RecursiveCharacterTextSplitter│
│ │ │
│ ▼ │
│ splitDocuments │
│ │ │
│ ▼ │
│ OpenAIEmbeddings │
│ │ │
│ ▼ │
│ MemoryVectorStore ←── 向量库 │
└──────────────────────────────────────────────────────┘
┌─ 在线阶段(问答)────────────────────────────────────┐
│ │
│ 用户问题 "fs模块有哪些api" │
│ │ │
│ ▼ │
│ embeddings.embedQuery() ──→ 问题向量 │
│ │ │
│ ▼ │
│ vectorStore.similaritySearch() ──→ Top-K Documents │
│ │ │
│ ▼ │
│ Prompt = 系统角色 + Context + 问题 │
│ │ │
│ ▼ │
│ ChatOpenAI.invoke(prompt) ──→ 最终答案 │
└──────────────────────────────────────────────────────┘
七、一行代码背后的工程智慧
// invoke 执行
// 内部逻辑, 将question 转为向量
// 在向量数据库中计算距离 返回K 个Document对象
// 工作流编排
const docs = await retriever.invoke(question);
这短短一行 retriever.invoke(question),底层串联了三个独立系统:
- Embedding 服务 — 将自然语言转为数学向量
- 向量数据库 — 在高维空间中做最近邻搜索(ANN,Approximate Nearest Neighbor)
- 检索策略 — Top-K 筛选、可能的过滤和重排
但作为调用者,你只需要一个方法调用。这就是 LangChain 工作流编排的价值——它把复杂的多步骤流程抽象为简洁的 API,让你专注于业务逻辑而非底层实现。
总结
| 组件 | 核心职责 | 关键技术点 |
|---|---|---|
ChatOpenAI | 生成自然语言回答 | temperature 控制随机性 |
OpenAIEmbeddings | 文本 → 向量 | 支持自定义 baseURL |
MemoryVectorStore | 存储向量 + 相似度搜索 | 开发环境用,生产需替换 |
Retriever | 标准检索接口 | k 控制返回数量 |
similaritySearchWithScore | 带分数的检索 | 距离越小越相似 |
| Prompt 拼接 | 注入上下文 | 角色 + Context + 问题三段式 |
从爬虫抓取、文档分块,到向量存储、语义检索,再到 LLM 生成回答——这条链路中的每一个环节都体现了工程上的深思熟虑。理解它们各自的设计意图,才能真正驾驭 RAG 系统,在 AI 时代成为一名合格的 AI 架构师。