深入浅出RAG——第7章:基础篇实战:文档问答机器人

📖 本章学习目标

  • 从零搭建一个可运行的 RAG 文档问答 CLI 工具
  • 将 CLI 工具扩展为 REST API 服务,提供 HTTP 访问能力
  • 实现多轮对话中的上下文管理,让机器人记住之前的对话内容
  • 组织 RAG 项目的目录结构和环境配置

《第6章:构建第一条 RAG 流水线》中,我们跑通了第一条完整的 RAG 流水线。但零散的代码片段还称不上是一个应用。本章是基础篇的收官实战,基于前六章的所有知识,构建一个用户真正可以使用的文档问答机器人。代码会覆盖 CLI 命令行工具和 REST API 服务两种形态。

这是你第一次将理论知识转化为实际可用的产品,也是从"学习者"到"开发者"的关键跨越。所以建议你要不只是阅读,而是跟着文中的思路来构建一个简单的RAG应用。

本文将专注于实战,因此内容会以代码为主,仅对一些难以理解或者未提及过的知识点做补充说明,其它所有用到的理论知识都在前面的篇章做了详细讲解。

一、需求定义与技术选型

1. 功能需求

我们要构建的文档问答机器人需要具备以下核心能力:

(1)知识库管理

  • 用户指定一个文档目录,系统自动加载并索引为知识库
  • 支持多种文档格式(Markdown、TXT、PDF)
  • 索引完成后持久化存储,无需每次重启都重新索引

(2)智能问答

  • 用户通过命令行或 HTTP 接口提问
  • 系统基于知识库检索相关文档,生成准确答案
  • 每次回答附带引用来源,标明信息出自哪个文档

(3)多轮对话

  • 支持连续对话,机器人能记住之前的对话内容
  • 理解上下文中省略的指代(如"它"、"那个")
  • 提供清空对话历史的功能

(4)可观测性

  • 显示检索到的文档片段
  • 记录查询日志,便于后续分析和优化

2. 技术选型

组件 选择 理由
Embedding OpenAI text-embedding-3-small 成本低,入门友好,中文支持好
向量数据库 Chroma 嵌入式运行,无需额外服务,适合开发阶段
LLM OpenAI gpt-4o 生成质量稳定,指令遵循能力强
CLI 框架 Node.js readline 零依赖,标准库自带
HTTP 框架 Express 生态成熟,示例简洁,易于扩展
环境变量管理 dotenv 安全地管理 API Key 等敏感信息

部分技术选型的考量因素如下:

  • Chroma vs Pinecone :开发阶段选择 Chroma 是因为它可以在本地运行,无需注册账号和配置云服务。生产环境可以无缝切换到 Pinecone 或 Qdrant(见《第3章:向量数据库入门》
  • Express vs Fastify:Express 虽然性能不是最优,但生态最成熟,学习资源最多。对于原型验证完全足够
  • readline vs Commander.js:readline 是 Node.js 标准库,零依赖。如果后续需要更复杂的命令行参数解析,再引入 Commander.js

3. 项目结构

bash 复制代码
rag-qa-bot/
├── src/
│   ├── indexer.ts     # 索引管道:加载文档 → 建库
│   ├── retriever.ts   # 检索器封装
│   ├── generator.ts   # 生成器:Prompt 组装 + LLM 调用
│   ├── cli.ts         # CLI 入口
│   └── server.ts      # REST API 入口
├── docs/              # 默认文档目录
│   ├── deploy-guide.md
│   ├── api-docs.md
│   └── faq.md
├── .env               # API Key 等环境变量
├── .gitignore         # 忽略敏感文件和缓存
├── package.json       # 项目依赖和脚本
└── README.md          # 使用说明

这种分层设计是比较常见的实践,它有几个明显的好处: 职责清晰 (每个模块只负责一个功能,便于测试和维护)、 易于扩展 (如果需要支持新的文档格式或向量数据库,只需修改对应模块)、可复用性强indexerretrievergenerator 可以在其他项目中复用)。

二、环境准备

1. 初始化项目

bash 复制代码
# 创建项目目录
mkdir rag-qa-bot && cd rag-qa-bot

# 初始化 npm 项目
npm init -y

