RAG 工程性实践
背景: 基于之前相关技术性积累,实现RAG的工程,项目采用的技术栈均为开源,最终所需的花费为LLM的模型调用,项目地址为 GitHub。
mono — AI 知识库 monorepo(样板框架)
当前进度:五个阶段全部完成(框架/检索编排/数据闭环/工作流端到端/可观测与生产化,全链路实测),另有若干蓝图外扩展(多仓同步 apps/data、登录与技能/工具注册表、对外供给 apps/mcp)。
结构
mono/
├── apps/
│ ├── api/ # @repo/api —— Hono API 网关(框架核心,集成一律为插件)
│ ├── data/ # @repo/data —— 多仓库→多向量库增量同步(webhook→Stream→diff→嵌入→per-repo collection)
│ ├── ingestion/ # @repo/ingestion —— 摄入服务(生产:chokidar+webhook+轮询 → XADD;消费:切分/嵌入 → Qdrant upsert)
│ ├── mcp/ # @repo/mcp —— stdio MCP Server(Claude Code 接入;薄协议层转调 api,不进容器)
│ └── web/ # @repo/web —— Next 14 + React Flow + Tailwind 4/daisyUI 5(登录 + 三段式工作台)
└── packages/
└── types/ # @repo/types —— 共享 zod schema + 类型(唯一事实源)常用命令
核心能力
数据写入闭环:文件放入
INGEST_DIR→ chokidar 事件 → XADDingestion:events→ Consumer Group 消费 → 句边界切分(512/50)→ DashScope 批量嵌入 → Qdrant upsert(同 doc_hash 幂等)。多仓库 → 多向量库增量同步:
apps/data把任意数量的 GitHub 仓库同步进 Qdrant——每仓库一个独立 collection,push 时只同步本次提交的 A/M/D/R 文件变化(见下文专节)。真实 RAG 管线:
POST /v1/retrieval/query—— 嵌入 → 远程 Qdrant 多集合检索(knowledgeOfAI命名向量 + apps/data 同步的 per-repo 未命名向量集合,RAG_SEARCH_COLLECTIONS声明清单,逐集合查询按 score 合并取 topK)→ qwen-plus 中文合成。工作流编排(端到端):web 画布编辑(增删 7 类节点/连线/条件边 + 节点属性面板改 config)→ 保存(PUT,改图自动升版本)→ 版本历史浏览 →
POST /v1/workflows/:id/run多轮执行(messages 历史重放 + MemorySaver checkpointer)。登录与工作台:
/login(web 的 route handler 转调POST /v1/auth/login校验凭据后,在本域签发 HMAC 会话 cookie)→ 首页三段式 Tab:知识库问答 / 工作流 / 设置。技能与 MCP 工具注册表:设置页直接维护(
/v1/skills、/v1/mcp-tools全 CRUD,存 PG),工作流的skill/mcp节点经config.refId引用,由 langgraph 编译器接线执行。摄入三来源:chokidar /
POST /webhooks/:source/ 定时轮询 hash 比对。端到端类型安全:
@repo/typeszod schema → api 路由 →AppType→ webhc<AppType>。外部数据供给:知识库作为数据源对 其他 LLM / Claude Code 开放——纯检索端点(
/v1/retrieval/search)、MCP Server(apps/mcp,Claude Code 一条命令接入)、Qdrant/PG 直连,详见 DATA_ACCESS.md。
架构说明(对 plan.md 的实现方式)
总体架构
┌─────────────────────────────────────────────┐
│ 远程基础设施(凭据集中于根 .env JSON 总账) │
│ PostgreSQL :5433 Redis :6380 Qdrant :6333 │
│ DashScope(千问 qwen-plus + text-embedding-v4)│
└──────┬──────────────┬──────────────┬──────────┘
│ │ │
┌─────────────┐ ┌──────▼──────┐ ┌────▼───────┐ ┌───▼───────────┐
│ apps/web │ │ apps/api │ │ ingestion │ │ Langfuse(可选) │
│ Next 14 │hc │ Hono 网关 │ │ chokidar │ │ OTel SDK │
│ React Flow ├───▶│ 插件体系 │ │ webhook │ │ (SpanProcessor│
│ 画布/检索面板 │ │ ┌─────────┐ │ │ 轮询兜底 │ │ + 业务埋点) │
└─────────────┘ │ │langgraph│ │ │ │ XADD │ └───────────────┘
AppType │ │llamaindex│ │ │ ▼ │
(端到端类型安全) │ │qdrant │ │ │ Redis Stream
│ │drizzle │ │ │ │ XREADGROUP(Consumer Group)
│ │ioredis │ │ │ ▼
│ │observab.│ │ │ 切分(512/50)→嵌入→Qdrant upsert
│ └─────────┘ │ └───────────────┘
└─────────────┘
packages/types —— zod schema 唯一事实源(全链路复用)图中未展开的两条支线:apps/data(GitHub push → 每仓库一条 Redis Stream → worker → per-repo collection,见下节)与 apps/mcp(stdio MCP Server,转调 api 的检索/工作流端点,不进部署拓扑)。
技术选型与理由
实现状态(五个阶段全部完成 + 蓝图外扩展)
框架骨架:插件体系(6 集成懒连接 + 优雅降级)、@repo/types、错误语义(503 未配置 / 502 上游失败)
检索与编排:真实 RAG 管线(嵌入→Qdrant→qwen-plus)、WorkflowGraph 编译执行、ingestion/web 骨架
数据写入闭环:文件→Stream→Consumer Group→切分→嵌入→upsert(幂等),检索命中新文档实测
工作流端到端:画布编辑保存、版本历史、多轮 /run(MemorySaver checkpointer + messages 重放)
可观测与生产化:OTel 业务埋点、检索缓存(实测 80 倍加速)、PubSub 通知、Notion/Confluence 适配器、Docker 部署化
(蓝图外)登录与设置台:登录页 + HMAC 会话 cookie、skills / mcp_tools 两张注册表(api 全 CRUD + web 设置页管理)
附加能力(五阶段蓝图之外的扩展):多仓库 → 多向量库增量同步(apps/data,详见下节)、对外数据供给(MCP Server apps/mcp + 纯检索端点,见 DATA_ACCESS.md)。
多仓库 → 多向量库增量同步(apps/data)
apps/data(@repo/data,端口 3003)把任意数量的 GitHub 仓库增量同步进 Qdrant:每个仓库对应一个独立 collection(一对一),仓库 push 时只同步本次提交产生的变化文件(新增/修改/删除/重命名),不做全量重建。与 ingestion(文件目录 → knowledgeOfAI)是两条互补的数据通道。
GitHub push ─▶ [Webhook :3003] ─▶ Redis Stream data-sync:<repo> ─▶ [Sync Worker]
▲ (每仓库一条流,SET NX 租约独占消费)
│ │
[Reconcile 对账] ◀── 每 5 分钟比对远端头与 state,落后则补投递(防 webhook 丢失)
│
Qdrant per-repo collection ◀────┘
(chinese-buy-us-stock-guide-main 等)
▲ git fetch + diff(from=state.sha|空树, to=after)
│ A/M/D/R 分类 → glob 过滤 → 切块(auto/fixed_size/markdown_aware/code_aware)
│ → DashScope 批量嵌入(batch≤10)→ upsert(512KB 分片)
│ → 全部成功后写 .sync-state/<repo>.state.json(事务化推进)
└ 幂等点 ID:sha256(repo:file_path:chunk_index);M 差集删除防僵尸向量;
content_hash 不变跳过;R 内容未变向量搬运免重嵌核心保证:
配置驱动:仓库 ↔ collection 映射、glob 过滤、切块策略全部集中在
apps/data/sync.config.yaml(env:VAR 注入),新仓库接入零代码改动状态外置 + 幂等写入:同步进度存
.sync-state/,向量点 ID 由内容位置确定性生成——任何环节重放都不产生脏数据;worker 侧 isAncestor 收敛检查跳过乱序/重放的旧事件事务化推进:向量库全部写入成功才更新 state,失败停留在旧 sha 整段可重跑;单文件/单批嵌入与写库带指数退避重试,超上限进仓库 DLQ 流(
data-sync:dlq:<repo>)仓库级隔离 + 水平扩展:每仓库一条 Redis Stream 保证严格 FIFO,租约机制让 worker 可水平扩容、崩溃自动接管(XAUTOCLAIM)
接入与运维:
# 新仓库三步:检出仓库 → sync.config.yaml 登记 → 全量初始化
git submodule add <repo-url> apps/data/repos/<name> # 推荐(指针可追踪),普通 clone 亦可
pnpm --filter @repo/data cli backfill <name> # 先加 --dry-run 预览
# 运维命令(sync/backfill/cleanup/status/config-check/reconcile/worker/webhook/serve)
pnpm --filter @repo/data cli status # 各仓库同步状态与漂移
pnpm dev:data # 一体化启动(webhook+worker+对账)检索接线:同步进 per-repo collection 的数据经 api 的 RAG_SEARCH_COLLECTIONS 清单被 RAG 管线检索(见「核心能力」):knowledgeOfAI@text-embedding-v4,chinese-buy-us-stock-guide-main——带 @ 的是命名向量集合,不带 @ 的是 data 系统的未命名向量集合;单集合未创建仅告警跳过。已接入仓库:chinese-buy-us-stock-guide(美股指南,13 文档 45 块,已实测检索命中)。详见 apps/data/README.md。
注:apps/data 已 Docker 化(apps/data/Dockerfile + compose 服务 data,是四服务中唯一 env_file: required: true 的);repos/ 与 .sync-state/ 已 gitignore,容器内不做 git clone(须先在宿主机克隆到 apps/data/repos/<name>,再经 bind mount 进容器)。本地开发仍可 pnpm dev:data 直跑。
待改进(已知短板)
部署(Docker)
服务器部署完整步骤见 deploy/DEPLOY.md(前置条件、逐步命令、构建镜像的三种方式、日常运维与回滚、故障排查表、安全清单)。下面是速览。
一键起:docker compose(推荐)
docker-compose.yml 编排 api / web / ingestion / data 四服务,外加两个可选 profile(proxy 反代、local-infra 容器版基础设施):
cp deploy/api.env.example deploy/api.env # 4 份凭据文件(deploy/*.env 已 gitignore)
cp deploy/ingestion.env.example deploy/ingestion.env
cp deploy/data.env.example deploy/data.env
cp deploy/web.env.example deploy/web.env
pnpm docker:up # = docker compose --env-file deploy/compose.env up -d --build
pnpm docker:ps # 四个服务(端口默认只绑 127.0.0.1)
# 另两个 profile(需手动带 flag,脚本未封装):
# docker compose --env-file deploy/compose.env --profile proxy up -d # + Caddy 同源反代
# docker compose --env-file deploy/compose.env --profile local-infra up -d # + 容器版 PG/Redis/Qdrant⚠️ 所有 compose 命令都必须带 --env-file deploy/compose.env(或走 pnpm docker:*)。
compose 默认读项目根目录的 .env 做插值,而本仓根 .env 是 JSON 凭据总账,会直接报unexpected character "{" 而无法启动。deploy/compose.env 只放端口/域名/URL 等公开变量。
同源反代(生产推荐):在
deploy/compose.env设NEXT_PUBLIC_API_URL=https://<域名>/api与SITE_ADDRESS=<域名>,再pnpm docker:up+ 起 proxy profile —— api 无需暴露公网(api 业务路由目前无鉴权,见待改进表)。
Caddy 路由表(handle块按书写顺序匹配,顺序不能乱):
实测:
/api/healthz命中 api、/login命中 web、/webhook/github命中 data、漏写/api前缀的/v1/*落到 web 404、POST /api/auth/login命中 web 的 handler。服务端 API 地址分离(
API_URL_INTERNAL):容器化后 web 的服务端(route handler / RSC)不能复用NEXT_PUBLIC_API_URL——直连模式下容器里的localhost是 web 自己(ECONNREFUSED),同源模式下要绕公网域名回来(hairpin)。故apps/web/src/lib/api.ts在服务端优先读API_URL_INTERNAL,compose 已固定注入http://api:3000(见该文件注释)。排查"登录永远 503 auth_unavailable"时先看这里。GitHub webhook 入口:Caddy 已把
/webhook/github转给 data:3003(每仓库 HMAC secret 校验在应用内);ingestion 的/webhooks/*默认不对外,按需在deploy/Caddyfile放开。⚠️ 环境变量填写规则(实测踩过):可选变量必须整行注释掉,不要写成
KEY=留空。compose 的env_file会把空值注入成空字符串,对z.string().url().optional()是「存在但非法」→ ingestion / data 直接 Exited(1) 崩溃(api 因插件注册表容错,只降级 disabled)。各.env.example已按此规则写好注释。data 前置:容器内不做
git clone(git.ts只对已存在仓库做 fetch/diff)——须先在宿主机git clone <url> apps/data/repos/<name>;repos/与.sync-state/经 bind mount 持久化,镜像已装git/openssh-client,并放行safe.directory(bind mount 属主与容器 root 不一致)。data 的env_file是唯一required: true(其凭据为设计上的硬需求,缺文件时 compose 直接报错而不是崩溃循环)。构建期内联:
NEXT_PUBLIC_API_URL改动后必须重建 web 镜像(--build),改运行时 env 无效;web 的 CMD 是node apps/web/server.js(output: "standalone"产物),不读/app/.env,其运行时变量经env_file/environment注入。web 镜像实测 ~300MB 级:靠 standalone 产物 +@repo/api放在 web 的 devDependencies(它只是 type-only 导入;错放 dependencies 会让pnpm deploy把整棵后端依赖树打进镜像,实测涨到 954MB,见apps/web/next.config.mjs注释)。
手工单容器
四个服务均可从仓库根独立构建(多阶段:安装 → workspace 构建 → 提取生产产物;api/ingestion/data 用 pnpm deploy --prod,web 用 Next 的 output: "standalone"):
docker build -f apps/api/Dockerfile -t repo-api .
docker build -f apps/ingestion/Dockerfile -t repo-ingestion .
docker build -f apps/data/Dockerfile -t repo-data .
docker build -f apps/web/Dockerfile -t repo-web .各容器 .env 内容(docker 化后必须提供)
镜像不含任何凭据。两种注入方式二选一:① docker run --env-file <宿主机 .env>;② 挂载到容器内 /app/.env(api/ingestion/data 的启动命令均带 --env-file-if-exists=.env 自动加载;web 例外,见下)。凭据以根 .env(JSON 总账)为唯一事实源,按下方键名拆分到各服务(完整变量清单以 deploy/*.env.example 为准):
api(端口 3000,挂载卷建议:无,无状态)
# ---- 服务 ----
PORT=3000
LOG_LEVEL=info
STRICT_INTEGRATIONS=false # true = 未配置的集成启动报错
# ---- 远程资源(取自根 .env 总账)----
DATABASE_URL=postgresql://myappuser:****@<host>:5433/myappdb?sslmode=no-verify
REDIS_URL=redis://:****@<host>:6380/0
QDRANT_URL=http://<host>:6333
QDRANT_API_KEY=<token>
# ---- LLM / 嵌入(DashScope OpenAI 兼容)----
OPENAI_API_KEY=<sk-...>
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
RAG_CACHE_TTL=300 # 检索响应缓存秒数,0 禁用(需 REDIS_URL)
RAG_SEARCH_COLLECTIONS=knowledgeOfAI@text-embedding-v4,chinese-buy-us-stock-guide-main
# ---- 可观测(可选;配齐才启动 OTel SDK 上报 Langfuse)----
# 可选变量按本仓规则整行注释掉,不要写成 KEY= 留空
# LANGFUSE_PUBLIC_KEY=<pk-...>
# LANGFUSE_SECRET_KEY=<sk-...>
# LANGFUSE_HOST=https://cloud.langfuse.comingestion(端口 3002,挂载卷建议:被监听目录 -v /data/docs:/data/docs)
# ---- 服务 ----
PORT=3002
LOG_LEVEL=info
INGEST_DIR=/data/docs # 容器内被监听目录(宿主目录经 -v 挂载)
POLL_INTERVAL_MS=60000
# ---- 远程资源 ----
REDIS_URL=redis://:****@<host>:6380/0
QDRANT_URL=http://<host>:6333
QDRANT_API_KEY=<token>
# ---- LLM / 嵌入 ----
OPENAI_API_KEY=<sk-...>
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
INDEX_CHUNK_SIZE=512 # 消费侧切分参数(对齐 ../RAG 的 SentenceSplitter)
INDEX_CHUNK_OVERLAP=50data(端口 3003,挂载卷建议:apps/data/repos 与 apps/data/.sync-state)
# ---- 服务 ----
PORT=3003
LOG_LEVEL=info
# ---- 配置与状态路径(相对进程 cwd=/app;镜像内有默认 sync.config.yaml)----
DATA_CONFIG_PATH=sync.config.yaml
SYNC_STATE_DIR=.sync-state # 同步进度,须持久化否则重跑全量
# ---- 向量库 / 嵌入:必填(sync.config.yaml 经 env: 引用,缺失即 fail-fast)----
QDRANT_URL=http://<host>:6333
QDRANT_API_KEY=<token>
OPENAI_API_KEY=<sk-...>
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# ---- redis(可选:不配则 worker/对账禁用、push 被拒 503;留空会崩,故整行注释)----
# REDIS_URL=redis://:****@<host>:6380/0
# ---- worker / 对账调参 ----
WORKER_POLL_MS=1000
WORKER_LEASE_MS=600000
WORKER_MAX_RETRIES=5
RECONCILE_INTERVAL_MS=300000
# ---- 各仓库 webhook secret(变量名取自 sync.config.yaml 的 webhook_secret_ref)----
GH_WEBHOOK_SECRET_STOCK_GUIDE=<secret>web(端口 3001):运行时只需 AUTH_SECRET(会话 HMAC 签名密钥,<32 字符则登录一律失败);NEXT_PUBLIC_API_URL 是构建期内联的,须在 docker build 时注入:
docker build --build-arg NEXT_PUBLIC_API_URL=http://<api-host>:3000 \
-f apps/web/Dockerfile -t repo-web .(或改用 -e NEXT_PUBLIC_API_URL=... 在 docker run 时传入,效果相同。)
运行示例
# 方式一:宿主机 .env 文件注入(推荐,凭据不进镜像)
docker run -d --name repo-api --env-file ./deploy/api.env -p 3000:3000 repo-api
# 方式二:容器内挂载 .env(api/ingestion 启动命令会自动加载)
docker run -d --name repo-api -v ./deploy/api.env:/app/.env -p 3000:3000 repo-api
# ingestion 额外挂载被监听目录
docker run -d --name repo-ingestion --env-file ./deploy/ingestion.env \
-v /data/docs:/data/docs -p 3002:3002 repo-ingestion
# data 额外挂载仓库目录与同步状态(容器内不做 git clone,须先在宿主机检出到 repos/)
docker run -d --name repo-data --env-file ./deploy/data.env \
-v ./apps/data/repos:/app/repos -v ./apps/data/.sync-state:/app/.sync-state \
-p 3003:3003 repo-data运维对接
健康检查:api
GET /healthz(存活,无依赖)与GET /readyz(就绪:enabled 插件全健康 200,否则 503);data 亦有/healthz、/readyz(后者上报 redis 可用性)。可直接对接 k8s liveness/readiness probe。compose 的 healthcheck 一律探无依赖的存活路径(api/data 用/healthz,web 用/login,ingestion 用/)——/readyz依赖远程 infra,拿它做重启判据会误杀。优雅停机:api/ingestion/data 均监听 SIGTERM 逆序回收资源(redis/pool/OTel flush),k8s
terminationGracePeriodSeconds留 10s+ 即可。非 Edge 部署:api/ingestion/data 是长驻 Node 进程(@hono/node-server),不可部署到边缘运行时(Vercel Edge/CF Workers);web 是标准 Next.js。
MCP 接入(
apps/mcp,不进容器):客户端本地claude mcp add rag-workbench -- node <repo>/apps/mcp/dist/index.js,环境变量API_URL指向部署后的 api 地址。单副本约束:api 的 langgraph checkpointer 是 MemorySaver(进程内存),data 的
repos//.sync-state/为本地卷——两者建议 replicas=1;要水平扩展先换 PG checkpointer 与共享卷。
评论