返回笔记首页

第三章:RAG + 向量数据库

主题配置

课程:AI 全栈开发实战 — 基于 LangChain.js + Vue3 + Node.js 实战 Demo:极速购客服系统 — 商品知识库问答

代码

langchain-course-ch3.zip


3.1 本章目标

完成本章学习后,你将能够:

  • 理解 RAG 的工作原理,知道为什么它能解决 LLM 的知识局限问题
  • 掌握向量嵌入(Embedding)的概念,理解文本相似度搜索的原理
  • 使用 LangChain.js 完成文档加载、切分、向量化、存储的完整流程
  • 用 pgvector 在 PostgreSQL 中存储和检索向量数据
  • 构建一个基于私有知识库的商品问答系统,并集成到 Vue3 前端

3.2 为什么需要 RAG

LLM 有两个硬伤:

第一,知识有截止日期。训练数据有时效性,模型不知道最新的商品信息、政策变动、公司内部文档。

第二,没有私有数据。LLM 不知道你们公司的产品手册、售后规则、内部知识库。

解决办法是 RAG(Retrieval-Augmented Generation,检索增强生成):在调用 LLM 之前,先从外部知识库里检索出和问题相关的内容,把这些内容一起塞进 Prompt,让 LLM 基于检索到的内容来回答,而不是靠自己的参数记忆回答。

流程如下:

plain
用户提问
   ↓
把问题转成向量
   ↓
在向量数据库中搜索相似内容(Top-K 检索)
   ↓
把检索到的内容 + 原始问题拼成 Prompt
   ↓
发给 LLM 生成回答

这个思路的关键在于"向量搜索",下面先把这个概念讲清楚。


3.3 向量嵌入是什么

向量嵌入(Embedding)是把文本转换成一组数字(向量)的技术。语义相近的文本,转换后的向量在数学空间里的距离也更近。

举例:

  • "苹果手机多少钱" → [0.12, -0.34, 0.87, ...](1536 维)
  • "iPhone 的价格是多少" → [0.11, -0.35, 0.89, ...](和上面的很接近)
  • "今天天气怎么样" → [-0.56, 0.23, -0.12, ...](和上面的相差很远)

这样就可以通过计算向量距离来判断两段文本的语义相似度,而不需要关键词完全匹配。

实际开发中,Embedding 模型把文本转成向量这件事是透明的,你只需要调 API,不需要了解内部实现。


3.4 pgvector 还是 ChromaDB

本章使用 pgvector,理由如下:

  • 极速购已经在用 PostgreSQL 存业务数据,不需要再引入一个独立的向量数据库服务
  • pgvector 是 PostgreSQL 插件,向量数据和业务数据在同一个库里,事务、权限、备份都统一管理
  • 生产环境更稳定,运维成本低

如果项目没有 PostgreSQL,或者只是快速验证,用 ChromaDB 也完全可以,切换只需要换掉 LangChain.js 的 VectorStore 实例,其余代码不变。


3.5 环境准备

3.5.1 安装 PostgreSQL + pgvector

本地开发推荐用 Docker 启动,省去手动安装的麻烦:

bash
docker run -d \
  --name pgvector-dev \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=langchain_course \
  -p 5432:5432 \
  pgvector/pgvector:pg16

连接后开启扩展:

sql
CREATE EXTENSION IF NOT EXISTS vector;

3.5.2 安装依赖

bash
cd server
pnpm add @langchain/community pg

@langchain/community 包含 pgvector 的 VectorStore 实现。pg 是 Node.js 的 PostgreSQL 客户端。

3.5.3 更新环境变量

bash
# server/.env 追加
PG_HOST=localhost
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=postgres
PG_DATABASE=langchain_course

3.6 准备知识库文档

RAG 的质量很大程度上取决于知识库内容的质量。本章用极速购的商品和售后规则作为知识库。

plain
server/src/data/knowledge/
├── products.md      # 商品介绍
└── policies.md      # 售后政策
markdown
<!-- server/src/data/knowledge/products.md -->
# 极速购商品介绍

## 蓝牙耳机 X1 Pro
- 价格:899 元
- 续航:单次充电播放 30 小时,充电盒额外提供 3 次充电
- 降噪:主动降噪,支持三档调节
- 连接:蓝牙 5.3,支持双设备同时连接
- 适用:通勤、办公、运动场景
- 保修:整机一年保修,耳机头六个月保修

