RAG 完整链路补充:从向量存储到智能问答

0 阅读6分钟

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,
  },
});

这里初始化了两种不同用途的模型:

模型类型作用
ChatOpenAILLM(大语言模型)理解上下文 + 生成自然语言回答
OpenAIEmbeddingsEmbedding 模型将文本转换为向量(一串数字),用于数学计算相似度

为什么要分开?

这是 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() 做了两件事:

  1. 遍历每一个 Document,调用 embeddings 将其 pageContent 转为向量
  2. 将 向量 + 原始 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);

similaritySearchWithScoreretriever.invoke 的区别在于——它额外返回了距离分数

分数怎么读?

const score = scoredResult[1];           // 原始分(距离值)
const similarity = (1 - score).toFixed(4); // 转为相似度

这里的核心公式是:

相似度 = 1 - 距离

原始分数(距离)相似度含义
0.120.88非常相关 ✅
0.230.77比较相关 ✅
0.450.55一般 🤔
0.800.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                 │
├─────────────────────────────────────┤
│  输出引导                             │
│  "你的回答:"                          │
└─────────────────────────────────────┘

这个结构的精妙之处:

  1. 角色设定限定了 LLM 的行为边界——"你是助手"而非"你是百科全书",防止模型越界编造
  2. 上下文注入(Augmented)是 RAG 的灵魂——把外部知识"喂"给模型,弥补它的知识盲区
  3. 问题 + 引导让模型聚焦,产出精准答案而非泛泛而谈

六、完整数据流全景图

把两篇文章串起来,就是一条完整的 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),底层串联了三个独立系统:

  1. Embedding 服务 — 将自然语言转为数学向量
  2. 向量数据库 — 在高维空间中做最近邻搜索(ANN,Approximate Nearest Neighbor)
  3. 检索策略 — Top-K 筛选、可能的过滤和重排

但作为调用者,你只需要一个方法调用。这就是 LangChain 工作流编排的价值——它把复杂的多步骤流程抽象为简洁的 API,让你专注于业务逻辑而非底层实现。


总结

组件核心职责关键技术点
ChatOpenAI生成自然语言回答temperature 控制随机性
OpenAIEmbeddings文本 → 向量支持自定义 baseURL
MemoryVectorStore存储向量 + 相似度搜索开发环境用,生产需替换
Retriever标准检索接口k 控制返回数量
similaritySearchWithScore带分数的检索距离越小越相似
Prompt 拼接注入上下文角色 + Context + 问题三段式

从爬虫抓取、文档分块,到向量存储、语义检索,再到 LLM 生成回答——这条链路中的每一个环节都体现了工程上的深思熟虑。理解它们各自的设计意图,才能真正驾驭 RAG 系统,在 AI 时代成为一名合格的 AI 架构师。