Claude Code + 通义千问,从零搭出生产级 RAG 要花多少钱?

项目概述

本项目是一个生产级的 RAG(Retrieval-Augmented Generation,检索增强生成)平台。用户可上传 PDF、Word、Excel、HTML、Markdown 等格式的文档,系统自动完成解析、分块、向量嵌入、索引存储,然后通过对话界面进行智能问答,回答基于上传的文档内容并附带引用来源。

开发方式与成本

这个项目是怎么做出来的?

流程分两步,先有方案文档,再有代码

第一步:生成方案文档 hashed-gliding-metcalfe.md

真正动手写代码之前,先用 Claude Code 产出一份完整的实现方案(文件名是 Claude Code 自动分配的任务代号)。这份约 440 行的 Markdown 相当于整个项目的「施工蓝图」,内容包括:

  • 生产级 RAG 的架构图与模块划分
  • Monorepo 目录结构与各文件职责
  • LangGraph 10 节点状态机、API 路由、数据模型
  • 混合检索、HyDE、自纠正、评估面板等技术选型

第二步:按方案文档生成代码

随后以 hashed-gliding-metcalfe.md 为唯一规格说明,让 Claude Code 逐模块生成 monorepo 代码、Docker 配置、前后端实现;运行报错时再在对话中调试、重构。运行时调用 阿里百炼 / 通义千问 的 OpenAI 兼容 API:

  • 对话与生成qwen3.6-plus
  • 向量嵌入text-embedding-3-large(1024 维,经百炼接口调用)

花了多少钱?

全程 API 调用(Embedding 批量写入 + 多轮对话测试 + Rerank 等)合计约 100 元人民币

为什么要做这个实验?

主要目的不是「做一个 Demo」,而是实测:从零完成一套可上线、带评测与可观测性的生产级 RAG,究竟要烧多少 Token、踩多少坑。本文把架构选型、14 个 Bug 的排查过程、以及 Token 成本体感一并记录下来,供你在立项或选型时参考------若你也用 Claude Code + 国内 LLM API 走类似路线,可以据此估算预算与工期。

执行构建流程

从「写方案」到「跑起来」,整条链路可以概括为下面这张图:上半段是 AI 生成与调试,下半段是本地执行构建
flowchart TD start(开始) --> genSpec"Claude Code 生成方案文档\
hashed-gliding-metcalfe.md"
genSpec --> genCode"按方案逐模块生成代码\
Monorepo · Docker · 前后端 · LangGraph"
genCode --> debugLoop{本地运行是否通过?} debugLoop -->|报错| fixBug"Claude Code 对话调试\
逐条修复 Bug · 重构"
fixBug --> debugLoop debugLoop -->|通过| execBuild"进入本地执行构建" execBuild --> dockerUp"docker compose up -d\
Milvus · PostgreSQL · Redis · Attu"
dockerUp --> npmInstall"npm install\
安装 workspaces 依赖"
npmInstall --> envConfig"配置 .env\
qwen3.6-plus · text-embedding-3-large"
envConfig --> npmDev"npm run dev\
Turbo 并行启动后端 :3000 · 前端 :5173"
npmDev --> uploadDoc"上传文档\
PDF / Word / Excel / HTML / Markdown"
uploadDoc --> ingestPipeline"入库流水线\
解析 → 分块 → Embedding → Milvus 混合索引"
ingestPipeline --> ragQuery"对话问答\
LangGraph 检索 · Rerank · 生成 · 引用"
ragQuery --> evalTune"评测面板调参 · 上线前检查" evalTune --> done("生产级 RAG 可用\
全程 API 约 100 元"
) style genSpec fill:#e8f4fc,stroke:#4a90d9 style genCode fill:#e8f4fc,stroke:#4a90d9 style fixBug fill:#e8f4fc,stroke:#4a90d9 style dockerUp fill:#fff3e6,stroke:#e67e22 style npmInstall fill:#fff3e6,stroke:#e67e22 style envConfig fill:#fff3e6,stroke:#e67e22 style npmDev fill:#fff3e6,stroke:#e67e22 style uploadDoc fill:#fff3e6,stroke:#e67e22 style ingestPipeline fill:#fff3e6,stroke:#e67e22 style ragQuery fill:#fff3e6,stroke:#e67e22 style evalTune fill:#fff3e6,stroke:#e67e22 style done fill:#e8f8e8,stroke:#27ae60

图例说明:

  • 蓝色节点:Claude Code 负责的部分(方案 → 代码 → 调试)
  • 橙色节点:你在本机执行的构建与运行步骤
  • 绿色终点:系统跑通并可对外提供 RAG 问答

对应仓库根目录常用命令:

bash 复制代码
docker compose up -d          # 或 npm run docker:up
npm install
# 复制 .env.example 为 .env,填入通义千问 API Key 与模型名
npm run dev                   # Turbo 同时启动 backend + frontend
复制代码
┌─────────────────────────────────────────────────────────┐
│                     前端 (5173)                          │
│  Vite + React 19 + TypeScript + TailwindCSS 4           │
│  Zustand 状态管理 · React Query · Lucide 图标           │
├─────────────────────────────────────────────────────────┤
│                     后端 (3000)                          │
│  Node.js + Fastify 5 + TypeScript + LangGraph           │
│  10 节点状态机 · SSE 流式响应 · Zod 配置校验            │
├──────────┬──────────┬──────────┬────────────────────────┤
│ Milvus   │ PostgreSQL│ Redis   │ 外部 API              │
│ 2.5.x    │ 16       │ 7       │ 阿里百炼              │
│ 稠密向量 │ 文档元数据│ 缓存    │ LLM: qwen-plus        │
│ +BM25稀疏│ Chunk关系 │ 限流    │ Embedding: v3 1024维  │
└──────────┴──────────┴──────────┴────────────────────────┘

核心依赖版本

组件 版本 说明
@langchain/langgraph 0.2.x LangGraph 状态机
@langchain/openai 0.3.x LLM/Embedding 客户端
@zilliz/milvus2-sdk-node 2.5.x Milvus Node SDK
fastify 5.x HTTP 框架
tailwindcss 4.x CSS 框架
pdf-parse 1.x PDF 解析
mammoth 1.x DOCX 解析
xlsx 0.18.x Excel 解析
cheerio 1.x HTML 解析

从零搭建的完整过程

第一阶段:方案文档(施工蓝图)

在创建任何代码文件之前,第一步是生成 hashed-gliding-metcalfe.md ------一份由 Claude Code 输出的生产级 RAG 实现方案。后续所有目录结构、LangGraph 节点、API 设计、技术栈选型,都以这份文档为准;可以说,项目是从 Markdown 规格说明「编译」出来的,而不是边想边写。

方案文档核心内容:

  • 架构概览:前端四面板 + Fastify 后端 + Milvus/PostgreSQL/Redis + 外部 LLM/Embedding/Rerank
  • 完整目录树packages/backendpackages/frontendpackages/shared 下每个文件的命名与职责
  • LangGraph 流水线:查询改写 → HyDE → 混合检索 → Rerank → 生成 → 自纠正 → 置信度评估
  • 入库流水线:多格式解析 → 语义分块 → 向量化 → Milvus 混合索引

这份文档保存在仓库根目录,与最终代码结构高度一致,是理解本项目来源的最佳入口。

第二阶段:基础设施搭建

2.1 Monorepo 结构

使用 npm workspaces + Turbo 构建 monorepo,目录结构如下:

复制代码
rag-platform/
├── package.json              # 根配置,定义 workspaces
├── turbo.json                # Turbo 任务编排
├── tsconfig.base.json        # 共享 TypeScript 配置
├── docker-compose.yml        # 基础设施编排
├── .env.example              # 环境变量模板
└── packages/
    ├── backend/              # Fastify API 服务
    ├── frontend/             # Vite + React 前端
    └── shared/               # 前后端共享 TypeScript 类型

关键配置点:

  • package.json 中使用 "workspaces": ["packages/*"] 让 npm 自动管理子包依赖
  • tsconfig.base.json 定义 target: ES2022module: NodeNextstrict: true
  • 子包通过 "extends": "../../tsconfig.base.json" 继承基础配置

2.2 Docker Compose 基础设施

yaml 复制代码
services:
  etcd:          # Milvus 元数据存储
  minio:         # Milvus 对象存储
  milvus:        # 向量数据库
  postgres:      # 关系型数据库
  redis:         # 缓存与限流
  attu:          # Milvus Web 管理界面

2.3 共享类型定义

packages/shared/src/ 定义了完整的业务类型:

  • document.ts:Document、DocumentCreateInput、DocumentListResponse
  • chunk.ts:Chunk、ChunkCreateInput
  • query.ts:QueryRequest、QueryResponse、Citation、ConfidenceScore
  • config.ts:RAGConfig、DEFAULT_RAG_CONFIG
  • response.ts:SSEEvent、FeedbackInput、EvalMetrics

第三阶段:后端核心服务

3.1 文档解析器工厂模式

typescript 复制代码
// parsers/parser.ts - 解析器接口
export interface DocumentParser {
  supportedMimeTypes: string[];
  parse(buffer: Buffer): Promise<ParseResult>;
}

// parsers/parser.ts - 工厂类
export class ParserFactory {
  private parsers: DocumentParser[] = [];
  register(parser: DocumentParser) { this.parsers.push(parser); }
  getParser(mimeType: string): DocumentParser {
    return this.parsers.find(p => p.supportedMimeTypes.includes(mimeType))!;
  }
}

各解析器实现:

  • PDF :使用 pdf-parse 提取文本,保留页码和元数据
  • DOCX :使用 mammoth 提取纯文本(带结构信息)
  • Excel :使用 xlsx 将每个 sheet 转为 CSV 格式文本
  • HTML :使用 cheerio 清理脚本/样式,再用 turndown 转 Markdown
  • Markdown:直接读取,提取标题作为元数据

3.2 分块策略路由