# 安装依赖
npm install langchain @langchain/openai @langchain/community chromadb express dotenv
npm install -D @types/node @types/express typescript ts-node

# 初始化 TypeScript
npx tsc --init

配置 tsconfig.json

json 复制代码
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

2. 配置环境变量

创建 .env 文件:

bash 复制代码
OPENAI_API_KEY=sk-your-api-key-here
CHROMA_HOST=http://localhost:8000
PORT=3000

创建 .gitignore 文件:

gitignore 复制代码
node_modules/
dist/
.env
*.log

在代码顶部加载环境变量:

typescript 复制代码
import dotenv from "dotenv";
dotenv.config();

3. 准备测试文档

docs/ 目录下准备 3-5 篇 Markdown 文档作为测试数据。例如:

docs/deploy-guide.md

markdown 复制代码
# 部署指南

## 预发环境

预发环境地址为xxx.xxx,需要通过内网访问。

部署步骤:
1. 代码审查并合并到 develop 分支
2. 构建 Docker 镜像
3. 使用 kubectl 部署到 staging namespace
4. 验证功能是否正常

## 生产环境

生产发布由 SRE 团队负责,需要提交 Jira 工单并获得批准。

docs/api-docs.md

markdown 复制代码
# API 文档

## 认证方式

所有 API 请求需要在 Header 中携带 Authorization Token:

    ```
Authorization: Bearer YOUR_TOKEN
    ```

Token 有效期为 24 小时,过期后需要重新获取。

## 速率限制

未认证用户:每小时 60 次请求
认证用户:每分钟 100 次请求

三、实现索引管道

索引管道负责将原始文档转换为向量并存入数据库。这是一个离线批处理任务。

typescript 复制代码
// src/indexer.ts
import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { ChromaClient } from "chromadb";
import { readdirSync, statSync } from "fs";
import { join, extname } from "path";
import { Document } from "@langchain/core/documents";

interface IndexerOptions {
  docsDir: string;           // 文档目录
  collectionName: string;    // Collection 名称
  chunkSize?: number;        // 切分大小
  chunkOverlap?: number;     // 重叠大小
}

export async function buildIndex(options: IndexerOptions): Promise<Chroma> {
  const {
    docsDir,
    collectionName,
    chunkSize = 500,
    chunkOverlap = 50,
  } = options;

  console.log(`\n【索引管道】开始处理文档目录: ${docsDir}`);
  
  // 扫描文档目录
  const files = scanDocuments(docsDir);
  console.log(`找到 ${files.length} 个文档文件`);
  
  if (files.length === 0) {
    throw new Error(`文档目录为空: ${docsDir}`);
  }

  // 加载和切分所有文档
  let allChunks: Document[] = [];
  
  for (const file of files) {
    console.log(`  处理: ${file}`);
    
    try {
      const chunks = await loadAndSplitFile(file, chunkSize, chunkOverlap);
      allChunks.push(...chunks);
    } catch (error) {
      console.error(`    ❌ 处理失败: ${error.message}`);
      // 继续处理其他文件,不中断整个流程
    }
  }

  console.log(`\n✓ 文档加载完成,共 ${allChunks.length} 个片段`);

  // 向量化和存储
  console.log("正在生成向量并存储...");
  
  const embeddings = new OpenAIEmbeddings({
    modelName: "text-embedding-3-small",
  });

  // 连接 Chroma 客户端
  const client = new ChromaClient({ 
    path: process.env.CHROMA_HOST || "http://localhost:8000" 
  });
  
  // 删除旧的 Collection(如果存在)
  try {
    await client.deleteCollection({ name: collectionName });
    console.log(`已删除旧的 Collection: ${collectionName}`);
  } catch (error) {
    // Collection 不存在,忽略
  }

  // 批量存入向量数据库
  const vectorStore = await Chroma.fromDocuments(
    allChunks,
    embeddings,
    {
      collectionName,
      url: process.env.CHROMA_HOST || "http://localhost:8000",
    }
  );

  console.log(`✓ 索引完成:${allChunks.length} 个片段,来自 ${files.length} 个文件\n`);
  
  return vectorStore;
}

/**
 * 扫描文档目录,返回所有支持的文件路径
 */
function scanDocuments(dir: string): string[] {
  const supportedExts = [".md", ".txt", ".pdf"];
  const files: string[] = [];

  function scan(currentDir: string) {
    const entries = readdirSync(currentDir);
    
    for (const entry of entries) {
      const fullPath = join(currentDir, entry);
      const stat = statSync(fullPath);
      
      if (stat.isDirectory()) {
        // 递归扫描子目录
        scan(fullPath);
      } else if (stat.isFile() && supportedExts.includes(extname(entry).toLowerCase())) {
        files.push(fullPath);
      }
    }
  }

  scan(dir);
  return files;
}

/**
 * 加载单个文件并切分为 chunks
 */
async function loadAndSplitFile(
  filePath: string,
  chunkSize: number,
  chunkOverlap: number
): Promise<Document[]> {
  const ext = extname(filePath).toLowerCase();
  
  let loader;
  
  if (ext === ".pdf") {
    // PDF 需要使用 PDFLoader(见第 4 章)
    const { PDFLoader } = await import("@langchain/community/document_loaders/fs/pdf");
    loader = new PDFLoader(filePath);
  } else {
    // Markdown 和 TXT 使用 TextLoader
    loader = new TextLoader(filePath);
  }
  
  const docs = await loader.load();
  
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize,
    chunkOverlap,
  });
  
  const chunks = await splitter.splitDocuments(docs);
  
  // 为每个 chunk 添加文件来源元数据
  for (const chunk of chunks) {
    chunk.metadata.source = filePath;
    chunk.metadata.fileType = ext;
  }
  
  return chunks;
}

代码不算很长,由于前面的文章已经对里面涉及到的各个方法基本都讲解过了,所以本文不再赘述。基本的思路就是使用scanDocuments 递归扫描目录(支持子目录嵌套),然后根据不同的文档类型使用不同的文档加载器加载,再根据设置的chunkSizechunkOverlap向量化后存入向量数据库,并记录相关元素据。

如果你的文档特别或大,导致了性能建议,也可以参考《第6章:构建第一条 RAG 流水线》讲解的性能优化思路进行相关优化。

四、实现检索-生成管道

1. 检索器封装

typescript 复制代码
// src/retriever.ts
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { VectorStoreRetriever } from "@langchain/core/vectorstores";

export interface RetrieverConfig {
  k?: number;                // 返回结果数量
  scoreThreshold?: number;   // 相似度阈值
}

export function createRetriever(
  vectorStore: Chroma,
  config: RetrieverConfig = {}
): VectorStoreRetriever {
  const { k = 4, scoreThreshold } = config;
  
  const retriever = vectorStore.asRetriever({ k });
  
  if (scoreThreshold !== undefined) {
    // 重写 invoke 方法,加入分数过滤逻辑
    retriever.invoke = async (query: string) => {
      // 1. 获取带有分数的检索结果 (注意:Chroma 返回的是距离 distance)
      // 假设 Chroma 使用余弦相似度,分数越高越相似;如果是 L2 距离,分数越低越相似
      const resultsWithScore = await vectorStore.similaritySearchWithScore(query, k);
      
      // 2. 根据阈值过滤
      const filteredDocs = resultsWithScore
        .filter(([doc, score]) => score >= scoreThreshold) 
        .map(([doc]) => doc);
        
      return filteredDocs;
    };
  }
  
  return retriever;
}

设计说明

在 RAG 系统中,scoreThreshold(相似度阈值)是一个非常核心的参数,它的实际作用是在检索返回结果后,丢弃那些相似度分数低于该阈值的文档,从而避免将不相关的噪声信息传递给大模型(宁缺毋滥) 。这里使用scoreThreshold 预留了接口,虽然 Chroma 的过滤需要在查询时指定,但这种设计保持了灵活性。

另外代码中还使用工厂函数 createRetriever 而非直接暴露 asRetriever(),便于后续扩展。最终返回的类型是 VectorStoreRetriever,符合 LangChain 的标准接口。

实际上,LangChain 已经内置了带阈值过滤的检索模式。你完全不需要手动重写 invoke 方法,只需在调用 asRetriever() 时指定 search_typesearch_kwargs 即可。这里的实现仅是为了说明设计程序时要保留灵活性和扩展性。

