我曾经帮助过一个团队,他们的API文档是一份长达200页的PDF文件。每位新加入的工程师在最初的两周里都会反复使用Ctrl+F搜索文档内容,在Slack上不断提出相同的问题,结果却总是被引导到第47页的同一段文字中。

这份文档的内容本身是准确的,表述也相当清晰。但没有人能够快速从中找到有用的信息。

而RAG技术正是为了解决这个问题而存在的。

一种简单的处理方法是直接将整个PDF文件作为输入传递给模型,让模型自己去理解其中的内容。然而这种方法很快就会遇到问题:上下文范围过大导致模型难以有效处理信息,每次请求都会产生高昂的计算成本,而且模型也会在海量文本中迷失方向。

RAG采取了不同的方法。它会在一开始就将文档分解成若干小部分,当你向系统提出问题时,它会从中筛选出与问题最相关的那3到4个部分作为模型的输入。这样一来,模型就能获得清晰、聚焦的上下文信息,而答案也会直接来自文档本身的内容——而不是大型语言模型在训练过程中记忆的信息。

在这个教程中,你将从零开始学习如何构建这样的系统。只需上传任何PDF文件(无论是API参考手册、内部技术规范还是研究论文),然后用简单的英语向系统提出问题,系统就会从文档本身中找到相关的答案,而不会依赖通用的训练数据。

所使用的技术栈包括:基于Node.js和Express的开发框架、用于生成文本的Groq工具,以及运行在Docker容器中的pgvector库。所有这些组件都是免费的,无需使用信用卡或尝试期。

完整的代码托管在GitHub上,地址为:nodejs-rag-chatbot

目录

RAG的工作原理

RAG技术包含两个阶段,相应的代码也直接对应这两个阶段。

数据导入阶段**——在您上传文档时只会运行一次:

  1. 从PDF文件中提取原始文本。

  2. 将这些文本分割成每段400到600个字符的长度,同时确保各段落之间存在一定的重叠,这样就不会有任何重要的内容被截断在段落边界处。

  3. 将每一段文本输入嵌入模型进行处理,使其转化为向量形式(即一段由数字组成的长列表,该列表能够表达文本的含义)。

  4. 将每段文本及其对应的向量存储到Postgres数据库中。

查询阶段——每当有人提出问题时,这一阶段就会被执行:

  1. 使用相同的模型对用户提出的问题进行编码处理。

  2. 在数据库中搜索那些向量与问题向量最为接近的数据片段。

  3. 选取其中最匹配的5个数据片段,将它们组合成一个上下文块。

  4. 将这个上下文块与用户的问题一起发送给大语言模型,然后获取它的回答。

这种处理方式之所以比关键词搜索更有效,是因为嵌入模型能够捕捉到词语的含义,而不仅仅是字面文字。如果你的文档中写着“终止进程”,而用户询问“该如何停止它?”,那么向量相似性算法就能找到相应的匹配结果;而普通的字符串匹配方法则无法做到这一点。

需要注意的一点是:在查询时必须使用与数据录入时相同的嵌入模型。这个模型决定了这些向量所处的几何空间。如果在中间阶段更换了模型,那么这些向量的坐标所代表的含义就会发生变化——这相当于在比较完全不同的东西。

我们正在构建什么

我们的系统架构被设计得非常简洁:只有两个端点,且没有任何不必要的组件:

  • POST /ingest:接收PDF文件上传,将其分割成多个数据片段,对每个片段进行编码处理,然后将这些向量存储在pgvector数据库中。

  • POST /chat:接收用户提出的问题,检索出最相关的信息片段,然后返回由大语言模型生成的答案。

完整的技术栈如下:

  • Node.js + Express——API层

  • Google Gemini免费API——用于数据编码处理,每个数据片段的向量维度为3,072。

  • Groq免费API——用于文本生成

  • PostgreSQL + pgvector——用于存储向量数据并执行余弦相似性搜索,这些服务都在Docker环境中运行。

  • pdf-parse——用于从PDF文件中提取原始文本。

Google Gemini负责数据编码处理,而Groq则负责文本生成。将这两项功能分配给不同的服务提供商并非随意决定。在某些地区(包括巴基斯坦),Google Gemini的文本生成API有使用量限制,而Groq则可以在任何地方无限制地使用。因此,无论你位于哪里,本教程中的操作步骤都是相同的。

先决条件