typescript 复制代码
class Chunker {
  async chunk(text, options, documentId) {
    switch (options.strategy) {
      case 'markdown':     return this.markdownChunk(...);   // 按标题切分
      case 'semantic':     return this.semanticChunk(...);   // 语义边界
      case 'hierarchical': return this.hierarchicalChunk(...);// 父子结构
      case 'fixed':        return this.fixedSizeChunk(...);  // 固定大小
    }
  }
}

Markdown 分块是最实用的策略:按 # 标题层级切分,保留 headerPath(如 ["第一章", "1.1 节", "1.1.1 小节"])作为元数据,这样检索时能知道 chunk 在文档中的位置。

3.3 Milvus 向量存储

typescript 复制代码
// 集合字段设计
{ name: 'id', data_type: DataType.VarChar, is_primary_key: true },
{ name: 'document_id', data_type: DataType.VarChar, is_partition_key: true },
{ name: 'content', data_type: DataType.VarChar, max_length: 65535 },
{ name: 'dense_vector', data_type: DataType.FloatVector, dim: 1024 },
{ name: 'sparse_vector', data_type: DataType.SparseFloatVector },
{ name: 'chunk_index', data_type: DataType.Int64 },
{ name: 'metadata', data_type: DataType.JSON },
// ... 过滤字段:doc_type, source, author, created_at, section_title

第四阶段:LangGraph RAG 管道

设计了完整的 10 节点有向无环图(DAG)带条件分支和自纠正循环:

复制代码
START
  │
  ▼
classify(查询分类)
  │
  ├─ factual/comparative → decompose(子问题分解)
  │                                │
  │                                ▼
  │                          retrieve(混合检索)
  │                                │
  ├─ general/other → rewrite(查询改写)
  │                        │
  │                        ├─ HyDE enabled → hyde(假设文档)
  │                        │                        │
  │                        └─ HyDE disabled  ───────┤
  │                                                 ▼
  │                                          retrieve(混合检索)
  │                                                 │
  │                                                 ▼
  │                                            rerank(重排序)
  │                                                 │
  │                                                 ▼
  │                                           compress(上下文压缩)
  │                                                 │
  │                                                 ▼
  │                                           generate(答案生成)
  │                                                 │
  │                                                 ▼
  │                                            grade(质量评估)
  │                                                 │
  │                          ┌─ pass/ambiguous ───► format(格式化)
  │                          │
  │                          └─ fail + retries ──► rewrite(回退重写)
  │                                                     │
  │                                                     └─ 循环回去
  │
  ▼
END

第五阶段:前端企业级 UI

  • 侧边栏 :深色背景(bg-sidebar)、图标+文字、选中高亮、可折叠
  • 对话界面:左侧多会话管理、中间聊天气泡、右侧引用面板、底部输入区
  • 文档管理:4 个统计卡片、搜索/筛选/视图切换、拖拽上传、列表/网格双视图
  • 评估面板:4 个指标卡片(命中率/MRR/忠实度/相关性)、7 天趋势柱状图、系统状态面板
  • 配置面板:Tab 式布局(检索/模型/分块/高级)、开关/滑块/下拉选择

踩坑记录与核心难点(重点)

Bug 1:@types/mammoth 包不存在 ------ 依赖安装失败

异常信息

复制代码
npm error 404 Not Found - GET https://registry.npmjs.org/@types%2fmammoth

排查过程

  1. npm install 失败,提示 @types/mammoth 不存在
  2. 检查 npm registry 确认该类型包确实不存在
  3. 查看 mammoth 源码,发现它自带 TypeScript 类型声明

解决方案 :从 package.jsondevDependencies 中删除 "@types/mammoth": "^1.4.4" 这行。

