Appearance
项目一速成:企业知识库 RAG Agent
深挖版见 01-项目-企业知识库RAG(追问树、决策表、挑战全在那里)。 本文目标:60 分钟读懂 + 20 分钟画熟 + 20 分钟背数字,让你被任何一句简历内容追问时都能接住第一层。
记忆锚点:状态与边界。 异步靠「可重放的状态表」兜底,正确性靠「检索层的过滤收口」保证。整个项目所有设计都能归到这两句话上。
一、项目名片(30 秒能背)
- 业务:企业制度、方案、资料散在 PDF/DOCX/TXT 里,人工查找慢、答案无出处 → 统一知识库入口:异步入库、权限隔离、语义检索、流式问答、引用溯源。
- 我的位置:Python 后端 + RAG 链路全部(数据模型、入库流水线、检索、问答编排、权限、SSE);前端和实例运维是协作方。
- 栈:Python / FastAPI / PostgreSQL(pgvector) / Redis / ARQ / MinIO / SQLAlchemy+Alembic / SSE。
- 一句话价值:难点不在模型,在异步流水线的状态管理、检索层的权限边界、失败路径的兜底。
二、架构与两条故事线(必须画得出)
入库这条线(背):上传只做「落 MinIO + 建记录 + 投 job」三件事立刻返回;worker 按解析→分块→Embedding→入库推进,每个阶段边界落状态;失败按阶段定位,重试不用整篇重来;进度经 Redis 到 SSE。
问答这条线(背):先鉴权;问题理解把「那它的审批流程呢」补全成独立问题;召回必须带三个过滤条件(知识库 ID、文档状态、未删除);生成时模型只能引用给定编号;生成后程序校验编号;召回为空或证据不足走拒答转人工。
三、技术方案四大块
3.1 数据模型:五张表
| 表 | 关键字段 | 为什么这么设计 |
|---|---|---|
| knowledge_base | 状态 | 权限和检索范围的单位 |
| document | 状态机(待处理/处理中/成功/失败/已删除)、MinIO 路径 | 异步入库没有状态就没有重试、进度、失效过滤 |
| chunk | 向量、文档 ID、页码、章节路径、Embedding 模型版本 | 召回最小单位要独立索引;页码支撑引用;向量必须记模型版本 |
| conversation / message | 消息挂引用 chunk | 翻历史时引用还在 |
| permission | 用户 × 知识库 × 角色 | 约束接口和检索两层,不耦合业务表 |
- 索引三处:chunk 表
(库ID, 状态)复合索引、document 表(库ID, 状态, 创建时间)、permission 表(用户ID, 库ID)唯一约束。 - 删除是软删:请求内立即返回;检索靠过滤屏蔽;物理清理走低峰定时任务。代价是表膨胀要配清理。
- Alembic:三个月 schema 改了很多轮,迁移进版本库才不会「本地能跑线上报错」。写迁移守则:加列可空、不直接删列、重命名拆两步。
- 重新入库不做原地更新:新版本写入 + 旧版本标记失效 + 检索只认当前版本(半新半旧无法解释)。
3.2 异步入库流水线(ARQ)
- 为什么异步:几百页 PDF 解析+分块+Embedding 是分钟级,放 HTTP 里必超时;网关超时了任务还在跑,用户重试就重复入库。
- 为什么拆四个阶段(这题必被问):四个阶段失败原因和重试代价完全不同——解析失败多是文件本身问题(加密/扫描件),重试没用直接标失败;Embedding 失败多是限流超时,退避重试能过;写库要幂等。混成一个黑盒就只有一个「处理失败」,运维是瞎的。
- 一个 job 内四步(不是四个 job):阶段间传的是整篇文本和 chunk 列表,拆开要重落中间产物;阶段级状态已够定位失败点。Embedding 要独立扩容时才值得拆队列。
- 状态放哪:数据库是权威(刷新页面、worker 重启都不丢),Redis 供 SSE 实时推。再加一个巡检:超过阈值仍在「处理中」的判定超时重置——只靠队列语义救不回「任务被领走但进程死了」。
- 幂等:入库阶段先按「文档 ID+版本」删半成品再批量写,重跑收敛到同一状态。
- ARQ vs Celery(必被问):全链路 asyncio,ARQ 原生 async;只依赖 Redis;量级用不上 Celery 的路由/优先级。代价:生态小没有现成面板,队列可靠性弱于 RabbitMQ——所以兜底靠状态表可重放,不靠队列。
- 大文件:解析分块按页流式、Embedding 限批、job 整体超时、单文件大小页数上限;CPU 密集的解析丢进 executor,不能让同步调用卡住事件循环。
3.3 检索:pgvector 与正确性边界
- HNSW + cosine:召回-延迟曲线比 IVFFlat 好,且不用先有数据训练(增量入库友好);主流 Embedding 输出归一化向量按余弦解释。注意算子
<=>要和建索引的距离类型一致。 - 三个过滤条件是硬约束不是应用层筛选:知识库 ID(从权限表算出的可见集合取交集,不是前端传什么用什么)、文档状态=成功、未删除。写成 SQL 的 WHERE:一是避免 top-k 召回一半被筛掉,二是杜绝任何调用方漏过滤。
- 收口(故障沉淀):曾经后台相似推荐那条路径漏了删除过滤,软删文档被引用。之后向量检索收敛成唯一入口函数,过滤条件函数内强制拼——正确性不能靠每个调用方记得写。
- 过滤会废掉近似索引(主动讲加分):过滤选择率低时近邻里几乎全被过滤掉、top-k 拿不满。解法三层:partial index(只索引成功且未删的行)、按库分区、放大
ef_search再过滤。 - 分块:结构优先(标题层级/段落,制度文档一条 chunk=一条完整规定),超长段落固定窗口 + overlap。参数是评测集试出来的,不是拍的。
- 首版纯向量——坦诚说:短板是文号、专有名词字面匹配会漏;当时优先保入库可靠性和权限正确性(出问题是事故,检索差是体验)。演进方向:BM25+向量双路 + RRF + Rerank(项目四里就是这么做的,可互指)。
3.4 问答链路、引用与权限 SSE
- 「没有证据就不许说话」:拒答是默认分支不是异常分支。企业里编一条制度比答不上危害大。
- 证据不足判定是组合信号:召回为零直接拒;最高分低于基线阈值拒;命中全来自同一极短 chunk 降级。不用单一相似度阈值(不同模型分数尺度不可比)。
- 引用靠程序不靠自觉:证据编短号,模型只许输出编号,生成后映射回 chunk,映射不上丢弃;剔完全没引用就降级。页码:PDF 解析时记进 chunk 元信息;DOCX 无页码退化为「章节+段落序号」(要坦白这个不对称)。
- 上下文预算:按分数逐条填充不截断句子(截断引用就对不上);多轮历史滑窗+摘要。
- 权限两层(纵深防御):接口层 FastAPI 依赖注入查「用户×库×角色」,不满足 403;检索 SQL 强制注入可见库集合。两层失效模式不相关(遗漏 vs 查询构造错误)。粒度定库级不定义档级:文档级 ACL 维护成本爆炸且逐文档判权毁掉向量检索性能。
- SSE 不用 WebSocket:单向流;复用 HTTP 网关/鉴权/日志/限流;浏览器自带重连。SSE 生命周期限一次问答,规避 JWT 无法回收 + 长连接不感知权限变更的问题。
- 生产坑两个(必讲):反向代理响应缓冲导致「憋到最后一次吐」(关 buffer + 心跳);多副本下进度必须走 Redis 共享不能进程内内存。
四、简历 bullet 对照表
| 简历 bullet | 两个技术锚点(提词) | 20 秒展开方向 |
|---|---|---|
| 数据模型 + SQLAlchemy/Alembic | 状态机 / chunk 独立表带页码和模型版本 | 稳定性首先是数据模型问题:文档生命周期状态支撑重试和失效过滤;Alembic 管 schema 演进 |
| Redis + ARQ 流水线 | 四阶段失败原因不同 / 状态表可重放 | 上传只登记,重活进 worker;失败按阶段定位重试;幂等靠删半成品重写 |
| pgvector 召回 + 三过滤 | 收口成唯一入口 / 过滤废近似索引的矛盾 | 防跨库召回和失效命中是正确性问题,不是体验问题;过滤下推到 WHERE |
| 问答链路 + 引用 + 拒答 | 编号引用程序校验 / 组合信号拒答 | 没有证据不许说话;引用能落到文档+页码+chunk |
| JWT+RBAC + SSE | 接口层+SQL 层双保险 / SSE 单向复用 HTTP | 区分查看/提问/写入;SSE 一次问答一条连接 |
五、数字解释卡(7 个)
先对齐
下面是当前简历成稿(resume-filled.md)上的示例值。口径和讲法现在就练熟——投递前若换成真实数据,逻辑不变只换数值;真实值统一回填 11 号文件。
| 简历上的数 | 口径(分子 / 分母 / 怎么测) | 被问「怎么来的」第一句话 |
|---|---|---|
| 6 个知识库 | 实际创建且有文档在用的库数,剔除测试库 | 「按部门/主题划分、真实在用的库,测试库不算」 |
| 3,200 份文档 | 文档表中入库成功且未删除的记录数;说清是当前有效不是累计上传 | 「有效文档数,解析失败和已删除的不在内」 |
| 约 28,000 个分块 | chunk 表有效行数;平均约 9 块/文档,对应十几页文档 + 条款级切分 | 「平均一份文档 9 个块左右,因为我们按条款结构切,不是固定长度」 |
| 120 条评测样例 | 问题 + 标准答案 + 应命中 chunk;分层:单文档可答 / 跨文档综合 / 无答案负样本(测拒答) | 「我自己整理加业务方提供问题,每条都标了应命中的 chunk,专门放了一层库里没有答案的负样本测拒答」 |
| Recall@5 = 86% | 分母 = 有标准答案的问题数;分子 = top5 至少命中一条标准 chunk 的问题数(hit-rate 版,chunk 级判定);某一版切分+某一版 Embedding 下测的 | 「86% 是 hit-rate 口径:120 条里 top5 命中标准 chunk 的比例,chunk 级判定,不是文档级。它主要用来对比切分和检索策略迭代——哪次改动变好变坏看它」 |
| 引用完整性 = 91% | 分母 = 产生了回答的问题数(拒答不计);分子 = 引用能映射回真实 chunk 且支撑结论的比例;编号有效性程序校验 + 相关性人工抽检 | 「两层:编号有效性是程序校验的,引用是否真支撑结论是抽检的;拒答的样本不进分母」 |
| 10 分钟 → 2 分钟 | 同一批查询任务的人工完成时间对比(找到答案并确认可用);业务侧计时/访谈口径,样本量不大 | 「业务侧拿同一批查询任务计的时:原来翻共享盘加问人约 10 分钟,现在提问到看到带引用的答案确认可用约 2 分钟——这是量级口径,我不当精确均值报」 |
数字互查关系(面试官会拿数打数):28,000 ÷ 3,200 ≈ 9 块/文档——要能立刻接上切分粒度;86% + 120 条——样本小,主动说「我只用它看退步,不做小幅优劣判断」。
说不准时的兜底句:「这个数是那一版配置下的结果,具体值我需要回去核评测记录,但口径我可以完整讲一遍。」
六、高频追问 8 题(每题 3-4 句接住)
- 为什么 pgvector 不用 Qdrant?你 BI 项目不是用了 Qdrant? —— 判断标准是「向量要不要和关系数据做强一致联合查询」。这里要和文档状态、软删、权限 JOIN 出引用,同库一条 SQL 搞定、共享事务、少一个组件;BI 那边元数据量小结构独立,专用向量库更干净。
- 为什么 ARQ 不用 Celery? —— 全 asyncio 链路 ARQ 原生异步;只依赖 Redis;量级用不上重型能力。代价是没有现成面板、队列可靠性弱,所以状态表可重放才是兜底。
- 只有向量召回,是不是入门版 RAG? —— 首版确实是单路,短板是文号类字面匹配。当时把时间投在入库可靠性和权限正确性上,因为那两块出问题是事故、检索差是体验。双路+RRF+Rerank 的补法我在政务项目里完整做过。
- 证据不足怎么判定?阈值多少? —— 组合信号不是单一阈值:零召回直接拒、最高分低于基线拒、证据极短降级。更稳的方向是加一个轻量判别步骤让模型自判证据充分性。
- 引用会不会是模型编的? —— 会,所以做后置校验:证据编号制 + 程序映射,映射不上就丢。原则是「程序兜底模型,不是相信模型」。
- 权限为什么做两层,不冗余吗? —— 故意的纵深防御:接口层失效于开发遗漏,SQL 层失效于查询构造错误,失效模式不相关。漏一个接口就是整片资料泄露。
- SSE 断线 / 多副本怎么办? —— 入库进度断了重连后从状态表续显(状态落库的另一个理由);生成断了首版让用户重问;多副本进度必须走 Redis 共享。
- 三个月做完五块,可信吗? —— 每块都是「生产可用的第一版」不是极致版:单路检索、库级 RBAC、五张核心表;且复用了前面项目沉淀的 FastAPI 骨架、SSE、LLM 接入和评测方法,真正新增量是入库流水线和 pgvector 检索。主动说清每块没做什么,比声称全做完可信。
故障故事一句话版(换成你真实的):⚠️ 深挖版第五节的四个故事挑一个背熟。模板:软删文档仍被检索到 → 两条检索路径只有一条带了过滤 → 收口成唯一入口函数 + 补断言测试 → 沉淀「正确性靠收口不靠自觉」。
七、速记卡(面试前 10 分钟)
- 锚点:状态与边界。异步兜底=状态表可重放;正确性=过滤收口。
- 入库线六个词:落盘、建记录、投 job、四阶段、落状态、可重试。
- 问答线六个词:鉴权、改写、三过滤、编号、校验、拒答。
- 五张表:库 / 文档(状态机)/ chunk(页码+模型版本)/ 会话消息 / 权限。
- 四阶段为什么拆:失败原因和重试代价不同。
- ARQ 三个理由:原生 async、只依赖 Redis、量级用不上重型;兜底靠状态表不靠队列。
- 检索三过滤:库 ID(可见集合交集)+ 成功状态 + 未删除;唯一入口函数。
- HNSW + cosine;过滤选择率低会废近似索引(partial index / 分区 / 放大候选)。
- 权限两层=纵深防御;SSE=单向复用 HTTP,一次问答一条连接。
- 数字:6 库 / 3,200 文档 / 28,000 块(≈9 块每份)/ 120 条 / 86% hit-rate / 91% 引用 / 10→2 分钟。