📖 本章学习目标
- 从零搭建一个可运行的 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 # 使用说明
这种分层设计是比较常见的实践,它有几个明显的好处: 职责清晰 (每个模块只负责一个功能,便于测试和维护)、 易于扩展 (如果需要支持新的文档格式或向量数据库,只需修改对应模块)、可复用性强 (indexer、retriever、generator 可以在其他项目中复用)。
二、环境准备
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 递归扫描目录(支持子目录嵌套),然后根据不同的文档类型使用不同的文档加载器加载,再根据设置的chunkSize和chunkOverlap向量化后存入向量数据库,并记录相关元素据。
如果你的文档特别或大,导致了性能建议,也可以参考《第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_type和search_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 分为四个部分:
- 系统指令:定义助手的角色和行为准则
- 对话历史:提供上下文信息
- 参考资料:检索到的相关文档
- 当前问题:用户的最新提问
这种结构清晰明了,便于模型理解各个部分的作用。
更多关于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();
}
功能特性:
- 特殊命令 :支持
/exit、/clear、/help三个命令 - 性能监控:显示检索和生成的耗时,便于性能分析
- 引用展示:列出检索到的文档来源和内容预览
- 错误处理:捕获异常并友好提示,避免程序崩溃
- 状态反馈:显示对话历史长度,让用户了解当前状态
六、扩展为 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 设计要点:
- RESTful 风格:使用标准的 HTTP 方法和状态码
- 错误处理:统一的错误响应格式,包含详细的错误信息
- 性能指标:在响应中包含检索和生成的耗时,便于客户端监控
- CORS 支持:允许跨域访问,方便前端集成
- 健康检查 :提供
/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
以下是应用启动后整个运作的时序图。
八、常见问题与解决方案
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:如何实现多用户会话隔离?
当前的实现是单用户的,所有对话历史共享。如果需要支持多用户,可以:
- 为每个用户创建独立的
RAGGenerator实例 - 使用
sessionId区分不同用户的对话历史 - 将会话历史存储在 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 小时后失效)
📚 延伸阅读
- Express.js Documentation --- Express 官方文档,包含中间件、路由、错误处理等完整指南
- Chroma JS Client --- Chroma + OpenAI Embeddings 集成文档
- Building a CLI with Node.js --- Node.js 命令行交互指南
- REST API Best Practices --- REST API 设计的最佳实践
- RAGAS Evaluation Framework --- RAG 系统评估框架,在第 12 章会详细介绍