2. 生成器:含多轮对话上下文管理

typescript 复制代码
// src/generator.ts
import { ChatOpenAI } from "@langchain/openai";
import { Document } from "@langchain/core/documents";
import { HumanMessage, SystemMessage, AIMessage } from "@langchain/core/messages";

interface Message {
  role: "user" | "assistant";
  content: string;
  timestamp: Date;
}

interface GeneratorConfig {
  modelName?: string;
  temperature?: number;
  maxHistoryLength?: number;  // 最大历史消息数
}

export class RAGGenerator {
  private llm: ChatOpenAI;
  private history: Message[] = [];
  private maxHistoryLength: number;

  constructor(config: GeneratorConfig = {}) {
    this.llm = new ChatOpenAI({
      modelName: config.modelName || "gpt-4o",
      temperature: config.temperature ?? 0,  // 降低随机性
    });
    this.maxHistoryLength = config.maxHistoryLength || 10;
  }

  /**
   * 构建包含历史和上下文的 Prompt
   */
  private buildPrompt(query: string, docs: Document[]): string {
    // 组装参考资料
    const context = docs
      .map((doc, i) => `[文档 ${i + 1}] ${doc.pageContent}`)
      .join("\n\n");

    // 组装对话历史
    const historyText = this.history.length > 0
      ? this.history
          .map((m) => `${m.role === "user" ? "用户" : "助手"}: ${m.content}`)
          .join("\n")
      : "(新对话)";

    return `你是一个专业的知识库问答助手。请根据以下资料回答问题。

## 对话历史
${historyText}

## 参考资料
${context}

## 当前问题
${query}

## 回答要求
1. 严格基于提供的参考资料回答,不要编造信息
2. 如果资料中没有相关信息,请明确说明"根据现有资料无法回答"
3. 在回答中标注引用的文档编号,如"[文档 1]"、"[文档 2]"
4. 回答要简洁清晰,避免冗余
5. 如果涉及步骤或列表,使用清晰的格式

请开始回答:`;
  }

  /**
   * 生成答案
   */
  async generate(query: string, docs: Document[]): Promise<string> {
    const prompt = this.buildPrompt(query, docs);
    
    const messages = [
      new SystemMessage("你是一个专业的知识库问答助手。"),
      new HumanMessage(prompt),
    ];
    
    const response = await this.llm.invoke(messages);
    const answer = response.content as string;

    // 更新对话历史
    this.addToHistory("user", query);
    this.addToHistory("assistant", answer);

    return answer;
  }

  /**
   * 添加消息到历史记录
   */
  private addToHistory(role: "user" | "assistant", content: string) {
    this.history.push({
      role,
      content,
      timestamp: new Date(),
    });

    // 如果超过最大长度,移除最早的消息
    if (this.history.length > this.maxHistoryLength * 2) {
      this.history = this.history.slice(-this.maxHistoryLength * 2);
    }
  }

  /**
   * 清空对话历史
   */
  clearHistory() {
    this.history = [];
    console.log("✓ 对话历史已清空");
  }

  /**
   * 获取当前历史长度
   */
  getHistoryLength(): number {
    return this.history.length;
  }
}

以上设计是一个最简单的、经典的LLM交互。基本的思路就是创建一个LLM,然后根据数据拼接prompt,连同会话历史一起传给LLM,LLM生成内容后响应结果,并将对话存入会话历史,用于后续的交互。

(1)对话历史管理

这段代码中 history 数组会持续累积对话内容。每当新问题进来,所有历史消息(含当前问题)都会被注入 Prompt,模型因此能理解"它""那个"等指代词。这是最简单的记忆管理,也是LLM能够保持会话连续的最原始的形式。例如:

makefile 复制代码
用户: 预发环境的地址是什么?
助手: 预发环境地址为 xxx.xxx [文档 1]
用户: 怎么访问它?
助手: 需要通过跳板机才能访问预发环境 [文档 1]

第二次提问中的"它"指代"预发环境",模型通过对话历史可以理解这个指代关系。

(2)历史长度控制

maxHistoryLength 限制了保留的历史消息对数。超过限制后,最早的消息会被丢弃。这是为了防止Token 消耗失控、无关历史信息干扰当前问题以及Prompt 过长导致 LLM 注意力分散。

《第11章:Prompt 工程与上下文优化》中,我们会讨论更高级的历史管理策略,如摘要压缩、重要性评分等。

(3)Prompt 结构设计

Prompt 分为四个部分:

  1. 系统指令:定义助手的角色和行为准则
  2. 对话历史:提供上下文信息
  3. 参考资料:检索到的相关文档
  4. 当前问题:用户的最新提问

这种结构清晰明了,便于模型理解各个部分的作用。

更多关于Prompt设计的内容,可以阅读文章:《那些被吹爆了的价值百万的AI提示词》

五、CLI 交互式问答

到此为止,关于RAG的索引、检索与生成就基本都实现了。接下来就是让整条流水线跑起来。而CLI 是最快跑通完整流程的形态,因为其无需前端代码。当你需要展示 Demo 或验证想法时,CLI 往往比 Web 应用更高效。下面就写一个CLI来验证以上实现的功能。

typescript 复制代码
// src/cli.ts
import * as readline from "readline";
import { RAGGenerator } from "./generator";
import { VectorStoreRetriever } from "@langchain/core/vectorstores";

export function startCLI(
  generator: RAGGenerator,
  retriever: VectorStoreRetriever
) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log("\n📖 RAG 文档问答机器人已启动");
  console.log("可用命令:");
  console.log("  /exit  - 退出程序");
  console.log("  /clear - 清空对话历史");
  console.log("  /help  - 显示帮助\n");

  const ask = () => {
    rl.question("> ", async (query) => {
      const trimmedQuery = query.trim();

      // 处理特殊命令
      if (trimmedQuery === "/exit") {
        console.log("再见!👋");
        rl.close();
        return;
      }

      if (trimmedQuery === "/clear") {
        generator.clearHistory();
        ask();
        return;
      }

      if (trimmedQuery === "/help") {
        console.log("\n可用命令:");
        console.log("  /exit  - 退出程序");
        console.log("  /clear - 清空对话历史");
        console.log("  /help  - 显示帮助");
        console.log("  直接输入问题进行问答\n");
        ask();
        return;
      }

      // 空输入
      if (!trimmedQuery) {
        ask();
        return;
      }

      try {
        console.log("\n🔍 正在检索相关文档...");
        
        // 检索
        const startTime = Date.now();
        const docs = await retriever.invoke(trimmedQuery);
        const retrieveTime = Date.now() - startTime;
        
        console.log(`✓ 找到 ${docs.length} 个相关片段(耗时 ${retrieveTime}ms)\n`);

        // 生成答案
        console.log("💬 正在生成答案...");
        const generateStartTime = Date.now();
        const answer = await generator.generate(trimmedQuery, docs);
        const generateTime = Date.now() - generateStartTime;

        console.log(`\n${answer}\n`);
        console.log(`⏱️  生成耗时: ${generateTime}ms`);

        // 显示引用来源
        if (docs.length > 0) {
          console.log("\n📎 引用来源:");
          for (const [i, doc] of docs.entries()) {
            const source = doc.metadata.source || "未知来源";
            const preview = doc.pageContent.slice(0, 100).replace(/\n/g, " ");
            console.log(`  [${i + 1}] ${source}`);
            console.log(`      ${preview}...`);
          }
        }
        
        console.log(`\n📊 对话历史长度: ${generator.getHistoryLength()} 条消息\n`);
      } catch (error) {
        console.error(`\n❌ 处理失败: ${error.message}\n`);
      }

      ask();
    });
  };

  ask();
}

功能特性

  1. 特殊命令 :支持 /exit/clear/help 三个命令
  2. 性能监控:显示检索和生成的耗时,便于性能分析
  3. 引用展示:列出检索到的文档来源和内容预览
  4. 错误处理:捕获异常并友好提示,避免程序崩溃
  5. 状态反馈:显示对话历史长度,让用户了解当前状态

六、扩展为 REST API 服务

当然,在实际的应用场景中,我们是需要将服务提供给用户使用的,这可能需要做成Web应用。当 CLI 验证可行后,即可以扩展为 REST API,供 Web 应用或其他服务调用。

typescript 复制代码
// src/server.ts
import express, { Request, Response } from "express";
import { RAGGenerator } from "./generator";
import { VectorStoreRetriever } from "@langchain/core/vectorstores";

interface ChatRequest {
  query: string;
  sessionId?: string;  // 可选:用于多用户会话隔离
}

interface ChatResponse {
  answer: string;
  sources: Array<{
    source: string;
    content: string;
    relevance?: number;
  }>;
  metadata: {
    retrieveTime: number;
    generateTime: number;
    historyLength: number;
  };
}

export function startServer(
  generator: RAGGenerator,
  retriever: VectorStoreRetriever
): void {
  const app = express();
  app.use(express.json());

  // CORS 支持(如果需要跨域访问)
  app.use((req, res, next) => {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
    res.header("Access-Control-Allow-Headers", "Content-Type");
    next();
  });

  /**
   * POST /chat - 问答接口
   */
  app.post("/chat", async (req: Request, res: Response) => {
    const { query }: ChatRequest = req.body;

    // 参数验证
    if (!query || typeof query !== "string" || query.trim().length === 0) {
      return res.status(400).json({
        error: "缺少必要参数: query",
      });
    }

    try {
      const startTime = Date.now();

      // 检索
      const retrieveStart = Date.now();
      const docs = await retriever.invoke(query);
      const retrieveTime = Date.now() - retrieveStart;

      // 生成
      const generateStart = Date.now();
      const answer = await generator.generate(query, docs);
      const generateTime = Date.now() - generateStart;

      const totalTime = Date.now() - startTime;

      // 组装响应
      const response: ChatResponse = {
        answer,
        sources: docs.map((doc) => ({
          source: doc.metadata.source || "未知来源",
          content: doc.pageContent.slice(0, 200),
        })),
        metadata: {
          retrieveTime,
          generateTime,
          historyLength: generator.getHistoryLength(),
        },
      };

      res.json(response);
    } catch (error) {
      console.error("API 错误:", error);
      res.status(500).json({
        error: "服务器内部错误",
        message: error instanceof Error ? error.message : "未知错误",
      });
    }
  });

  /**
   * POST /clear - 清空对话历史
   */
  app.post("/clear", (req: Request, res: Response) => {
    generator.clearHistory();
    res.json({ message: "对话历史已清空" });
  });

  /**
   * GET /health - 健康检查端点
   */
  app.get("/health", (_req: Request, res: Response) => {
    res.json({
      status: "ok",
      timestamp: new Date().toISOString(),
    });
  });

  /**
   * GET /stats - 统计信息
   */
  app.get("/stats", (_req: Request, res: Response) => {
    res.json({
      historyLength: generator.getHistoryLength(),
    });
  });

  const port = parseInt(process.env.PORT || "3000", 10);
  
  app.listen(port, () => {
    console.log(`\n🚀 RAG API 服务已启动`);
    console.log(`   http://localhost:${port}`);
    console.log(`   健康检查: http://localhost:${port}/health`);
    console.log(`   问答接口: POST http://localhost:${port}/chat\n`);
  });
}

API 设计要点

  1. RESTful 风格:使用标准的 HTTP 方法和状态码
  2. 错误处理:统一的错误响应格式,包含详细的错误信息
  3. 性能指标:在响应中包含检索和生成的耗时,便于客户端监控
  4. CORS 支持:允许跨域访问,方便前端集成
  5. 健康检查 :提供 /health 端点,便于运维监控

API 使用示例

bash 复制代码
# 健康检查
curl http://localhost:3000/health

# 提问
curl -X POST http://localhost:3000/chat \
  -H "Content-Type: application/json" \
  -d '{"query": "预发环境的部署步骤是什么?"}'

# 清空历史
curl -X POST http://localhost:3000/clear

七、运行入口

将索引、检索、生成、CLI或API整合到一个主入口函数中即可启动使用了。

typescript 复制代码
// src/main.ts
import { buildIndex } from "./indexer";
import { createRetriever } from "./retriever";
import { RAGGenerator } from "./generator";
import { startCLI } from "./cli";
import { startServer } from "./server";

async function main() {
  console.log("=== RAG 文档问答机器人 ===\n");

  // ========== 索引阶段 ==========
  const vectorStore = await buildIndex({
    docsDir: "./docs",
    collectionName: "knowledge_base",
    chunkSize: 500,
    chunkOverlap: 50,
  });

  // ========== 检索-生成阶段 ==========
  const retriever = createRetriever(vectorStore, { k: 4 });
  const generator = new RAGGenerator({
    modelName: "gpt-4o",
    temperature: 0,
    maxHistoryLength: 10,
  });

  // 根据命令行参数选择模式
  const mode = process.argv[2] || "cli";

  if (mode === "server") {
    console.log("启动模式: REST API 服务\n");
    startServer(generator, retriever);
  } else {
    console.log("启动模式: CLI 交互\n");
    startCLI(generator, retriever);
  }
}

main().catch((error) => {
  console.error("启动失败:", error);
  process.exit(1);
});

package.json 脚本配置

json 复制代码
{
  "scripts": {
    "dev": "ts-node src/main.ts",
    "server": "ts-node src/main.ts server",
    "build": "tsc",
    "start": "node dist/main.js"
  }
}

运行方式

bash 复制代码
# CLI 模式
npm run dev

# Server 模式
npm run server

# 生产环境
npm run build
npm start

以下是应用启动后整个运作的时序图。

sequenceDiagram participant U as 用户 participant CLI as CLI/Server participant R as Retriever participant V as 向量数据库 participant G as Generator participant LLM as LLM U->>CLI: 提问 CLI->>R: retrieve(query) R->>V: 相似度搜索 V-->>R: Top-K 文档片段 R-->>CLI: 相关文档 CLI->>G: generate(query, docs) G->>LLM: Prompt(历史+文档+问题) LLM-->>G: 生成答案 G-->>CLI: 答案 CLI-->>U: 答案 + 引用来源

八、常见问题与解决方案

1. Chroma 连接失败

症状Error: connect ECONNREFUSED 127.0.0.1:8000

原因:Chroma 服务未启动

解决

bash 复制代码
docker run -d -p 8000:8000 chromadb/chroma

或者使用嵌入式模式(无需单独服务)

typescript 复制代码
import { ChromaClient } from "chromadb";

const client = new ChromaClient({
  path: "./chroma_db",  // 本地目录
});

2. 检索结果为空

症状找到 0 个相关片段

原因

  • 文档未正确索引
  • 查询与文档内容语义差异太大
  • 相似度阈值设置过高

解决

  • 检查索引过程是否有错误
  • 尝试换一种提问方式
  • 降低 k 值或调整相似度阈值

FAQ

Q1:对话历史越来越长,Token 消耗会失控吗?

会的。这里展示的是最简单的实现。生产系统中需要对历史消息做截断或摘要,在《第11章:Prompt 工程与上下文优化》中会详细讨论 Token 预算的分配策略。

常见的优化策略包括:

  • 滑动窗口:只保留最近 N 轮对话
  • 摘要压缩:用 LLM 将历史对话总结为简短摘要
  • 重要性评分:保留重要的对话,丢弃无关的寒暄

Q2:这个机器人能支持多个文档目录吗?

可以。把 docsDir 改为数组,buildIndex 遍历所有目录汇总文档即可。更复杂的多租户场景(不同用户看到不同知识库)见《第16章:生产级 RAG 系统架构设计》

typescript 复制代码
// 支持多个文档目录
const docsDirs = ["./docs/product", "./docs/api", "./docs/faq"];
for (const dir of docsDirs) {
  const chunks = await loadDirectory(dir);
  allChunks.push(...chunks);
}

Q3:为什么不直接做成 Web 应用?

CLI 是最快跑通完整流程的形态,因为它零前端代码即可进行调试。当你需要展示 Demo 或验证想法时,CLI 往往比 Web 应用更高效。确认方案可行后再扩展为 REST API 或 Web 界面。

如果你确实需要 Web 界面,可以基于本章的 REST API 快速构建:

  • 使用 React/Vue 构建前端
  • 调用 /chat 接口获取答案
  • 显示引用来源和对话历史

Q4:如何实现多用户会话隔离?

当前的实现是单用户的,所有对话历史共享。如果需要支持多用户,可以:

  1. 为每个用户创建独立的 RAGGenerator 实例
  2. 使用 sessionId 区分不同用户的对话历史
  3. 将会话历史存储在 Redis 或数据库中

《第16章:生产级 RAG 系统架构设计》中会详细介绍多租户架构的实现。

Q5:如何评估机器人的回答质量?

目前只能人工检查答案是否准确。在《第12章:RAG 系统评估与指标体系》中,我们会介绍如何使用 RAGAS 等工具自动化评估 RAG 系统的表现,包括:

  • 答案相关性(Answer Relevance)
  • 上下文精确率(Context Precision)
  • 忠实度(Faithfulness)

练习

练习 1:构建并运行你的问答机器人

准备 3-5 篇 Markdown 技术文档放入 docs/ 目录,运行 CLI 工具,针对文档内容提 5 个具体问题。

验证标准

  • 5 个问题的答案均准确引用文档内容,而非模型编造
  • 每个答案都有明确的引用标注(如"文档 1")
  • 能够展示检索到的原始片段

练习 2:扩展支持 PDF 文档

修改 buildIndex 函数,让它同时支持 .md.txt.pdf 格式。PDF 使用《第4章:文档加载》中介绍的 PDFLoader

验证标准

  • 包含 PDF 文档的知识库能正确索引
  • 针对 PDF 内容提问,能够检索并返回正确答案
  • 处理 PDF 解析失败的情况(如扫描件)

提示

bash 复制代码
npm install pdf-parse

练习 3:实现对话历史重置

在 CLI 工具中添加 /clear 命令,清空 RAGGenerator 的对话历史。

验证标准

  • 输入 /clear 后,模型不再知道之前的对话内容
  • 再次提问之前讨论过的话题,模型无法引用历史信息
  • 显示"对话历史已清空"的提示信息

练习 4:添加查询日志

实现一个简单的日志系统,记录每次查询的以下信息:

  • 时间戳
  • 用户问题
  • 检索到的文档数量
  • 生成答案的耗时
  • 总耗时

将日志保存到 logs/queries.log 文件中。

验证标准

  • 每次查询都会在日志文件中追加一条记录
  • 日志格式清晰,便于后续分析
  • 能够统计平均查询延迟和热门问题

提示

typescript 复制代码
import { appendFileSync } from "fs";

function logQuery(query: string, metrics: any) {
  const logEntry = {
    timestamp: new Date().toISOString(),
    query,
    ...metrics,
  };
  
  appendFileSync(
    "logs/queries.log",
    JSON.stringify(logEntry) + "\n"
  );
}

练习 5:实现简单的缓存机制

对于重复的查询,直接从缓存返回答案,避免重复调用 LLM。

验证标准

  • 相同问题第二次提问时,响应速度显著提升
  • 缓存命中率能够被统计和展示
  • 缓存有过期机制(如 1 小时后失效)

📚 延伸阅读

相关推荐
Jay80591 小时前
一文讲清楚 Epoch、Batch 和 Iteration 的区别
人工智能
深海鱼在掘金1 小时前
深入浅出RAG——第8章:RAG 的局限性及应对策略
人工智能·架构
lucas_AI1 小时前
1.2B 小模型赢过 235B 大模型:NaviDC-OCR 把文档解析卷明白了
人工智能·深度学习·算法
阿星AI工作室1 小时前
一文看懂爆火的 Graph Engineering,以及它和 Loop Engineering、Agent 循环到底啥关系
人工智能
小柯南敲键盘1 小时前
跨境电商图片翻译工具推荐:批量AI翻译+视频字幕+智能抠图
人工智能·python·音视频
王六米。1 小时前
武汉人工智能应用软件开发、企业AI智能体服务怎么排查
人工智能·武汉自动意志科技有限公司·智钳claw·ai漫剧生成·人工智能应用软件开发·企业ai智能体服务
AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(四十):AI说“没有错误“,系统判了“测试失败“——一个正则的误判
数据库·人工智能
airobotcn1 小时前
智能巡检平台容器化部署:Docker+K8s在工业边缘的实践
人工智能·docker·容器·kubernetes·机器人·自动化
weixin_446260851 小时前
Vero基准:AI智能体能否构建形式化验证软件仓库
人工智能