## 机械键盘 K200
- 价格:1299 元
- 轴体:可选青轴、红轴、茶轴
- 背光:RGB 全键背光,支持自定义灯效
- 接口:USB-C 有线连接,支持 N-Key Rollover
- 尺寸:87 键紧凑布局
- 保修:整机两年保修

## iPhone 手机壳(适配 15 系列)
- 价格:49.5 元/个
- 材质:军规防摔 TPU + PC 双层材质
- 特点:精准开孔,支持 MagSafe 磁吸充电
- 颜色:透明、磨砂黑、星光蓝
- 保修:30 天质量问题无理由退换
markdown
<!-- server/src/data/knowledge/policies.md -->
# 极速购售后政策

## 退货政策
- 收到商品后 7 天内,无理由退货(商品需未拆封、无损坏)
- 质量问题 15 天内免费退换
- 定制商品、数字内容不支持退货

## 换货政策
- 收到商品后 15 天内,质量问题免费换货
- 换货运费由极速购承担
- 非质量问题换货,运费由买家承担

## 退款流程
1. 在订单详情页申请退款,选择退款原因
2. 等待客服审核,通常 1 个工作日内处理
3. 审核通过后寄回商品,提供快递单号
4. 极速购收到商品后 3 个工作日内退款
5. 退款原路返回,信用卡退款可能需要 3-5 个银行工作日

## 保修政策
- 保修期内免费维修(人为损坏除外)
- 保修需提供购买凭证(订单号或发票)
- 联系售后:400-888-8888,工作日 9:00-18:00

3.7 文档处理流程

RAG 的离线阶段(入库)分四步:加载 → 切分 → 向量化 → 存储。

3.7.1 数据库连接

javascript
// server/src/db/postgres.js
import pg from 'pg';
import 'dotenv/config';

const { Pool } = pg;

export const pool = new Pool({
  host:     process.env.PG_HOST,
  port:     parseInt(process.env.PG_PORT),
  user:     process.env.PG_USER,
  password: process.env.PG_PASSWORD,
  database: process.env.PG_DATABASE,
});

3.7.2 Embedding 模型

课程推荐使用国内模型,两个方案任选其一。

方式一(推荐):智谱 AI

注册地址 https://open.bigmodel.cn,申请 API Key 后在 .env 加入 ZHIPU_API_KEY

javascript
// server/src/models/embedding.js
import { OpenAIEmbeddings } from '@langchain/openai';
import 'dotenv/config';

export const embeddings = new OpenAIEmbeddings({
  modelName:    'embedding-3',
  openAIApiKey: process.env.ZHIPU_API_KEY,
  configuration: {
    baseURL: 'https://open.bigmodel.cn/api/paas/v4',
  },
});
方式二:阿里云百炼(通义)

注册地址 https://bailian.console.aliyun.com,用阿里云账号开通百炼服务后在 .env 加入 DASHSCOPE_API_KEY

javascript
// server/src/models/embedding.js
import { OpenAIEmbeddings } from '@langchain/openai';
import 'dotenv/config';

export const embeddings = new OpenAIEmbeddings({
  modelName:    'text-embedding-v3',
  openAIApiKey: process.env.DASHSCOPE_API_KEY,
  configuration: {
    baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  },
});

两种方式代码结构完全一样,只有 modelNameopenAIApiKeybaseURL 三个字段不同,其余代码不需要改动。

入库(ingest.js)和检索(rag-chain.js)必须使用同一个 Embedding 模型,不能混用。

3.7.3 文档入库脚本

这个脚本只需要执行一次(或者知识库更新时重新执行):

javascript
// server/src/scripts/ingest.js
import { readFileSync }         from 'fs';
import { join, dirname }        from 'path';
import { fileURLToPath }        from 'url';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { PGVectorStore }        from '@langchain/community/vectorstores/pgvector';
import { Document }             from '@langchain/core/documents';
import { embeddings }           from '../models/embedding.js';
import { pool }                 from '../db/postgres.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

// 1. 加载文档
const loadDocs = () => {
  const files = ['products.md', 'policies.md'];
  return files.map((file) => {
    const content = readFileSync(
      join(__dirname, '../data/knowledge', file),
      'utf-8'
    );
    return new Document({
      pageContent: content,
      metadata:    { source: file },
    });
  });
};

// 2. 切分文档
// chunkSize:每个切片的最大字符数
// chunkOverlap:相邻切片的重叠字符数,保证语义不在切割处断裂
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize:    500,
  chunkOverlap: 50,
});

// 3. 向量化并存入 pgvector
const ingest = async () => {
  console.log('开始处理文档...');

  const docs   = loadDocs();
  const chunks = await splitter.splitDocuments(docs);
  console.log(`文档切分完成,共 ${chunks.length} 个片段`);

  const vectorStore = await PGVectorStore.fromDocuments(
    chunks,
    embeddings,
    {
      pool,
      tableName:       'knowledge_embeddings',
      columns: {
        idColumnName:        'id',
        vectorColumnName:    'embedding',
        contentColumnName:   'content',
        metadataColumnName:  'metadata',
      },
    }
  );

  console.log('文档入库完成');
  await pool.end();
};

ingest().catch(console.error);

运行入库脚本:

bash
node src/scripts/ingest.js

3.8 RAG Chain

在线阶段(检索 + 生成)是 RAG 的核心,LangChain.js 的 LCEL 把这个流程表达得很清晰:

javascript
// server/src/chains/rag-chain.js
import { RunnableSequence, RunnablePassthrough } from '@langchain/core/runnables';
import { StringOutputParser }  from '@langchain/core/output_parsers';
import { ChatPromptTemplate }  from '@langchain/core/prompts';
import { PGVectorStore }       from '@langchain/community/vectorstores/pgvector';
import { createModel }         from '../models/deepseek.js';
import { embeddings }          from '../models/embedding.js';
import { pool }                from '../db/postgres.js';

// 1. 初始化向量检索器
const vectorStore = await PGVectorStore.initialize(embeddings, {
  pool,
  tableName: 'knowledge_embeddings',
  columns: {
    idColumnName:       'id',
    vectorColumnName:   'embedding',
    contentColumnName:  'content',
    metadataColumnName: 'metadata',
  },
});

// 每次检索返回最相似的 4 个片段
const retriever = vectorStore.asRetriever({ k: 4 });

// 2. RAG Prompt
// {context} 是检索到的相关文档内容
// {question} 是用户问题
const ragPrompt = ChatPromptTemplate.fromMessages([
  [
    'system',
    `你是极速购电商平台的专业客服助手小购。

请根据以下知识库内容回答用户的问题。
如果知识库中没有相关内容,请如实告知用户,不要编造信息。
回答语气友好,称呼用户为"亲",回复简洁清晰。

知识库内容:
{context}`,
  ],
  ['human', '{question}'],
]);

// 3. 把检索到的文档列表格式化成字符串
const formatDocs = (docs) =>
  docs.map((doc) => doc.pageContent).join('\n\n---\n\n');

// 4. 组装 RAG Chain
// RunnablePassthrough 把输入原封不动传递给下一步
// question 字段直接传给 Prompt,同时也传给 retriever
export const ragChain = RunnableSequence.from([
  {
    context:  retriever.pipe(formatDocs),
    question: new RunnablePassthrough(),
  },
  ragPrompt,
  createModel({ temperature: 0 }),
  new StringOutputParser(),
]);

// 带来源信息的版本,返回检索到的文档片段,便于前端展示引用来源
export const ragChainWithSources = RunnableSequence.from([
  RunnablePassthrough.assign({
    docs: retriever,
  }),
  {
    answer: RunnableSequence.from([
      (input) => ({ context: formatDocs(input.docs), question: input.question }),
      ragPrompt,
      createModel({ temperature: 0 }),
      new StringOutputParser(),
    ]),
    sources: (input) =>
      input.docs.map((doc) => ({
        content:  doc.pageContent.slice(0, 100) + '...',
        source:   doc.metadata.source,
      })),
  },
]);

3.9 RAG 路由接口

javascript
// server/src/routes/rag.js
import express              from 'express';
import { ragChainWithSources } from '../chains/rag-chain.js';

const router = express.Router();

router.post('/query', async (req, res) => {
  const { question } = req.body;

  if (!question) return res.status(400).json({ error: 'question 不能为空' });

  res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');

  const send = (type, data) =>
    res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);

  try {
    const result = await ragChainWithSources.invoke({ question });

    // 先推送引用来源
    if (result.sources?.length) {
      send('sources', { sources: result.sources });
    }

    // 再推送回答
    send('answer', { content: result.answer });
    send('done', {});
    res.end();
  } catch (err) {
    console.error('[RAG Error]', err.message);
    send('error', { content: '查询出错,请重试' });
    res.end();
  }
});

export default router;

server/src/index.js 中注册:

plain
import ragRouter from './routes/rag.js';
app.use('/api/rag', ragRouter);

3.10 前端 RAG 问答界面

RAG 场景的特色是可以展示"答案来源",让用户知道 AI 的回答有据可查,增加可信度。

3.10.1 useRag Composable

javascript
// client/src/composables/useRag.js
import { ref } from 'vue';

const API_BASE = 'http://localhost:3000/api';

export function useRag() {
  const messages = ref([]);
  const loading  = ref(false);
  const error    = ref('');

  const ask = async (question, scrollCallback) => {
    if (!question.trim() || loading.value) return;

    error.value = '';
    messages.value.push({ role: 'user', content: question });
    scrollCallback?.();

    loading.value = true;

    const assistantIndex = messages.value.length;
    messages.value.push({
      role:    'assistant',
      content: '',
      sources: [],
      loading: true,
    });

    try {
      const response = await fetch(`${API_BASE}/rag/query`, {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ question }),
      });

      const reader  = response.body.getReader();
      const decoder = new TextDecoder('utf-8');

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const lines = decoder
          .decode(value, { stream: true })
          .split('\n')
          .filter((l) => l.startsWith('data: '));

        for (const line of lines) {
          try {
            const parsed = JSON.parse(line.slice(6));

            if (parsed.type === 'sources') {
              messages.value[assistantIndex] = {
                ...messages.value[assistantIndex],
                sources: parsed.sources,
              };
            }

            if (parsed.type === 'answer') {
              messages.value[assistantIndex] = {
                role:    'assistant',
                content: parsed.content,
                sources: messages.value[assistantIndex].sources,
                loading: false,
              };
              scrollCallback?.();
            }

            if (parsed.type === 'error') {
              messages.value[assistantIndex] = {
                role:    'assistant',
                content: parsed.content,
                sources: [],
                loading: false,
              };
            }
          } catch {}
        }
      }
    } catch (err) {
      error.value = `请求失败:${err.message}`;
      messages.value.pop();
    } finally {
      loading.value = false;
    }
  };

  const clearMessages = () => {
    messages.value = [];
    error.value    = '';
  };

  return { messages, loading, error, ask, clearMessages };
}

3.10.2 RagView 组件

vue
<!-- client/src/views/RagView.vue -->
<template>
  <div class="rag-page">
    <header class="chat-header">
      <div class="header-left">
        <div class="avatar">购</div>
        <div>
          <h1>极速购知识库问答</h1>
          <span class="subtitle">基于商品手册和售后政策</span>
        </div>
      </div>
      <button @click="clearMessages">清空</button>
    </header>

    <main class="messages-wrap" ref="messagesRef">
      <div v-if="messages.length === 0" class="welcome">
        <p>您好,我可以回答关于商品规格、价格、售后政策等问题。</p>
        <div class="quick-btns">
          <button v-for="q in quickQuestions" :key="q" @click="handleQuick(q)">
            {{ q }}
          </button>
        </div>
      </div>

      <div
        v-for="(msg, i) in messages"
        :key="i"
        class="message-row"
        :class="msg.role"
      >
        <div class="avatar-sm">{{ msg.role === 'user' ? '我' : '购' }}</div>
        <div class="message-content">
          <!-- 加载中 -->
          <div v-if="msg.loading" class="bubble loading-bubble">
            <span class="dot" /><span class="dot" /><span class="dot" />
          </div>
          <!-- 回答 -->
          <div v-else class="bubble">{{ msg.content }}</div>
          <!-- 来源引用 -->
          <div
            v-if="msg.sources && msg.sources.length"
            class="sources-wrap"
          >
            <span class="sources-label">参考来源</span>
            <span
              v-for="(src, si) in msg.sources"
              :key="si"
              class="source-tag"
            >
              {{ src.source }}
            </span>
          </div>
        </div>
      </div>

      <div v-if="error" class="error-tip">{{ error }}</div>
    </main>

    <footer class="input-area">
      <textarea
        v-model="inputText"
        placeholder="输入问题,Enter 发送"
        :disabled="loading"
        @keydown.enter.exact.prevent="handleSend"
        rows="1"
      />
      <button
        class="send-btn"
        :disabled="loading || !inputText.trim()"
        @click="handleSend"
      >
        {{ loading ? '查询中...' : '发送' }}
      </button>
    </footer>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue';
import { useRag } from '../composables/useRag.js';

