投研智库 RAG Agent(一):项目背景、痛点与总体架构
「投研智库 RAG Agent」专栏第 1 篇。本专栏以连载形式完整记录一个企业知识库问答系统从 0 到 1 的构建过程------本篇讲清楚:为什么做、目标是什么、整体怎么设计。
专栏目录
- 项目背景与总体架构(本篇)
- 环境底座:Docker Compose 一键拉起向量库全家桶
- 导入链路(上):LangGraph 图设计与前置解析节点
- 导入链路(中):「长切短合」文档切分策略
- 导入链路(下):实体识别、BGE-M3 向量化与 Milvus 双集合
- 查询链路(上):实体确认三分支与双路召回
- 查询链路(下):RRF 融合、Rerank 精排与效果验证
- 工程化收官:FastAPI 双服务、两段式 SSE 与生产化改造清单
一、为什么要做这个系统:三个真实痛点
资管业务里,基金产品说明书、投研材料、制度合规文档分散在各处,客服和业务人员的知识获取一直有三个痛点:
- 实体表述不一致 :用户说的是基金简称、代码、口语别名,文档里是全称------直接检索经常"答非所问",更危险的是跨实体误答(问 A 产品答成 B 产品,在金融场景这是事故级问题);
- 口语化提问 vs 正式文档表达的语义鸿沟:用户问"这产品会不会亏",文档写的是"风险等级与回撤说明",普通向量检索召回不到;
- 裸 LLM 不可信:无溯源、会幻觉、无法拒答------企业场景需要"有依据才回答,没依据就承认不知道"。
所以这个系统的设计目标从第一天起就不是"能搜到一些 chunk",而是四个字:可信回答------不串实体、可拒答、可溯源、可观测。后面每一篇文章里的设计决策,都能回溯到这四个字。
把"可信回答"再拆细一层,它对应四条可验证的工程要求:
| 目标 | 工程落点 | 对应机制(后文详解) |
|---|---|---|
| 不串实体 | 检索必须带实体过滤条件 | 导入侧实体标签 + 查询侧实体确认三分支(第 5、6 篇) |
| 可拒答 | 置信度不足 / 证据不足时明确说"不知道" | 0.85/0.60 双阈值 + 生成端证据约束(第 6、7 篇) |
| 可溯源 | 答案能给出引用的 chunk 与原文出处 | chunk 元数据(文件名/标题/part 序号)全程携带(第 4、5 篇) |
| 可观测 | 每一步在干什么、卡在哪,运维能看到 | 节点级任务状态 + SSE 进度事件 + 中间产物落盘(第 8 篇) |
二、技术选型总览
| 层 | 选型 | 一句话理由 |
|---|---|---|
| 编排 | LangGraph StateGraph |
固定编排图,控制流由工程代码决定,确定性场景可控优先 |
| Web 框架 | FastAPI | 原生 async、Pydantic 校验、自带 Swagger,SSE 支持好 |
| 向量库 | Milvus 2.5 | dense + sparse 双向量字段、hybrid search、生产级 |
| Embedding | BGE-M3(本地,FlagEmbedding) | 一个模型同时出稠密+稀疏向量,8192 token 长输入 |
| 精排 | BGE-Reranker-Large(本地) | cross-encoder 细粒度相关性 |
| PDF 解析 | MinerU | 扫描版/表格 PDF 解析成 Markdown,保留标题层级 |
| 对象存储 | MinIO | 文档图片持久化(Milvus 内部存储也复用同一实例) |
| 会话历史 | MongoDB 7 | 多轮对话历史落库(任务进度走内存任务表,见第 8 篇) |
| 生成模型 | 云端 LLM(OpenAI 兼容接口) | 生成走 API,Embedding/Rerank 本地跑省成本 |
选型里有一条贯穿性原则:高频批量调用(向量化、精排)本地跑,低频高价值调用(生成、改写)走云端------导入一份手册要向量化几百个 chunk,走 API 又贵又慢。
版本约束上有两个实际教训值得记录:
pymilvus[model]>=2.6与 Milvus Server 2.5.4 搭配使用,sparse 向量相关 API 在旧版本 SDK 上行为不一致,升级 SDK 前先查 release note;flagembedding>=1.3.5依赖特定版本区间的transformers,锁定在uv.lock里避免隐式升级把本地推理搞挂。
三、总体架构:Import / Query 双 Agent 流水线
系统拆成两条独立的 LangGraph 流水线,各自是一个 StateGraph 固定编排图:
scss
┌─────────────────────── Import 导入链路 ───────────────────────┐
│ node_entry → node_pdf_to_md → node_md_img → node_document_split │
│ (入口校验) (MinerU解析) (图片摘要+MinIO) (长切短合) │
│ → node_item_name_recognition → node_bge_embedding │
│ (实体识别+实体库) (BGE-M3 双向量) │
│ → node_import_milvus │
│ (双集合幂等入库) │
└──────────────────────────────────────────────────────────────┘
┌─────────────────────── Query 查询链路 ────────────────────────┐
│ node_item_name_confirm ──┬─ 高置信(≥0.85) → 继续检索 │
│ (历史读取+改写+实体对齐) ├─ 中置信(0.60~0.85) → 返回候选反问 │
│ └─ 低置信(<0.60) → 拒答 │
│ ↓ │
│ node_search_embedding ──┐ │
│ node_search_embedding_hyde ─┤→ node_rrf → node_rerank → answer │
│ (双路并行召回) (RRF融合) (BGE-Reranker) (SSE流式) │
└──────────────────────────────────────────────────────────────┘
代码组织:目录即架构
bash
app/
├── import_process/ # 导入链路(独立服务,端口 8000)
│ ├── agent/
│ │ ├── state.py # ImportGraphState 状态契约
│ │ ├── main_graph.py # 图组装:节点注册、条件边、编译
│ │ └── nodes/ # 7 个节点,一节点一文件
│ ├── api/ # FastAPI 服务与上传接口
│ └── page/ # import.html(FastAPI 托管)
├── query_process/ # 查询链路(独立服务,端口 8001)
│ ├── agent/ # state.py + main_graph.py + 8 个节点
│ ├── api/ # 查询与 SSE 流式接口
│ └── page/ # chat.html
├── clients/ # Milvus / MinIO / Mongo / Neo4j 客户端封装
├── lm/ # 本地模型:embedding_utils / reranker_utils / lm_utils
├── conf/ # 各组件配置(从 .env 读取)
├── core/ # logger、prompt 加载器
└── utils/ # sse_utils、task_utils、稀疏向量归一化等
prompts/ # 所有 Prompt 模板独立成 .prompt 文件
三条组织原则:
- 一节点一文件 :每个节点文件自带
if __name__ == '__main__'单测入口,可以脱离图独立跑(第 3 篇细讲); - 客户端统一封装在
clients/:节点不直接 import pymilvus/pymongo,连接管理、重试、转义统一收口; - Prompt 不写在代码里 :全部放
prompts/*.prompt文件,由core/load_prompt.py加载------改 Prompt 不动代码、diff 清晰可审。
为什么拆成两条链路?
因为两侧的优化目标完全不同:导入侧关心知识质量 (解析、分块、元数据、向量质量),查询侧关心回答可信度(理解、召回、排序、生成约束)。拆开之后问题定位也清晰------命中率差,到底是分块切碎了、向量选错了、还是 Query 改写偏了,可以分层归因。
两条链路还各自包成独立 FastAPI 服务(导入 8000 / 查询 8001):导入是低频重任务(解析+向量化,CPU 密集),查询是高频轻任务(毫秒级接口+流式推送),资源画像完全不同,拆开互不拖累。
为什么用固定编排图而不是自由 ReAct?
企业知识库问答是确定性强、风险高的场景,控制流应该由工程代码和条件边决定,而不是让模型每步自由发挥。准确说这是"预编排 Agentic Workflow ",不是开放 Agent------这个定位是有意的架构取舍,不是能力不足。(什么场景才值得让模型自主规划?姊妹专栏《研发效能 Multi-Agent 系统》讲的就是另一条路线。)
具体到这个系统,LLM 只出现在四个受控位置,每处都有明确的输入输出约束和失败兜底:
| LLM 调用点 | 职责 | 输出约束 | 失败兜底 |
|---|---|---|---|
| 图片摘要(导入) | 图片 → 描述文本 | 纯文本 | 跳过该图,保留原图链接 |
| 实体识别(导入) | chunk → 归属实体 | JSON | 重试 + 空实体标记 |
| 改写与实体抽取(查询) | 口语问题 → 自包含问题 + 候选实体 | JSON | 用原始问题降级检索 |
| 答案生成(查询) | 证据 chunk → 回答 | 证据约束 Prompt | 证据不足明确拒答 |
控制流永远在图手里,LLM 只做"文本进、文本出"的受限变换------这就是"可控优先"的具体含义。
四、一次典型请求的完整数据流
用一个真实问题把两条链路串起来。前提:已导入《掌柜 ZR1200W 企业级无线路由器》产品手册。
导入时发生了什么(一次性成本):
bash
上传 PDF → MinerU 解析成带标题层级的 Markdown(约 3 万字)
→ 图片上传 MinIO + VL 模型生成摘要回填(十几张图)
→ 长切短合切分(→ 约 80 个 chunk,携带标题/part 元数据)
→ LLM 识别实体"掌柜ZR1200W" → 写入每个 chunk 元数据 + 实体库
→ BGE-M3 生成 dense(1024维) + sparse 双向量
→ Milvus 双集合幂等入库(CHUNKS + ITEM_NAME)
查询"ZR1200W 这个路由器最多带多少台设备?"时发生了什么(每次请求):
sql
① 读 MongoDB 最近历史 → LLM 改写 + 抽实体"ZR1200W"
② 实体向量化 → ITEM_NAME 集合 hybrid search → 得分 0.93 ≥ 0.85 → 自动确认
③ fan-out 并行:改写 query 向量检索 ∥ HyDE 假设文档检索(均带实体过滤)
④ RRF 按排名融合去重 → 截断 Top10
⑤ BGE-Reranker 精排 → 精选 Top5 进 Prompt
⑥ 证据约束生成,答案带出处,SSE 逐 token 推给前端
每一步的中间结果都可检查:改写后的 query、确认的实体与得分、各路召回列表、RRF/Rerank 排序,全部落日志------排障时可以精确回答"错在哪一环" 。
五、构建路线:为什么按这个顺序连载
本专栏的文章顺序就是项目真实的构建顺序,背后是一条数据依赖链:
环境底座 ← 没有 Milvus/MinIO/Mongo,后面全免谈
↓
导入链路(7节点) ← 库里没数据,查询无从谈起 → 数据先行
↓
查询链路(8节点) ← 有数据了,才能搭检索和生成
↓
工程化 + 调优 ← 端到端通了,才有资格谈流式体验和效果优化
两条原则贯穿始终:
- 数据先行:先把"写入"做扎实,再做"读取"。导入链路是查询链路的地基;
- 每步可验收:每篇文章结尾都有一个"里程碑动作"证明这一步通了------不攒大招、不到最后才联调。
六、本篇小结
- 目标:可信回答(不串实体、可拒答、可溯源、可观测),四个词各自落到具体机制上,而不是口号;
- 架构:Import / Query 双
StateGraph流水线 + 双 FastAPI 服务,固定编排、可控优先,LLM 只在四个受控位置出现; - 代码组织:一节点一文件、客户端统一收口、Prompt 独立成文件;
- 路线:数据先行、每步可验收。
下一篇我们从最底层开始:用 Docker Compose 一键拉起 Milvus / MinIO / MongoDB / Attu,把地基打好。
下一篇:《环境底座:Docker Compose 一键拉起向量库全家桶》