RAG 工程性实践
背景: 基于之前相关技术性积累,实现RAG的工程,GitHub。
mono — AI 知识库 monorepo(样板框架)
框架/检索编排/数据闭环/工作流端到端/可观测与生产化,全链路实测。
结构
mono/
├── apps/
│ ├── api/ # @repo/api —— Hono API 网关(框架核心,集成一律为插件)
│ ├── data/ # @repo/data —— 多仓库→多向量库增量同步(webhook→Stream→diff→嵌入→per-repo collection)
│ ├── ingestion/ # @repo/ingestion —— 摄入服务(生产:chokidar+webhook+轮询 → XADD;消费:切分/嵌入 → Qdrant upsert)
│ └── web/ # @repo/web —— Next 14 + React Flow + shadcn 风格组件
└── 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 画布编辑(增删节点/连线/条件边)→ 保存(PUT,改图自动升版本)→ 版本历史浏览 →
POST /v1/workflows/:id/run多轮执行(messages 历史重放 + MemorySaver checkpointer)。摄入三来源: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 唯一事实源(全链路复用)技术选型与理由
实现状态(五个阶段全部完成)
框架骨架:插件体系(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 部署化
附加能力:多仓库 → 多向量库增量同步(apps/data,五阶段蓝图之外的扩展,详见下节)。
多仓库 → 多向量库增量同步(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/markdown/code/fixed)
│ → 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)
接入与运维:
# 新仓库三步:submodule 拉取 → sync.config.yaml 登记 → 全量初始化
git submodule add <repo-url> apps/data/repos/<name>
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 化(pnpm dev:data 直跑长驻 Node 进程);repos/ 与 .sync-state/ 已 gitignore,凭据复用 .env(QDRANT_URL / REDIS_URL / OPENAI_API_KEY)。
待改进(已知短板)
部署(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 的启动命令已带 --env-file-if-exists=.env 自动加载)。凭据以根 .env(JSON 总账)为唯一事实源,按下方键名拆分到各服务:
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)
# ---- 可观测(可选;配齐才启动 OTel SDK 上报 Langfuse)----
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
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/v1web(端口 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运维对接
健康检查:api
GET /healthz(存活,无依赖)与GET /readyz(就绪:enabled 插件全健康 200,否则 503);data 亦有/healthz、/readyz(后者上报 redis 可用性)。可直接对接 k8s liveness/readiness probe。compose 的 healthcheck 一律用/healthz——/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 与共享卷。
评论