const { messages, loading, error, ask, clearMessages } = useRag();

const inputText   = ref('');
const messagesRef = ref(null);

const quickQuestions = [
  '蓝牙耳机 X1 Pro 的续航怎么样?',
  '商品可以退货吗?',
  '机械键盘保修多久?',
  '退款需要多少天?',
];

const scrollToBottom = async () => {
  await nextTick();
  if (messagesRef.value)
    messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
};

const handleSend = async () => {
  const text = inputText.value.trim();
  if (!text || loading.value) return;
  inputText.value = '';
  await ask(text, scrollToBottom);
};

const handleQuick = (q) => {
  inputText.value = q;
  handleSend();
};
</script>

<style scoped>
.rag-page {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 780px;
  margin: 0 auto;
  background: #f8fafc;
  font-family: -apple-system, 'PingFang SC', sans-serif;
}

.chat-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 20px;
  background: #fff;
  border-bottom: 1px solid #e2e8f0;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.avatar {
  width: 42px; height: 42px; border-radius: 12px;
  background: #0f766e; color: #fff;
  font-size: 18px; font-weight: 700;
  display: flex; align-items: center; justify-content: center;
}
.header-left h1      { font-size: 16px; font-weight: 600; margin: 0; color: #1e293b; }
.header-left .subtitle { font-size: 12px; color: #94a3b8; }

.messages-wrap {
  flex: 1; overflow-y: auto;
  padding: 20px 16px;
  display: flex; flex-direction: column; gap: 16px;
}

.welcome { text-align: center; padding: 40px 20px; color: #64748b; }
.welcome p { font-size: 15px; margin: 0 0 16px; }
.quick-btns { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.quick-btns button {
  padding: 7px 14px; border-radius: 20px;
  border: 1px solid #99f6e4; background: #f0fdfa;
  color: #0f766e; font-size: 13px; cursor: pointer;
}

.message-row { display: flex; gap: 8px; }
.message-row.user { justify-content: flex-end; flex-direction: row-reverse; }

.avatar-sm {
  width: 32px; height: 32px; border-radius: 10px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 12px; font-weight: 700;
}
.message-row.user .avatar-sm      { background: #2563eb; color: #fff; }
.message-row.assistant .avatar-sm { background: #f0fdfa; color: #0f766e; }

.message-content {
  display: flex; flex-direction: column; gap: 6px;
  max-width: 75%;
}
.message-row.user .message-content { align-items: flex-end; }

.bubble {
  padding: 12px 16px; border-radius: 16px;
  font-size: 14px; line-height: 1.7; white-space: pre-wrap;
}
.message-row.user .bubble {
  background: #2563eb; color: #fff;
  border-bottom-right-radius: 4px;
}
.message-row.assistant .bubble {
  background: #fff; color: #1e293b;
  border-bottom-left-radius: 4px;
  box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}

.loading-bubble {
  display: flex; gap: 5px; align-items: center;
  min-width: 60px;
}
.dot {
  width: 7px; height: 7px; border-radius: 50%;
  background: #94a3b8;
  animation: dot-bounce 1.2s infinite;
}
.dot:nth-child(2) { animation-delay: .2s; }
.dot:nth-child(3) { animation-delay: .4s; }
@keyframes dot-bounce {
  0%, 80%, 100% { transform: translateY(0); opacity: .4; }
  40%           { transform: translateY(-5px); opacity: 1; }
}

.sources-wrap {
  display: flex; align-items: center; gap: 6px;
  flex-wrap: wrap;
}
.sources-label { font-size: 11px; color: #94a3b8; }
.source-tag {
  font-size: 11px; padding: 2px 8px;
  background: #f0fdfa; color: #0f766e;
  border: 1px solid #99f6e4; border-radius: 4px;
}

.error-tip {
  text-align: center; padding: 10px 16px;
  background: #fef2f2; color: #dc2626;
  border-radius: 8px; font-size: 13px;
}

.input-area {
  padding: 14px 16px; background: #fff;
  border-top: 1px solid #e2e8f0;
  display: flex; gap: 10px; align-items: flex-end;
}
textarea {
  flex: 1; resize: none; border: 1px solid #e2e8f0;
  border-radius: 12px; padding: 10px 14px;
  font-size: 14px; font-family: inherit;
  outline: none; background: #f8fafc;
  min-height: 42px; max-height: 120px;
}
textarea:focus    { border-color: #0f766e; background: #fff; }
textarea:disabled { opacity: 0.6; }

.send-btn {
  width: 80px; height: 42px; border-radius: 12px;
  border: none; background: #0f766e; color: #fff;
  font-size: 14px; font-weight: 600; cursor: pointer;
}
.send-btn:hover:not(:disabled) { background: #0d6b62; }
.send-btn:disabled { background: #99f6e4; color: #0f766e; cursor: not-allowed; }
</style>

3.11 chunkSize 怎么选

切片大小直接影响检索质量,没有固定答案,但有几个参考原则:

chunkSize 太小(比如 100 字符):切片失去上下文,比如"保修期一年"被切成"保修期"和"一年",检索到的片段单独看没有意义。

chunkSize 太大(比如 2000 字符):一个片段包含太多内容,会把不相关的信息也带入 Prompt,干扰 LLM 的判断,同时消耗更多 token。

一般原则:

  • 结构化文档(FAQ、政策条款):chunkSize 300~500,chunkOverlap 50
  • 长文档(技术手册、报告):chunkSize 800~1000,chunkOverlap 100
  • 对话记录:chunkSize 200~300,按轮次切分比按字数切分更合理

本章的商品手册和售后政策用 500 / 50 是合适的。


3.12 RAG 效果调优

检索数量 k

retrieverk 参数控制返回多少个相似片段。k 太小可能漏掉关键信息,k 太大会把噪声也带进来。一般从 4 开始,根据实际效果调整。

相似度阈值

可以给 retriever 设置相似度阈值,过滤掉相关性不够高的片段:

javascript
const retriever = vectorStore.asRetriever({
  k: 4,
  filter: { similarityThreshold: 0.75 },
});
重排序(Reranking)

检索到 10 个片段,用一个小模型对它们重新排序,取 Top-4 再传给 LLM。对于知识库较大的场景,这个步骤可以明显提升答案质量,第四章 LangGraph 部分会进一步演示。

Prompt 里的指令

在 RAG Prompt 的 system 消息里明确告诉 LLM"如果知识库中没有相关内容,请如实告知",可以有效防止 LLM 在检索结果不相关时仍然凭空生成答案。


3.13 运行 Demo

bash
# 第一步:启动 pgvector
docker start pgvector-dev

# 第二步:入库(只需执行一次)
cd server
node src/scripts/ingest.js

# 第三步:启动服务
pnpm dev

# 第四步:前端注册路由(router/index.js)
# { path: '/rag', component: RagView }
pnpm dev  # 访问 http://localhost:5173/rag

测试问题:

  • "蓝牙耳机 X1 Pro 续航多少小时" → 应该给出 30 小时的准确回答
  • "最新的 iPhone 16 有货吗" → 知识库里没有,应该告知用户无相关信息
  • "退款要多少天" → 应该综合退款流程里的步骤给出完整回答

3.14 常见问题

Q:执行 ingest.js 报错 relation does not exist

pgvector 扩展没有开启,连接数据库后执行 CREATE EXTENSION IF NOT EXISTS vector;

Q:检索结果不相关,回答质量差

先检查入库是否成功(SELECT count(*) FROM knowledge_embeddings;),再检查 chunkSize 是否合理,最后检查问题的 Embedding 和文档的 Embedding 是否使用了同一个模型。

Q:Embedding API 调用失败

检查 .env 里对应的 Key 是否正确填写(智谱用 ZHIPU_API_KEY,百炼用 DASHSCOPE_API_KEY),以及 embedding.js 里的 baseURL 是否和选择的平台匹配。入库和检索两处必须使用同一套配置。

Q:知识库更新后答案没变

重新运行 ingest.js 会在数据库里追加新数据,旧数据仍然存在。如果是全量更新,先执行 TRUNCATE knowledge_embeddings; 清空表再重新入库。


3.15 本章小结

知识点 掌握内容
RAG 原理 检索增强生成的完整流程,解决知识截止和私有数据问题
向量嵌入 文本转向量,语义相似度搜索的原理
pgvector Docker 启动,扩展开启,PGVectorStore 配置
文档处理 加载 → 切分(RecursiveCharacterTextSplitter)→ 向量化 → 存储
RAG Chain RunnableSequence 组装检索 + 生成流程,ragChainWithSources
调优方向 chunkSize 选择,k 值,相似度阈值,Prompt 指令

下一章:LangGraph 多 Agent 编排,把前三章的能力组合起来,构建一个可以自主规划、多步执行的复杂 Agent 系统。