在开始之前,请确保满足以下要求:

  • 你的计算机上已经安装了Node.js 20及以上版本。

  • Docker Desktop已启动运行(我们将会使用它来在本地运行Postgres数据库)。

  • 你需要一个免费的Google Gemini API密钥,用于数据编码处理。

  • 你还需要一个免费的Groq API密钥,用于文本生成。

如何获取免费的Google Gemini API密钥

  1. 访问aistudio.google.com/app/apikey,使用你的Google账户登录。

  2. 点击“创建API密钥”。

  3. 选择“在新项目中创建API密钥”。

  4. 复制生成的API密钥——它的开头通常是AIzaSy...

无需使用信用卡或进行账单支付。

如何获取免费的Groq API密钥

  1. 访问console.groq.com,使用Google账户进行注册

  2. 在左侧侧边栏中点击“API密钥”

  3. 点击“创建API密钥”,为其指定一个名称,然后复制该密钥——它的开头通常是gsk_...

Groq是免费使用的,同时提供了较为宽松的调用限制,并且可以在所有地区使用。

项目设置

创建项目目录并对其进行初始化:

mkdir nodejs-rag-chatbot
cd nodejs-rag-chatbot
npm init -y

安装所需的依赖包:

npm install express pg pdf-parse uuid dotenv multer
npm install --save-dev nodemon

关于这些依赖包的简要说明:multer使得文件上传功能能够在/ingest端点上正常使用;如果没有这个包,Express就无法解析多部分数据格式。

pdf-parse用于处理PDF文件,但需要注意:扫描生成的PDF文件实际上只是图片,其中并不包含任何文本内容,因此使用pdf-parse处理这类文件时可能会得到空字符串作为结果。

pg用于与Postgres数据库进行交互;uuid为每条数据记录生成唯一的ID;而dotenv则会在应用程序开始运行之前加载所有的配置信息。

在项目根目录下创建一个.env文件,其中需要设置以下七项内容:

GEMINI_API_KEY=AIzaSy...         ← 从Google AI Studio获取的Gemini API密钥
GROQ_API_KEY=gsk_...             ← 从console.groq.com获取的Groq API密钥
POSTGRES_USER=rag_user
POSTGRES_PASSWORD=rag_pass       ← 可随意设置此密码,该密码仅用于本地开发环境
POSTGRES_DB=rag_db
DATABASE_URL=postgresql://rag_user:rag_pass@localhost:5432/rag_db
PORT=3000

需要注意的一点是:POSTGRES_PASSWORD中设置的密码必须与DATABASE_URL中指定的密码完全一致。我曾经不小心更改了其中的一个密码,结果花了很长时间才弄清楚为什么会出现“密码认证失败”的错误,后来才发现这两个密码是不匹配的。

更新package.json文件中的脚本配置:

"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}

创建src目录:

mkdir src

最终的项目文件夹结构应该如下所示:

nodejs-rag-chatbot/
├── src/
│   ├── index.js        ← Express应用程序的入口文件
│   ├── db.js           ← 用于连接Postgres数据库并配置数据库结构
│   ├── embeddings.js   ← 处理Gemini模型生成的相关逻辑
│   ├── ingest.js       ← 负责文档导入的处理流程
│   └── query.js        ← 负责执行RAG查询的相关代码
├── docker-compose.yml
├── .env
└── package.json

使用Docker配置Postgres与pgvector

`pgvector`为Postgres添加了一种名为`vector`的列类型,同时还提供了用于基于相似性进行搜索所需的操作函数。通常情况下,你需要自行安装这个扩展,但`pgvector/pgvector` Docker镜像已经包含了这一功能,因此你只需下载该镜像即可使用。

现在,将 docker-compose.yml 文件添加到项目根目录中:

services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

当 Compose 启动时,${VARIABLE} 这些占位符会从 .env 文件中取值进行替换——这样一来,docker-compose.yml 文件本身就能保持整洁。从项目刚开始就采用这种做法是非常有必要的。我见过有些人跳过这一步,结果在代码库公开后才后悔。

现在启动 Compose 服务:

docker compose up -d

连接数据库

创建 src/db.js 文件。这个文件用于配置连接池,并在程序首次运行时创建 documents 表:

const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

async function initDb() {
  await pool.query(`CREATE EXTENSION IF NOT EXISTS vector`);
  await pool.query(`
    CREATE TABLE IF NOT EXISTS documents (
      id UUID PRIMARY KEY,
      content TEXT NOT NULL,
      source TEXT NOT NULL,
      embedding VECTOR(3072)
    )
  `);

  console.log('数据库已准备就绪');
}

module.exports = { pool, initDb };

VECTOR(3072) 这个维度与 Gemini 的 gemini-embedding-001 模型的输出结果完全一致。如果将来你使用其他嵌入模型,请检查该模型的输出维度,并相应地调整这个数值。

构建数据摄入管道

首先从 embeddings.js 文件开始。这个文件是连接外部 API 的桥梁——通过它,可以将文本转换为向量数据,同时也可以使用 Groq 生成最终答案。将这两个功能集中在同一个文件中,意味着如果你以后更换服务提供商,只需要修改这一个文件即可。

src/embeddings.js:

const GEMINI_KEY = process.env.GEMINI_API_KEY;
const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1/models';

async function embedText(text) {
  const res = await fetch(
    `${GEMINI_BASE}/gemini-embedding-001:embedContent?key=${GEMINI_KEY}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: { parts: [{ text }] } }),
    }
  );
  const data = await res.json();
  if (!res.ok) throw new Error(JSON.stringify(data));
  return dataembedding.values;
}

async function generateAnswer(context, question) {
  const res = await fetch(
    'https://api.groq.com/openai/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
      },
      body: JSON.stringify({
        model: 'llama-3.1-8b-instant',
        messages: [
          {
            role: 'system',
            content: '你是一个有帮助的助手。请仅使用提供的上下文来回答这个问题。如果上下文中的信息不足,请明确说明。'
          },
          {
            role: 'user',
            content: `上下文:\n${context}\n\n问题:${question}`,
          },
        ],
      }),
    }
  );
  const data = await res.json();
  if (!res.ok) throw new Error(JSON.stringify(data));
  return data.choices[0].message.content;
}

module.exports = { embedText, generateAnswer };

我们直接使用 Node.js 内置的 `fetch` 函数来调用这两个 API,而没有使用官方 SDK。这样做的原因很实际:Google 的 Node.js SDK 默认会通过 `v1beta` 端点来处理请求,而 `gemini-embedding-001` 这个功能在 `v1beta` 端点是不可用的,只有在 `v1` 端点才能使用。直接使用 `fetch` 函数可以完全避开这一限制,同时还能降低代码中对其他库的依赖程度。

src/ingest.js:

const pdfParse = require('pdf-parse');
const { v4: uuidv4 } = require('uuid');
const { pool } = require('./db');
const { embedText } = require('./embeddings');

function chunkText(text, chunkSize = 500, overlap = 50) {
  const chunks = [];
  let start = 0;

  while (start < text.length) {
    const end = Math.min(start + chunkSize, text.length);
    chunks.push(text.slice(start, end).trim());
    start += chunkSize - overlap;
  }

  return chunks.filter(chunk => chunk.length > 50);
}

async function ingestDocument(buffer, filename) {
  const { text } = await pdfParse(buffer);
  const chunks = chunkText(text);

  console.log(`正在处理来自 "${filename}" 的 ${chunks.length} 个数据块`);
  for (const chunk of chunks) {
    const embedding = await embedText(chunk);

    await pool.query(
      `INSERT INTO documents (id, content, source, embedding)
       VALUES ($1, $2, $3, $4::vector)`,
      [uuidv4(), chunk, filename, JSON.stringify.embedding)]
    );
  }

  return chunks.length;
}

module.exports = { ingestDocument };

每个数据块包含 500 个字符,相邻的数据块之间会有 50 个字符的重叠部分。

为什么要设置重叠部分呢?如果不设置这个重叠区域,那些跨越边界的语句就会被分割开来,一半属于一个数据块,另一半属于另一个数据块;这样,在获取到这些数据块后,它们就无法被正确地解读了。设置重叠部分就可以保证这些跨越边界的句子能够保持完整。

对于大多数技术文档来说,500 个字符作为一个数据块的长度是合适的;而对于那些内容较为密集的法律或金融文本,可能需要将每个数据块的长度设置为 300 个字符左右。

构建查询管道

src/query.js:

const { pool } = require('./db');
const { embedText, generateAnswer } = require('./embeddings');

async function queryDocuments-question) {
  const questionEmbedding = await embedText(question);

  const { rows } = await pool.query(
    `SELECT content, source,
            1 - (embedding <=> $1::vector) AS similarity
     FROM documents
     ORDER BY embedding <=> $1::vector
     LIMIT 5`,
    [JSON.stringify/questionEmbedding)]
  );

  if (rows.length === 0) {
    return { answer: '没有找到相关的文档。', sources: [] };
  }

  const context = rows.map(r => r.content).join('\n\n---\n\n');
  const answer = await generateAnswer(context, question);

  return {
    answer,
    sources: [...new Set(rows.map(r => r.source))],
    topSimilarity: parseFloat(rows[0].similarity).toFixed(3),
  };
}

module.exports = { queryDocuments };

<=>运算符实际上代表了pgvector算法所计算出的余弦距离。在语义上相似的文本会生成方向相同的向量,因此它们之间的距离会很小;而通过使用1 - distance这个表达式,就可以得到一个相似度分数——当这个分数接近1时,就说明这两段文本的匹配程度非常高。

在我的测试中,我发现0.7这个数值是一个可靠的阈值——超过这个阈值的数据片段几乎总是与问题相关的;而低于0.5的阈值时,检索结果就会变得杂乱无章,那些只是包含一两个关键词、但实际上并不能回答问题的数据片段也会被包括进来。

当出现这种情况时,系统提示指令(“如果上下文信息不足,请明确说明”)就显得非常重要了。一个表现良好的模型会告诉用户它不知道答案,而不是随意猜测。

我们还会显示原始文件的名称。当你上传了多份文档后,用户需要知道这个答案是来自架构规范还是事故报告。

使用Express构建聊天API

src/index.js:

require('dotenv').config();
const express = require('express');
const multer = require('multer');
const { initDb } = require('./db');
const { ingestDocument } = require('./ingest');
const { queryDocuments } = require('./query');

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

app.use(express.json());

app.post('/ingest', upload.single('file'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: '没有上传文件' });
  }

  if (!req.file.mimetype.includes('pdf')) {
    return res.status(400).json({ error: '仅支持PDF格式的文件' });
  }

  try {
    const count = await ingestDocument(req.file.buffer, req.file.originalname);
    res.json({ message: `已从 "${req.file.originalname}"中提取了${count}个数据片段` });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: '提取失败', detail: err.message });
  }
});

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

  if (!question || typeof question !== 'string') {
    return res.status(400).json({ error: '问题字段是必填项' });
  }

  try {
    const result = await queryDocuments/question);
    res.json(result);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: '查询失败', detail: err.message });
  }
});

const PORT = process.env.PORT || 3000;

initDb().then(() => {
  app.listen(PORT, () => {
    console.log(`RAG聊天机器人正在端口${PORT}上运行`);
  });
});

memoryStorage()这个方法会将上传的文件保存在内存缓冲区中,而不是将其写入磁盘。我们会立即解析这些文件并存储其中的数据片段,因此实际上并没有什么需要保存的内容。

测试聊天机器人

启动服务器:

npm run dev

你应该会看到以下提示:

数据库已准备就绪
RAG聊天机器人正在端口3000上运行

上传一份PDF文件即可。任何格式的PDF文件都可以使用。我测试时使用的是一份Node.js最佳实践指南的副本:

# Linux / macOS
curl -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

# Windows PowerShell
curl.exe -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

响应:

{"message": "已从\"your-document.pdf\"中提取了142个数据块。"}

现在来提一个问题:

# Linux / macOS
curl -X POST http://localhost:3000/chat \
  -H "Content-Type: application/json" \
  -d '{ "question": "在异步函数中,应该如何处理错误呢?" }'

# Windows PowerShell
curl.exe -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d "{\"question\": \"在异步函数中,应该如何处理错误呢?"}"

响应:

{
  "answer": "在Node.js中,对于异步函数,应该使用try/catch块来处理被拒绝的Promise。在Express框架中,可以将捕获到的错误传递给next(err),从而触发相应的错误处理中间件。另外,也可以创建一个包装函数,将任何异步路由处理函数封装在一个Promise对象中,当Promise被拒绝时再调用next,这样就可以保持你的路由处理函数代码的简洁性。",
  "sources": ["your-document.pdf"],
  "topSimilarity": "0.841"
}

topSimilarity分数可以说明搜索结果的质量:如果这个分数高于0.7,那么提取到的数据块确实与查询内容相关;如果低于0.5,说明搜索效果不佳——虽然找到了一些信息,但这些信息并不能真正回答用户提出的问题。

你可以尝试询问一些PDF文档中没有提到的话题。如果系统能够正确响应,它应该会提示“缺乏足够的信息”,而不会随意生成答案。这种行为才是我们在实际生产环境中所期望看到的。

该代码库中包含了两个诊断脚本,如果在使用过程中遇到任何问题,这些脚本会非常有用:

  • node test-keys.js — 用于测试API密钥的有效性,并报告每个密钥的使用结果

  • node list-models.js — 获取与你的API密钥相关的所有Gemini模型列表

在开始查看下面的故障排除方法之前,请先运行这些脚本。

故障排除

本节中提到的所有问题都是我在开发过程中实际遇到的,没有任何假设性的内容。

端口5432已被占用

错误:地址已有人使用

可能是系统中已经安装了其他软件(比如本地的Postgres数据库),因此该端口被占用了。有两种解决方法:首先,在docker-compose.yml文件中修改配置:

ports:
  - "5433:5432"

其次,需要更新.env文件中的DATABASE_URL设置:

DATABASE_URL=postgresql://rag_user:rag_pass@localhost:5433/rag_db

实际上,容器本身仍然会在内部使用端口5432进行通信。你所做的只是更改了客户端连接该容器时使用的端口号而已。

用户“rag_user”的密码认证失败

错误:用户“rag_user”的密码认证失败

Postgres初始设置时使用的密码与您的应用程序发送的密码不一致。请打开.env文件,将POSTGRES_PASSWORDDATABASE_URL中包含的密码进行对比,确保两者完全相同。

在修正这一差异后,旧的数据卷中仍然保存着错误的密码。因此,您必须删除该数据卷并重新创建:

docker compose down -v
docker compose up -d

使用-v选项会删除数据卷。下次Postgres启动时,它会使用当前.env文件中指定的密码进行登录。

未找到Gemini模型(404错误)

Google AI模型的命名方式已经发生了变化。一些旧的教程和博客文章中提到的模型名称,在v1端点上已不再存在。因此,适用于当前环境的正确模型是gemini-embedding-001,这个仓库也是使用该模型。

如果您想查看自己的API密钥可以使用的所有模型,请运行以下命令:

node list-models.js

该脚本会直接从API获取模型列表,因此您无需自行猜测。

向量维度不匹配

错误:预期维度为768,但实际上为3072

当您的数据库表创建时指定的维度数量与所使用的嵌入模型生成的向量维度不一致时,就会出现这个错误。gemini-embedding-001生成的向量维度为3,072,而本教程中的documents表使用的是VECTOR(3072)类型,因此才会出现这种不匹配的情况。

如果遇到这个错误,说明要么存在一个维度设置错误的旧表,要么您在更换嵌入模型后没有重新创建相应的表。请删除旧数据卷并重新启动系统:

docker compose down -v
docker compose up -d

向量索引维度限制

错误:ivfflat索引类型最多只支持2000个维度

pgvector的ivfflathnsw索引类型的最大维度限制为2000。由于gemini-embedding-001生成的向量维度为3,072,因此这两种索引类型都无法正常使用。

本教程会删除这些索引,然后让pgvector进行全量扫描——这对于开发环境或规模较小的数据集来说是可以接受的。但如果需要在生产环境中处理数千份文档,就应该选择维度低于2000的模型。OpenAI的text-embedding-3-small模型生成的向量维度为1536,适用于这两种索引类型。

端口3000已被占用

错误:EADDRINUSE:地址已有人使用 :::3000

其他进程已经占用了端口3000。请修改.env文件中的端口号。

PORT=3002

保存设置后,重新启动服务器。

使用Gemini生成功能时出现配额超出限制的错误(限制值为0)

“限制值为0”这一情况意味着谷歌已经完全停止了在您所在国家提供免费的生成服务,并非您的配额已经被用完了。我在从巴基斯坦进行测试时也遇到了这个问题。

正因为如此,本教程才改用了Groq。请确保 `GROQ_API_KEY` 已经被添加到 `.env 文件中,并且 `src/embeddings.js` 文件中的 `generateAnswer` 函数所指向的地址确实是 `api.groq.com`。

node test-keys.js

运行这个命令可以独立测试Gemini嵌入端点和Groq生成端点,从而确认它们是否都能正常工作。

nodemon无法检测到 `.env 文件中的更改

nodemon只会监控 `.js` 文件,因此对 `.env` 文件的修改不会触发服务器重新启动。请切换到运行服务器的终端窗口,输入 `rs` 然后按回车键,这样就能强制重启服务器并应用您所做的更改。

curl 在Windows PowerShell中无法使用

curl : 未识别到“curl”命令

或者

curl : 因参数“Method”被多次指定,导致无法绑定该参数

PowerShell内置了一个名为 `curl` 的别名,但实际上它指的是 `Invoke-WebRequest` 命令——这两个命令的参数格式和执行结果完全不同。如果想要直接使用真正的 `curl` 命令,需要在命令前加上 `.exe` 后缀。

# 使用curl.exe进行数据上传
curl.exe -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

# 进行聊天请求
curl.exe -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d "{\"question\": \"如何处理异步错误?\"}"
添加“.exe”后缀就可以解决这个问题。

Docker Desktop停止运行了

在大多数情况下,重新启动计算机后,Docker Desktop并不会自动启动。如果您的Docker命令突然出现连接错误,很可能就是这个原因造成的。请打开Docker Desktop,等待系统显示“引擎正在运行”的提示,然后再尝试执行命令。

如何切换到使用OpenAI API

如果您想改用OpenAI API而不是Gemini,只需要进行三处修改即可。

1. 安装OpenAI SDK:

npm install openai

2. 完全替换 `src/embeddings.js` 文件中的相关代码:

const OpenAI = require('openai');

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function embedText(text) {
const result = await client.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return result.data[0].embedding;
}

async function generateAnswer(context, question) {
const result = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '请仅根据提供的上下文来生成答案。如果上下文信息不足,请说明。' },
{ role: 'user', content: `上下文:\n${context}\n\n问题:${question}` },
],
});
return result.choices[0].message.content;
}

module.exports = { embedText, generateAnswer };

3. 在src/db.js文件中修改向量尺寸:

打开db.js文件,将VECTOR(3072)替换为VECTOR(1536)——这个数值正是text-embedding-3-small模型的输出结果尺寸。之后重启Docker容器,以便数据库能够使用正确的尺寸重新创建:

docker compose down -v
docker compose up -d

其他部分无需进行任何修改。无论使用哪种模型,数据的导入和查询逻辑都依然有效。

接下来该做什么

你们目前开发的系统已经可以正常使用了,但当真正将其交给用户使用时,还是会发现一些问题。

最明显的问题就是数据流式传输。目前,/chat接口会保持连接状态,直到Groq完成整个答案的生成,然后再一次性将所有结果返回给用户。对于简短的问题来说,这种处理方式没有问题;但对于较长的问题,用户可能会等待几秒钟,甚至怀疑请求是否已经失败。

Groq API实际上支持数据流式传输——只需在请求体中添加stream: true选项,Groq就会在生成答案的过程中逐步将结果返回过来。使用Express框架并通过res.write()函数将这些结果呈现给用户,最多只需要花费15分钟的时间,但用户体验会得到显著改善。

元数据过滤

也是你需要实现的功能之一。当你加载了大量的文档后,查询结果中就会包含各种无关的信息;例如,如果你查询API规范,也会得到入门指南中的相关内容。

解决这个问题的方法是为每份文档添加一个metadata JSONB字段,在数据导入时存储文档的ID;在进行相似度查询时,添加WHERE metadata->>'doc_id' = $1条件。你还可以在/chat接口中将其作为可选的请求参数:{ "question": "...", "docId": "api-spec-v2" }。这样,用户就能得到更有针对性的查询结果,而你的系统也会运行得更加高效。

当你的文档数量达到数百份时,可以考虑使用重新排序功能。向量相似度检索虽然速度很快,但得到的结果只是近似匹配的结果——它找到的可能是与问题在语义上相近的文档,并不一定是最能直接回答问题的文档。

解决这个问题的方法是:首先根据余弦距离筛选出排名前20的文档,然后对这些文档使用交叉编码器重新计算它们的相关性得分,最后从中选出排名最高的5份文档作为最终结果。如果你不想自己实现这个功能,LangChain.js也提供了相关的封装代码。

大多数人在真正需要用到它之前,往往会忘记文档管理这一功能——也就是列出已被处理的文件列表、删除特定文件,或重新上传已更新版本的文件。

使用`DELETE FROM documents WHERE source = $1`这条指令就可以实现文件的删除操作;再添加一个`GET /documents`接口,让它执行`SELECT DISTINCT source FROM documents`这条查询语句,那么你就拥有了一套可以实际使用的完整API了。

RAG并不是什么神奇的工具。它其实只是一个设计得当的信息检索系统,再加上一个被要求严格遵循自身功能范围的语言模型而已。

你得到的回答质量取决于三个因素:PDF文件被解析的准确性、文件分块的大小是否与内容类型相匹配,以及你的系统提示是否能够清晰地引导语言模型选择“不知道”而不是进行猜测。只要把这些方面都处理得当,你就真正开发出了有用的工具——这种工具能够帮助新工程师顺利度过入职后的前两周时间。

Comments are closed.