教训 :不是所有包都有对应的 @types/* 包。先查一下包是否自带 .d.ts 文件,没有再装 @types/*


Bug 2:minhash 包不存在 ------ 去重模块依赖缺失

异常信息

复制代码
npm error notarget No matching version found for minhash@^2.0.0

排查过程

  1. npm 找不到 minhash 的 2.0.0 版本
  2. 搜索 npm registry 发现该包名已被弃用,现在叫 node-minhash 或其他替代

解决方案 :暂时移除 minhash 依赖,去重功能先用简单的内容哈希(xxhash-wasm)替代。MinHash+LSH 作为后续优化项。


Bug 3:dotenv 在 monorepo 中找不到 .env 文件

异常信息

复制代码
ZodError: [
  { "code": "invalid_type", "expected": "string", "received": "undefined", "path": ["llmApiKey"] },
  { "code": "invalid_type", "expected": "string", "received": "undefined", "path": ["embeddingApiKey"] },
  { "code": "invalid_type", "expected": "string", "received": "undefined", "path": ["databaseUrl"] }
]

排查过程

  1. 后端启动就崩溃,Zod 报所有环境变量 undefined
  2. .env 文件明明存在于 D:\rag-platform\.env
  3. config/schema.ts 中使用了 import 'dotenv/config'
  4. 问题:dotenv 默认在 process.cwd() 下查找 .env,而 process.cwd()packages/backend/
  5. 环境变量命名也不匹配:.env 中用的是 LLM_BASE_URL,但 schema 中定义的字段名是驼峰 llmBaseUrl

解决方案(两步修复)

第一步:修复 .env 变量名为大写下划线(与 shell 环境变量命名习惯一致):

typescript 复制代码
// config/schema.ts
export const ConfigSchema = z.object({
  LLM_BASE_URL: z.string().default('https://api.openai.com/v1'),
  LLM_API_KEY: z.string(),
  // ... 全部使用大写下划线
});

第二步:显式指定 .env 路径:

typescript 复制代码
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });

排查技巧 :在 getConfig() 中加 console.log(process.env.LLM_API_KEY) 确认环境变量是否被加载。


Bug 4:LangGraph 节点名与状态字段名冲突

异常信息

复制代码
Error: grade is already being used as a state attribute (a.k.a. a channel), cannot also be used as a node name.

排查过程

  1. 后端启动崩溃,错误指向 graph.ts 第 48 行
  2. 检查发现 RAGState 中定义了 grade: Annotation<string>()
  3. 同时又用 .addNode('grade', gradeNode) 注册了同名节点
  4. LangGraph 要求节点名和状态字段名不能重复

解决方案 :将节点名从 'grade' 改为 'evaluate',同时更新所有引用该节点的边:

typescript 复制代码
workflow.addConditionalEdges('evaluate', routeAfterGrade, {
  format: 'format',
  retry: 'rewrite',
});

Bug 5:Milvus createIndexparams 参数格式错误

异常信息

复制代码
Error: ErrorCode: UnexpectedError. Reason: there is no vector index on field: [dense_vector], please create index firstly

排查过程

  1. Milvus 集合创建成功,但 loadCollection 时报错说没有向量索引
  2. 检查 createIndex 调用代码,使用的是 extra_params: JSON.stringify({ nlist: 1024 })
  3. 搜索 Milvus Node SDK 文档和源码,发现新版 SDK 使用 params 参数且期望传 对象
  4. 传 JSON 字符串导致 Go 后端反序列化失败,索引创建静默失败

解决思路

  1. 先查 Milvus SDK 的 createIndex 方法签名
  2. 发现 params 应该是对象而非字符串
  3. 改为 params: { nlist: 1024 } 后问题解决
typescript 复制代码
// ❌ 错误写法
await milvus.createIndex({
  index_type: 'IVF_FLAT',
  metric_type: 'COSINE',
  extra_params: JSON.stringify({ nlist: 1024 }),
});

// ✅ 正确写法
await milvus.createIndex({
  index_type: 'IVF_FLAT',
  metric_type: 'COSINE',
  params: { nlist: 1024 },
});

Bug 6:TailwindCSS v4 样式不生效

异常信息:无报错,但前端页面没有样式,所有 Tailwind 类名都失效。

排查过程

  1. 页面能打开,但就是纯 HTML 堆砌,没有任何样式
  2. 检查 index.css 中有 @import "tailwindcss"
  3. 检查 vite.config.ts 中只有 [react()] 插件
  4. TailwindCSS v4 的集成方式变了:需要在 Vite 中配置 @tailwindcss/vite 插件

解决思路

  1. 搜索 TailwindCSS v4 文档,确认需要 @tailwindcss/vite 插件
  2. npm install @tailwindcss/vite
  3. vite.config.ts 中添加插件
typescript 复制代码
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [react(), tailwindcss()],
});

Bug 7:文档入库时重复创建记录

异常表现:上传一个 PDF 文件,文档列表中显示两条相同名称的记录。

排查过程

  1. 查询 PostgreSQL 发现确实有两条相同 title 的记录
  2. 追踪代码流程:
    • routes/ingest.ts:收到文件 → documentService.create() → 创建记录 → 调用 ingestionService.ingest()
    • services/ingestion.service.ts:解析文档 → this.documentService.create()又创建了一次
  3. 问题:两个地方各自调用了一次 create()

解决思路

  1. 路由层负责创建记录(返回 documentId 给前端)
  2. 把 documentId 传给 ingestionService.ingest(documentId, ...)
  3. 服务层复用已有 ID,不再创建新记录
typescript 复制代码
// routes/ingest.ts
const documentId = await documentService.create({...});
ingestionService.ingest(documentId, buffer, fileName, mimeType, ...);

// services/ingestion.service.ts
async ingest(documentId: string, fileBuffer: Buffer, ...) {
  // 直接使用传入的 documentId,不再创建
  // ...
}

Bug 8:向量维度不匹配(3072 vs 1024)

异常表现

  • 文档上传成功、分片 8 个、Milvus 中有 8 条数据
  • 但检索返回 0 条结果,没有任何错误提示
  • 使用 curl 直接测试后端 API 也返回空

排查过程

第一步:确认 Milvus 中有数据

bash 复制代码
node -e "
const { MilvusClient } = require('@zilliz/milvus2-sdk-node');
const client = new MilvusClient({ address: 'localhost:19530' });
const count = await client.query({
  collection_name: 'rag_chunks',
  output_fields: ['count(*)'],
  filter: '',
});
console.log('Count:', count.data); // 输出: [{"count(*)":"8"}]

第二步:检查向量维度

bash 复制代码
const sample = await client.query({
  collection_name: 'rag_chunks',
  output_fields: ['id', 'dense_vector'],
  filter: '', limit: 1,
});
console.log('Vector length:', sample.data[0].dense_vector.length); // 输出: 1024

第三步:检查 schema 定义

typescript 复制代码
// vectorstore/schema.ts
export const DENSE_VECTOR_DIM = 3072; // text-embedding-3-large 的维度

发现问题 :集合创建时用了 3072 维(默认配置,原本打算用 OpenAI 的 text-embedding-3-large),但实际 embedding API 调用的是阿里百炼的 text-embedding-v3,返回 1024 维。虽然 Milvus 接受了插入(可能截断或补零了),但搜索时维度不匹配导致静默返回空结果。

解决方案:修改 schema 为实际 embedding 维度

typescript 复制代码
// vectorstore/schema.ts
export const DENSE_VECTOR_DIM = 1024; // text-embedding-v3 的维度

同时需要删除旧集合重建(因为 Milvus 不允许修改已存在集合的维度)。


Bug 9:LangChain SDK 参数不兼容 ------ baseUrl vs configuration: { baseURL }

异常表现

  • 文档上传成功,8 个 chunks 创建成功
  • 日志显示 "Generated 8 embeddings"
  • Milvus 显示 "Indexed 8 chunks"
  • 但检索时返回 0 条文档
  • 更诡异的是 embedding 阶段耗时极长(数十秒),正常应该 1-2 秒

排查过程

第一次尝试 :检查 embeddings/openai.ts 代码

typescript 复制代码
// 当时的代码
embeddings = new OpenAIEmbeddings({
  model: config.EMBEDDING_MODEL,
  apiKey: config.EMBEDDING_API_KEY,
  baseUrl: config.EMBEDDING_BASE_URL,  // ← 这行有问题
  dimensions: config.EMBEDDING_DIMENSION,
});

第二次尝试:写独立脚本测试两种参数方式

bash 复制代码
# 测试 baseUrl(新版语法)
node -e "
const { OpenAIEmbeddings } = require('@langchain/openai');
const embeddings = new OpenAIEmbeddings({
  model: 'text-embedding-v3',
  apiKey: 'sk-...',
  baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  dimensions: 1024,
});
embeddings.embedDocuments(['test']);
"
# 结果:卡住不动,超时
bash 复制代码
# 测试 configuration.baseURL(旧版语法)
node -e "
const { OpenAIEmbeddings } = require('@langchain/openai');
const embeddings = new OpenAIEmbeddings({
  model: 'text-embedding-v3',
  apiKey: 'sk-...',
  configuration: { baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
  dimensions: 1024,
});
embeddings.embedDocuments(['test']);
"
# 结果:238ms 完成,返回 2 个 1024 维向量

根因@langchain/openaiOpenAIEmbeddings 类中,baseUrl 参数不被识别(被忽略),导致它使用默认的 OpenAI API 地址(api.openai.com),而不是阿里百炼的地址。由于 API Key 不匹配 OpenAI,请求要么超时要么返回错误数据。

解决方案

typescript 复制代码
embeddings = new OpenAIEmbeddings({
  model: config.EMBEDDING_MODEL,
  apiKey: config.EMBEDDING_API_KEY,
  configuration: { baseURL: config.EMBEDDING_BASE_URL },  // ✅ 正确
  dimensions: config.EMBEDDING_DIMENSION,
  timeout: 30000,
});

// ChatOpenAI 同理
llm = new ChatOpenAI({
  model: config.LLM_MODEL,
  apiKey: config.LLM_API_KEY,
  configuration: { baseURL: config.LLM_BASE_URL },  // ✅ 正确
  temperature: config.LLM_TEMPERATURE,
  maxTokens: config.LLM_MAX_TOKENS,
});

Bug 10:Milvus 集合已存在但索引丢失

异常表现

复制代码
Error: ErrorCode: UnexpectedError. Reason: there is no vector index on field: [dense_vector], please create index firstly

排查过程

  1. 之前的多次重启/崩溃导致 Milvus 中留下了不完整的集合
  2. hasCollection 检查通过(集合存在),但索引可能没建完
  3. 每次重启后端时 ensureCollections() 跳过重建,导致使用没有索引的旧集合

解决方案:每次重启后端前手动删除 Milvus 集合

bash 复制代码
node -e "
const { MilvusClient } = require('@zilliz/milvus2-sdk-node');
const client = new MilvusClient({ address: 'localhost:19530' });
client.dropCollection({ collection_name: 'rag_chunks' });
client.dropCollection({ collection_name: 'rag_document_summaries' });
client.closeConnection();
"

更好的方案 :在 ensureCollections() 中加入索引存在性检查,如果集合存在但索引缺失则重建。


Bug 11:相似度阈值过高导致全部过滤

异常表现

  • 文档已索引,Milvus 中有 8 条数据
  • Embedding API 正常工作
  • 手动向 Milvus 发起随机向量搜索能返回结果(score: 0.007-0.01)
  • 但 RAG 管道中检索返回 0 条

排查过程

第一步:在 retrieve.node.ts 中加日志

typescript 复制代码
console.log(`Retrieved ${documents.length} documents for query`);

输出:Retrieved 0 documents for query: "周文轩是谁?..."

第二步:直接在 Node.js 中测试 Milvus 搜索

bash 复制代码
node -e "
const { MilvusClient } = require('@zilliz/milvus2-sdk-node');
const { OpenAIEmbeddings } = require('@langchain/openai');

// 生成查询向量
const embeddings = new OpenAIEmbeddings({
  model: 'text-embedding-v3',
  apiKey: 'sk-...',
  configuration: { baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
  dimensions: 1024,
});
const [queryVec] = await embeddings.embedDocuments(['周文轩是谁']);

// 搜索 Milvus
const milvus = new MilvusClient({ address: 'localhost:19530' });
const result = await milvus.search({
  collection_name: 'rag_chunks',
  vector: queryVec,
  topk: 5,
  output_fields: ['id', 'content', 'chunk_index'],
});
console.log('Results:', result.results.length);
console.log('Scores:', result.results.map(r => r.score));
"

输出:

复制代码
Results: 5
Scores: [0.4522, 0.3395, 0.3032, 0.2815, 0.2654]

第三步:发现关键差异------Milvus 直接搜索返回 score 范围是 0.26-0.45 ,但代码中相似度阈值是 0.7

根因 :阿里百炼 text-embedding-v3 的 COSINE 相似度分数天然偏低,在 0.2-0.45 范围,而 OpenAI 的 embedding 分数通常在 0.7 以上。代码中默认阈值 0.7 是参照 OpenAI 设定的,导致所有结果都被过滤。

解决方案(两步)

  1. 降低默认阈值:similarityThreshold: 0.2
  2. 移除 retrieve.node.ts 中的阈值过滤,让后续的 reranking 和 grading 来处理质量控制
typescript 复制代码
// retrieve.node.ts - 移除阈值过滤
// const filtered = results.filter(r => r.score >= threshold);  // ← 删除这行
const documents = results.map(r => ({ ...r }));  // 直接返回所有结果

Bug 12:Fastify multipart Content-Length 不匹配

异常信息

json 复制代码
{
  "statusCode": 400,
  "code": "FST_ERR_CTP_INVALID_CONTENT_LENGTH",
  "message": "Request body size did not match Content-Length"
}

排查过程

  1. curl 直接请求后端 API 正常
  2. 通过前端(Vite dev server 代理)请求返回 400
  3. 问题只在开发模式下出现,生产环境不会有
  4. 原因:Vite 代理在处理 multipart/form-data 时可能修改了 Content-Length 头

解决方案:生产环境不需要 Vite 代理(nginx 直接代理),开发模式下暂时用 curl 或 Postman 测试文件上传。


Bug 13:pino-pretty transport 无法解析

异常信息

复制代码
Error: unable to determine transport target for "pino-pretty"

排查过程

  1. Fastify logger 配置了 transport: { target: 'pino-pretty' }
  2. pino-pretty 没有安装
  3. 在 ESM 模式下,pino 的 transport target 解析方式不同

解决方案:直接移除 transport 配置,使用 Fastify 默认的 JSON 日志格式

typescript 复制代码
const app = Fastify({
  logger: {
    level: config.NODE_ENV === 'development' ? 'debug' : 'info',
    // 移除 transport 配置
  },
});

Bug 14:后端进程端口冲突

异常信息

复制代码
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000

排查过程

  1. 新启动的后端进程报错端口占用
  2. 旧的后端进程还在运行(tsx watch 模式不会自动退出)
  3. tsx watch 会监听文件变化自动重启,但有时候会 fork 出多个进程

解决方案

bash 复制代码
# 找到占用 3000 端口的进程 PID
netstat -ano | grep 3000 | grep LISTENING
# 强制终止
taskkill //PID <PID> //F

排查方法论总结

在整个开发过程中,形成了以下排查策略:

策略 1:逐层隔离测试

当 RAG 管道返回 0 条结果时,按以下顺序逐层测试:

复制代码
第 1 层:Milvus 中有数据吗?
  → node 查询 count(*) → 8 条 ✓

第 2 层:Milvus 中的向量维度正确吗?
  → 查询 sample dense_vector.length → 1024 ✓

第 3 层:Milvus 能搜到结果吗?
  → 直接 search → 5 条,score: 0.26-0.45 ✓

第 4 层:Embedding API 调用的是正确的地址吗?
  → 独立脚本测试 baseUrl vs configuration → baseUrl 超时 ✗

第 5 层:代码中的阈值过滤是否合理?
  → 阈值 0.7 > 实际分数 0.45 → 全部过滤 ✗

策略 2:对比测试

当怀疑某个参数配置不正确时,写两个独立脚本对比:

bash 复制代码
# 方案 A
node -e "new OpenAIEmbeddings({ baseUrl: ... }).embedDocuments(['test'])"
# 结果:超时

# 方案 B
node -e "new OpenAIEmbeddings({ configuration: { baseURL: ... } }).embedDocuments(['test'])"
# 结果:238ms 成功

策略 3:日志驱动

在每个关键步骤加 console.log

复制代码
Parsing document: xxx.pdf → 5868 characters
Created 8 chunks
Generated 8 embeddings
Indexed 8 chunks in Milvus
Ingestion completed in 789ms

如果某个步骤没有日志输出,问题就在那一步。

策略 4:直接查日志文件

当后端在后台运行时,输出被写入临时文件:

复制代码
C:\Users\ADMINI~1\AppData\Local\Temp\claude\D--\...\tasks\{task-id}.output

通过 Read 工具读取完整日志,而不是等后台通知。


容易出问题的关键检查点

上线前必查清单

# 检查项 验证方法
1 Embedding 维度一致性 Milvus schema 的 dim 值 == 模型实际输出维度
2 API 地址参数 使用 configuration: { baseURL } 而非 baseUrl
3 相似度阈值 先用独立脚本测试 embedding 的分数分布
4 Milvus 索引创建 params 传对象而非 JSON 字符串
5 环境变量加载 monorepo 中需要指定 .env 的绝对路径
6 文档入库去重 路由层和服务层不会各自创建数据库记录
7 TailwindCSS 插件 v4 必须在 vite.config.ts 中注册
8 后端进程 重启前确保旧进程已退出,避免端口占用
9 Milvus 集合状态 重启后端时可能需要删除旧集合重建
10 LangGraph 节点名 不能与 RAGState 中的字段名重复

开发调试建议

  1. 写独立测试脚本:对每个可能出问题的组件(Embedding、Milvus 搜索、LLM 调用)写一个独立的可运行脚本,不要等到整个系统跑起来才测试
  2. 打印关键指标 :在 retrieve.node.ts 中打印检索到的文档数量和前几个 score 值,确认过滤逻辑正确
  3. 使用 Attu 管理界面 :Milvus 的 Attu( http://localhost:3001 )可以直观查看集合、数据和索引状态
  4. PostgreSQL 直查 :用 psql 或 SQL 客户端直接查询 documentschunks 表,确认数据入库
  5. 后端进程管理tsx 的 watch 模式容易残留进程,建议在开发阶段用 npx tsx src/index.ts(不 watch)代替 npm run dev(watch)

项目总结

本项目从零到完整可用的 RAG 平台,共修复了 14 个 Bug ,经历了无数次重启和重试。起点是 Claude Code 生成的方案文档 hashed-gliding-metcalfe.md ,代码据此逐模块落地;模型侧依赖 通义千问 qwen3.6-plustext-embedding-3-large ,全链路 API 花费约 100 元------可作为「先写规格、再 AI 生成代码 + 国内 LLM」完成生产级 RAG 的一次 Token 与成本样本。

Bug 分类

类型 数量 典型问题
SDK 参数不兼容 3 baseUrl vs configuration.baseURLparams vs extra_params
参数不匹配 3 向量维度 3072 vs 1024、相似度阈值 0.7 vs 0.45
配置/路径问题 3 .env 找不到、Tailwind 插件未注册、端口占用
逻辑 Bug 3 重复创建记录、节点名冲突、索引创建静默失败
环境问题 2 @types/mammoth 不存在、minhash 包被弃用

最大教训

这些问题的共同特点是:不会抛明确的错误,而是静默失败------返回 0 条结果、向量全是 0、配置被忽略、请求超时但不报错。

排查这类问题的核心方法论:

  1. 不要相信系统没报错就是正常的------没有结果本身就是一个错误信号
  2. 逐层隔离,从下往上测------先确认数据库/向量库正常工作,再测服务层,最后测管道层
  3. 写独立测试脚本------不要等到整个系统跑起来才测试单个组件
  4. 打印关键指标------每个步骤都打印输入输出的数量和关键值
  5. 对比验证------用已知的正确方式(如 curl、独立脚本)对比可疑的代码路径

附录:完整文件清单

复制代码
D:\rag-platform\
├── .env                              # 环境变量配置
├── .env.example                      # 环境变量模板
├── .gitignore
├── docker-compose.yml                # 基础设施编排
├── package.json                      # Monorepo 根配置
├── tsconfig.base.json                # 共享 TypeScript 配置
├── turbo.json                        # Turbo 任务编排
├── hashed-gliding-metcalfe.md        # 实现方案(代码生成前的施工蓝图)
├── PROJECT_JOURNEY.md                # 本文档
│
├── packages/backend/
│   ├── package.json
│   ├── tsconfig.json
│   └── src/
│       ├── index.ts                  # 后端入口
│       ├── config/
│       │   ├── index.ts              # 配置导出
│       │   └── schema.ts             # Zod 校验 schema
│       ├── server/
│       │   ├── app.ts                # Fastify 实例
│       │   └── routes/
│       │       ├── config.ts         # /api/v1/config
│       │       ├── documents.ts      # /api/v1/documents CRUD
│       │       ├── eval.ts           # /api/v1/eval/*
│       │       ├── feedback.ts       # /api/v1/feedback
│       │       ├── ingest.ts         # /api/v1/documents POST
│       │       └── query.ts          # /api/v1/query POST
│       ├── services/
│       │   ├── document.service.ts   # 文档 CRUD
│       │   ├── evaluation.service.ts # 评估服务
│       │   ├── feedback.service.ts   # 反馈服务
│       │   ├── generation.service.ts # LLM 生成
│       │   ├── ingestion.service.ts  # 文档入库
│       │   └── retrieval.service.ts  # 检索服务 + RRF
│       ├── graph/
│       │   ├── state.ts              # LangGraph 状态定义
│       │   ├── graph.ts              # 图编译
│       │   ├── nodes/                # 10 个节点
│       │   │   ├── classify.node.ts
│       │   │   ├── compress.node.ts
│       │   │   ├── decompose.node.ts
│       │   │   ├── format.node.ts
│       │   │   ├── generate.node.ts
│       │   │   ├── grade.node.ts
│       │   │   ├── hyde.node.ts
│       │   │   ├── rerank.node.ts
│       │   │   ├── retrieve.node.ts
│       │   │   └── rewrite.node.ts
│       │   └── subgraphs/
│       │       ├── correction.graph.ts
│       │       └── retrieval.graph.ts
│       ├── llm/
│       │   ├── openai.ts             # ChatOpenAI 客户端
│       │   ├── provider.ts
│       │   ├── models.ts
│       │   └── prompts/
│       │       ├── rag.prompt.ts
│       │       ├── grade.prompt.ts
│       │       └── hyde.prompt.ts
│       ├── embeddings/
│       │   ├── openai.ts             # OpenAIEmbeddings 客户端
│       │   └── provider.ts
│       ├── reranker/
│       │   ├── cohere.ts
│       │   ├── cross-encoder.ts
│       │   └── provider.ts
│       ├── chunking/
│       │   ├── chunker.ts            # 分块策略路由
│       │   └── strategies/
│       │       ├── fixed.ts
│       │       ├── hierarchical.ts
│       │       ├── markdown.ts
│       │       └── semantic.ts
│       ├── parsers/
│       │   ├── parser.ts             # 解析器接口+工厂
│       │   ├── pdf.parser.ts
│       │   ├── docx.parser.ts
│       │   ├── excel.parser.ts
│       │   ├── html.parser.ts
│       │   └── markdown.parser.ts
│       ├── vectorstore/
│       │   ├── milvus.ts             # Milvus 客户端封装
│       │   ├── collections.ts
│       │   ├── schema.ts             # 集合 schema
│       │   └── bm25.ts               # BM25 稀疏向量
│       ├── metadata/
│       │   ├── extractor.ts
│       │   └── filter.ts
│       ├── dedup/
│       │   └── deduplicator.ts
│       ├── db/
│       │   ├── postgres.ts           # PostgreSQL 客户端
│       │   └── migrations/
│       │       └── 001_initial.sql
│       ├── cache/
│       │   └── redis.ts
│       ├── tracing/
│       │   └── tracer.ts
│       └── types/
│           ├── document.ts
│           ├── chunk.ts
│           ├── query.ts
│           └── eval.ts
│
├── packages/frontend/
│   ├── package.json
│   ├── tsconfig.json
│   ├── vite.config.ts
│   ├── index.html
│   └── src/
│       ├── main.tsx
│       ├── App.tsx
│       ├── styles/index.css
│       ├── api/
│       │   ├── client.ts
│       │   ├── documents.ts
│       │   ├── eval.ts
│       │   └── query.ts
│       ├── components/
│       │   ├── chat/
│       │   │   ├── ChatWindow.tsx
│       │   │   ├── MessageList.tsx
│       │   │   ├── MessageBubble.tsx
│       │   │   ├── QueryInput.tsx
│       │   │   ├── CitationPanel.tsx
│       │   │   ├── ConfidenceBadge.tsx
│       │   │   └── FeedbackButtons.tsx
│       │   ├── upload/
│       │   │   ├── DocumentManager.tsx
│       │   │   ├── DocumentUpload.tsx
│       │   │   ├── UploadProgress.tsx
│       │   │   └── DocumentList.tsx
│       │   ├── eval/
│       │   │   ├── EvalDashboard.tsx
│       │   │   ├── MetricsChart.tsx
│       │   │   └── ComparisonView.tsx
│       │   └── config/
│       │       └── ConfigPanel.tsx
│       ├── hooks/
│       │   ├── useQuery.ts
│       │   ├── useUpload.ts
│       │   └── useSSE.ts
│       └── store/
│           ├── chatStore.ts
│           └── configStore.ts
│
└── packages/shared/
    ├── package.json
    ├── tsconfig.json
    └── src/
        ├── index.ts
        └── types/
            ├── config.ts
            ├── chunk.ts
            ├── document.ts
            ├── query.ts
            └── response.ts

源码下载:rag-platform.zip

公众号: