Appearance
项目一:企业知识库 RAG Agent
亿达信息技术有限公司 | 2026.04 - 2026.06 | Python 后端 / Agent 开发
本文是内部答辩底稿,不对外。所有效果类数字统一写成
<待核实>,简历上的示例值集中放在第六节对照。
开场前必须先对齐的三件事
- 这个项目在简历上的起止时间(2026.04 - 2026.06)与你在亿达的整段任职时间要能讲成一条线,第七节有专门处理。
- 团队里到底几个人、前端是谁、运维是谁 —— 第二节的"我做的 / 别人做的"边界必须换成真实情况。
- 第六节所有数字先自己算一遍,算不出来的宁可在面试里说"这个是估算口径",也不要报一个自己解释不了的百分比。
一、30 秒定位
企业的制度、项目方案和业务资料散在 PDF、DOCX、TXT 里,靠人翻文件夹找,找到了也说不清答案出自哪份文件哪一页。我做的是这个知识库的 Python 后端和 RAG 链路:设计知识库 / 文档 / 分块 / 会话 / 权限这套数据模型,搭 Redis + ARQ 的异步入库流水线,用 pgvector 做带权限和状态过滤的向量召回,再把"问题理解 → 证据召回 → 答案生成"串成一条会返回引用的问答链路。上线后文档上传走异步、大文件不再阻塞接口,每条回答都能落到具体文档、页码和 chunk,召回为空或证据不足时直接拒答转人工。我还搭了一套问题-标准答案的离线评测集,用它跟踪 Recall@K 和引用完整性,效果数据是 <待核实>。
一句话概括我的价值:这个项目的难点不在模型,在异步流水线的状态管理、检索层的权限边界和失败路径的兜底设计,这三块正好是后端工程的活。
二、架构与我的位置
一句话讲架构:请求侧只做"登记 + 鉴权 + 转异步",重活全部交给 ARQ worker;检索侧所有查询都必须带上知识库 ID 和文档状态过滤;对外只有两类实时通道,入库进度和问答流式输出,都走 SSE。
边界要说清楚(面试官很在意,别把整个项目说成自己的):
| 模块 | 归属 | 我实际做到哪一层 |
|---|---|---|
| 数据模型、迁移、数据访问层 | 我 | 从零设计到落地,Alembic 迁移由我维护 |
| ARQ 入库流水线 | 我 | 任务拆分、状态机、重试与失败分支 |
| pgvector 召回与过滤 | 我 | 索引选型、过滤条件、召回参数 |
| 问答链路编排与引用装配 | 我 | Prompt 结构、证据组装、拒答判定 |
| JWT + 知识库级 RBAC | 我 | 权限模型设计 + 前置到接口和检索两层 |
| 前端上传、会话、引用渲染 | 前端同事 | 我出接口契约和 SSE 事件定义,联调 |
| PostgreSQL / Redis / MinIO 实例与部署 | 运维 | 我提需求、写 compose 与初始化脚本、参与排障 |
| Embedding 与 LLM 服务 | 外部服务 | 我做接入、超时重试、模型切换开关 |
需你确认
上表的协作方划分是按"典型小团队"写的。如果实际上前端也是你写的、或者部署也是你做的,把对应行改掉 —— 说多了会被追细节,说少了白丢分。
三、简历每条 bullet 的展开与追问树
bullet 1:设计知识库、文档、分块、会话和权限数据模型,使用 SQLAlchemy + Alembic 管理数据访问和 Schema 演进
要讲什么
这套模型的核心判断是:RAG 系统的稳定性首先是数据模型问题,不是检索问题。文档要有明确的生命周期状态(待处理 / 处理中 / 成功 / 失败 / 已删除),因为入库是异步的,没有状态就没法做重试、没法做进度上报、也没法在检索时把"还没处理完"和"已经废弃"的内容挡住。分块单独一张表而不是塞进文档表的 JSON 字段,是因为召回的最小单位是 chunk,它需要独立主键、独立向量列和独立索引,还要能回指到文档和页码用于引用。会话和消息分开,是为了支持多轮问答时把每轮引用的 chunk 也存下来,用户回看历史时引用还在。权限单独一层,是因为它要同时约束文档管理接口和检索 SQL,不能耦合在业务表里。Alembic 是因为这个项目在三个月里表结构改了很多轮,手改 DDL 一定会出现"本地能跑线上报错",迁移脚本进版本库是底线要求。
必然被追问
- 五张表的关系和索引怎么设计的? 知识库 1:N 文档 1:N 分块;会话 N:1 知识库、1:N 消息;权限是"用户 × 知识库 × 角色"的关联表。索引重点三处:分块表上
(知识库ID, 状态)的复合索引配合向量索引供检索用;文档表上(知识库ID, 状态, 创建时间)供列表分页;权限表上(用户ID, 知识库ID)唯一约束防重复授权。 - 为什么向量不直接放文档表? 一份文档对应多个向量,放不进单行;而且向量索引是建在 chunk 粒度上的,混在文档表里会让文档列表查询也去碰向量列,得不偿失。
- 文档删除是物理删还是软删?软删之后向量怎么办? 软删。理由:删除动作在请求线程里要立即返回,而级联删大量 chunk + 重建向量索引是重操作;软删只改状态位,检索侧靠过滤条件屏蔽。真正的物理清理放到低峰期的定时任务里。代价是表会持续膨胀,需要配套清理策略。
- 文档重新入库(重新分块)时旧 chunk 怎么处理? 按"新版本写入 + 旧版本标记失效 + 检索只认当前版本"来做,不做原地更新。原地更新的问题是中途失败会留下一半新一半旧的分块,检索结果无法解释。
- Alembic 迁移在线上怎么执行,回滚过吗? 迁移作为部署的独立步骤先跑,跑完再滚服务;写迁移时坚持"加列可空、不直接删列、重命名拆成两步",让新旧版本代码能同时兼容一段时间。真正的
downgrade我更多用在本地,线上更倾向用兼容式前滚。
追到底会问
- pgvector 的向量索引和你的过滤条件是怎么配合的?会不会出现过滤把索引废掉? 会。近似索引(HNSW / IVFFlat)是先在向量空间里找近邻再应用
WHERE,如果知识库过滤的选择率很低(一个小知识库躺在大表里),近邻里几乎全被过滤掉,top-k 拿不满、召回骤降。解法有三层:把过滤字段做成 partial index、按知识库做表分区或分区索引、或者放大候选集(提高ef_search/probes)再过滤。这是我会主动讲的一个点,因为它说明我知道"过滤 + 向量"不是免费的。 - 数据量再涨两个数量级,这套模型要改什么? 分块表按知识库或时间做分区;向量索引改成按分区建,避免单个巨型索引重建时间不可接受;文档列表查询走独立的汇总表而不是 count 大表;软删数据定期归档下沉。
- SQLAlchemy 为什么用而不是裸 SQL?异步用的哪套驱动? 用 ORM 的收益在于模型定义、迁移、类型标注三者对齐,改字段时编译期就能发现调用点。但检索这类性能敏感的语句我不介意直接写 SQL /
text(),特别是要拼向量算子和过滤条件的时候。异步侧配的是 asyncpg,注意点是 ORM 的懒加载在 async 下会炸,关联数据要显式selectinload。
答不上来时的兜底话术 "这块当时是我按'先保证生命周期状态可解释、再谈性能'来定的,具体索引参数我需要回去看一下当时的迁移脚本,不敢凭印象报。"
原理补课:表结构设计 · RAG 流水线 · 并发、事务与一致性
bullet 2:搭建 Redis + ARQ 异步入库流水线,按"解析 → 分块 → Embedding → 入库"拆分任务;记录处理状态并支持失败重试,避免大文件处理阻塞请求
要讲什么
改成异步的直接原因是:一份几百页的 PDF,解析 + 分块 + 逐批 Embedding 的耗时是分钟级,放在 HTTP 请求里等于必然超时 —— 而且网关超时了任务其实还在跑,用户重试就变成重复入库。所以上传接口只做三件事:文件落 MinIO、写一条文档记录(状态 = 待处理)、投一个 job 到队列,然后立刻返回文档 ID。四个阶段之所以要显式拆开,是因为它们的失败原因和重试代价完全不同:解析失败通常是文件本身的问题(加密 PDF、扫描件、编码异常),重试没用,应该直接标失败并把原因告诉用户;Embedding 失败大多是外部服务限流或超时,退避重试就能过;写库失败要保证幂等,不能重试一次就多一份分块。把这四类混成一个黑盒任务,就只能得到一个"处理失败",运维起来是瞎的。状态记在数据库(权威)+ Redis(供 SSE 实时推送),这样刷新页面能拿到当前进度,worker 重启也不会丢状态。
必然被追问
- 为什么用 ARQ 不用 Celery? 三个理由:一是这个服务本来就是 asyncio 的(FastAPI + asyncpg + 异步 HTTP 调 Embedding),ARQ 原生 async,Celery 在 async 场景下要么用同步驱动要么绕
asgiref,链路变复杂;二是依赖少,只要一个 Redis,不额外引 RabbitMQ;三是这个项目的任务量级用不上 Celery 的路由、优先级队列、chord 那套能力。代价是 ARQ 生态小、可观测工具少(没有 Flower 那种现成面板),监控要自己做。 - 四个阶段是四个独立 job 还是一个 job 内部四步? 我选的是"一个文档一个 job、内部按阶段推进并在每个阶段边界落状态"。理由:阶段之间要传的是整篇文本和 chunk 列表,拆成独立 job 就得把中间产物再落一次存储,收益不大;而阶段级状态已经足够定位失败点,重试时可以从上一个成功阶段的产物继续。真要拆的场景是 Embedding 需要独立扩容 —— 那时才值得把它拆成单独队列。
- 重试怎么做?幂等怎么保证? 重试分两级:Embedding 这类外部调用在阶段内做带指数退避的有限次重试;整个 job 失败后由 ARQ 的重试机制或人工触发重跑。幂等靠"入库阶段先按文档 ID + 版本删掉半成品分块再批量写",而不是逐条 upsert,保证重跑结果收敛到同一个状态。
- 大文件会怎样?内存和超时怎么控? 解析和分块按页 / 按段流式处理,不把整篇文本长期驻留;Embedding 分批调用并限制批大小;job 设置整体超时,超时后标记失败而不是无限挂着。真正的兜底是限制单文件大小和页数上限,超过的走线下处理。
- worker 被 kill / 重启,卡在"处理中"的文档怎么办? 依赖两层:一是文档状态带
更新时间,有一个巡检任务把超过阈值仍在"处理中"的记录判定为超时并重置为待重试;二是 job 本身要能重入(见幂等)。这一点很重要,因为只靠队列语义救不回来"任务已被领取但进程死了"的情况。 - Embedding 是批量还是逐条?限流怎么处理? 批量。逐条的问题是网络往返把耗时放大一个数量级。限流侧做的是并发上限 + 退避重试 + 失败批次单独记录,避免一批失败拖垮整个文档。
追到底会问
- Redis 当队列可靠吗?断电会丢任务吗? 会有风险,要如实说。Redis 的持久化是 RDB / AOF,最坏情况丢掉最近一段写入;ARQ 的实现是把 job 放在 Redis 结构里、worker 领取后再执行,没有 RabbitMQ 那种 ack + 持久化队列的强保证。我的处理方式是"数据库是权威状态源":即使队列里的 job 丢了,文档记录还在"待处理",巡检任务会把它重新投递一次。所以真正兜底的不是队列,是可重放的状态表。
- worker 的并发数怎么定?CPU 密集和 IO 密集混在一个 worker 里有什么问题? 有问题。解析是 CPU 密集(还可能是 C 扩展里阻塞 GIL),Embedding 调用是 IO 密集,混在同一个 asyncio worker 里,解析会把事件循环卡住,导致同一进程内其它 job 的 IO 也停摆。正确做法是把 CPU 密集部分丢进
run_in_executor/ 独立进程池,或者干脆按队列拆两组 worker 分别配并发。并发数按"外部 Embedding 服务能承受的 QPS"和"CPU 核数"两个上限里取小。 - SSE 要展示进度,但进度是 worker 进程算出来的,跨进程怎么传? worker 每完成一个阶段就往 Redis 写状态(并可用 pub/sub 发一条事件),API 进程的 SSE 端点订阅 / 轮询这个 key 往下推。这里有个细节:SSE 连接可能在多个 API 副本上,所以必须走 Redis 这种共享通道,不能用进程内内存。
答不上来时的兜底话术 "重试和幂等这块我是按'状态表可重放'的思路做的,队列本身我没当成强保证来用;具体的重试次数和退避参数我需要回去核一下配置。"
原理补课:Worker 与异步任务 · Redis 深入 · 消息队列 · RAG 流水线
bullet 3:基于 pgvector 实现向量召回,将知识库、文档状态和删除状态作为过滤条件,阻断跨库召回和失效文档命中
要讲什么
这条 bullet 表面在讲检索,实际在讲正确性边界。企业知识库最不能出的事故不是答得不好,是把 A 部门的资料答给了 B 部门的人,或者拿一份已经作废的旧制度去回答。所以我把三个过滤条件做成了检索层的硬约束,而不是在应用层"取回来再筛":知识库 ID 限定检索范围(而且这个 ID 不是前端传什么就用什么,是从权限表算出的可见集合取交集)、文档状态只认入库成功的、删除状态只认未删的。做成 SQL 里的 WHERE 而不是取回后过滤,一是避免"取 top-k 回来筛掉一半只剩两条",二是避免任何一条代码路径漏掉过滤 —— 我把检索入口收敛成一个函数,所有调用方都必须经过它。
必然被追问
- 为什么用 pgvector 不用 Qdrant / Milvus?你在 BI 项目里就用了 Qdrant,为什么这里换了? 这是我预期一定会被交叉追问的点。答案是场景不同:知识库这边的检索天然要和"文档状态、软删、知识库归属、权限"这些关系型数据做联合过滤,还要和文档表 JOIN 出引用信息(文件名、页码),放在同一个 PostgreSQL 里可以一条 SQL 搞定、且和业务数据共享事务,运维上也少一个组件。BI 那边的向量数据是字段别名和指标描述,量小、结构独立、不需要和业务库 JOIN,用独立向量库更干净。判断标准是"向量是否需要和关系数据做强一致的联合查询",需要就 pgvector,不需要就专用向量库。
- 用了什么索引?为什么?距离函数选哪个? 向量索引在 HNSW 和 IVFFlat 之间选 HNSW:召回率-延迟曲线更好,而且不需要先有数据才能训练(IVFFlat 需要足够样本才建得出合理的聚类),对"知识库刚建、文档陆续进来"这种增量场景更友好。代价是构建慢、内存占用高。距离用 cosine,因为主流 Embedding 模型输出的是归一化向量,语义相似度按余弦解释;注意 pgvector 的算子(
<=>/<->)必须和建索引时声明的距离类型一致,否则索引不会被用上。 - 分块策略怎么定的?chunk size 和 overlap 呢? 优先按文档结构切(标题层级、段落),再对超长段落做固定窗口切分并保留重叠。理由:制度类文档条款性很强,按结构切能让一个 chunk 恰好是一条完整规定,检索命中率和引用可读性都更好;overlap 是为了防止答案正好跨在切割边界上。具体参数是
<待核实>,当时是拿评测集试出来的,不是拍的。 - 只有向量召回够吗?没做 BM25 / 混合检索 / rerank? 首版只有向量召回,这是真实情况,我不打算美化。它的短板很明确:专有名词、编号、缩写这类字面匹配的问题,纯向量会漏(比如问"XX-2024-017 号文件")。后来补的方向是 BM25 + 向量双路召回 + RRF 融合,再上 Reranker 精排。我会说清楚"首版没做的原因是先保证入库和权限这两条主干可用",以及"补上之后 Recall 的变化是
<待核实>"。 - Embedding 模型选的哪个?换模型怎么办? 选型看三条:中文效果、维度(影响存储和索引成本)、是否能私有化部署(企业资料不能出网是硬要求)。换模型的代价是全量重跑 Embedding,所以 chunk 表要记录"用哪个模型、哪个版本生成的向量",否则新旧向量混在一张表里检索结果不可解释。这一点是我踩过之后加的。
追到底会问
- HNSW 的
ef_search调大调小分别影响什么?你怎么定的? 调大 → 候选集更大 → 召回率上升、延迟上升;调小反之。定法是拿评测集扫一遍,画召回-延迟曲线,选在"召回还没明显掉、延迟还在预算内"的拐点。这也是评测集除了汇报以外的真实用途。 - 过滤选择率极低时怎么办? 三条路:给高频过滤组合建 partial index(例如只索引"状态 = 成功且未删除"的行,这本身就把大量无效行剔掉了)、按知识库分区、或者在小知识库上直接放弃近似索引走精确扫描(数据少的时候顺序扫比近似搜还快)。会主动提"我知道近似索引在强过滤下会退化"这件事,比背参数更有说服力。
- top-k 怎么定?拿回来的 chunk 直接全部塞给模型吗? 不是。召回 k 会比最终喂给模型的条数取得大一些,中间做去重(同一文档相邻 chunk 合并)、按文档聚合、按 token 预算截断。因为同一份文档的连续几个 chunk 往往内容重复,全塞进去只是浪费上下文。
- 怎么估算向量的存储成本? 一行 = 向量维度 × 4 字节 + 原文 + 元数据,再乘 chunk 数,HNSW 索引本身还要额外一份内存开销。会讲估算方法而不是背具体数字,具体值
<待核实>。
答不上来时的兜底话术 "检索这块我是拿评测集调出来的,不是按经验值拍的;具体参数我需要回去看配置,但我能讲清当时是按什么曲线选的。"
原理补课:RAG 检索与召回 · RAG 流水线 · 表结构设计
bullet 4:编排"问题理解 → 证据召回 → 答案生成"链路,统一返回文档、页码和 chunk 引用;召回为空或证据不足时拒答或转人工
要讲什么
这条链路的设计原则是"没有证据就不许说话"。企业场景里一个编出来的制度条款比"答不上来"危害大得多,所以我把拒答做成了默认分支而不是异常分支。问题理解这一步做的是把用户口语化的问题和多轮上下文里的指代补全成一个可检索的独立问题(比如"那它的审批流程呢"要先还原成完整问题),否则带着"它"去检索必然召回噪声。证据召回之后不是直接扔给模型,而是先做上下文构造:按文档聚合、给每段证据编号、把文件名和页码作为元信息一起给模型,并在 Prompt 里要求模型只能引用给定编号。答案生成后还要做一次引用校验 —— 模型返回的引用编号必须落在实际给它的证据集合里,编号不存在的引用会被剔掉,剔完发现一条引用都不剩的答案会被降级处理。整条链路对外通过 SSE 逐段输出,用户能先看到"正在检索 / 找到几条证据 / 开始生成",而不是干等。
必然被追问
- "证据不足"怎么判定?阈值多少? 不是单一相似度阈值,因为不同 Embedding 模型的分数尺度不可比、也不稳定。我用的是组合信号:召回条数为零直接拒答;最高分低于基线阈值时拒答;命中的证据全部来自同一个 chunk 且长度极短时降级为"仅供参考"。阈值本身是拿评测集里的正负样本扫出来的,具体值
<待核实>。我会主动说"用绝对阈值是有局限的,更稳的做法是加一个轻量判别步骤让模型自己判断证据是否足够回答"。 - 引用怎么保证是真的?模型会不会编引用编号? 会,所以必须做后置校验。做法是给每条证据一个短编号,模型只允许输出这些编号,生成完成后用程序把编号映射回 chunk 记录 —— 映射不上的直接丢弃。这是"程序兜底模型",不是"相信模型"。
- 页码怎么来的?DOCX 没有页码怎么办? PDF 在解析阶段就把每段文本的页号记进 chunk 的元信息里,所以引用能精确到页。DOCX / TXT 没有物理分页概念,退化成"章节标题 + 段落序号"作为定位信息,前端展示时用"第几节"而不是"第几页"。这是必须坦白的一个不对称,装作都能给页码会被问穿。
- 上下文太长怎么办?token 预算怎么管? 按优先级填充:先放最高分的证据,逐条累加直到接近预算上限,剩下的丢弃而不是截断句子(截断会让引用对不上)。多轮对话的历史用滑动窗口 + 摘要压缩,只保留最近几轮原文加一段前情摘要。
- 拒答率太高业务不满意怎么办? 这是产品取舍,要给方案而不是硬扛:拒答时不能只说"我不知道",要返回召回到的最相关文档链接让用户自己看,并给出"转人工"入口;同时把拒答样本收集起来,它们是补文档和调检索最有价值的输入。
- 多轮对话的引用怎么存? 引用挂在消息上而不是会话上,用户翻历史消息时引用还能点开。
追到底会问
- 一句话综合了三条证据,引用怎么落到句子级? 首版是答案级引用(整段回答后面列出所有引用来源),这是老实话。句级引用的做法是要求模型在每个论断后面直接输出编号标记,再由程序把标记解析成结构化引用;代价是对模型指令遵循能力要求更高、失败率上升,而且需要更强的后处理容错。我会说"我知道句级引用是更好的形态,也知道它的成本在哪"。
- 用户说答案错了,你怎么定位是召回的问题还是生成的问题? 分层归因,这是我在评测里固定做的两步:先看标准答案所在的 chunk 有没有进 top-k(没进 → 召回问题,去查分块策略、检索方式、文档是否入库成功);进了但答错 → 生成问题(去查 Prompt、上下文截断、证据顺序)。不做这个拆分,所有问题都会变成"模型不行",然后无从下手。
- 这条链路算不算 Agent?和你在客服项目里的 Agent 有什么区别? 这条是固定流程的 RAG pipeline,不是自主决策的 Agent —— 节点顺序是我定死的,模型不参与"下一步做什么"的决策,只在"是否证据充足"这类点上做判断。我会明确区分这两者,因为把 pipeline 说成 Agent 是很容易被戳破的。要往 Agent 演进的方向是让模型自主决定"要不要再检索一轮、要不要换个问法重查"。
答不上来时的兜底话术 "引用这块我的原则是'程序校验优先于模型自觉',具体的阈值和 Prompt 细节我需要回去核,但判定逻辑我可以完整讲一遍。"
原理补课:引用与溯源 · RAG 流水线 · Agent 是什么 · Agent 评测
bullet 5:将 JWT + 知识库级 RBAC 前置到文档和检索链路,区分查看、提问、写入权限,并通过 SSE 输出入库与问答处理状态
要讲什么
权限这块我做了两层,是刻意的。第一层在接口边界上用 FastAPI 的依赖注入做前置校验,拿到 JWT 里的用户身份后查该用户在目标知识库上的角色,不满足就直接 403,业务代码里看不到任何权限判断。第二层在检索 SQL 里强制注入"该用户可见的知识库 ID 集合",就算某个接口忘了加依赖、或者以后有人新写一条检索路径,SQL 层还会兜住。只做第一层的风险是漏一个接口就整片资料泄露,而这种漏是最容易发生的。权限粒度定在知识库级而不是文档级,是因为企业的资料本身就是按部门 / 主题成组管理的,文档级 ACL 会带来指数级的授权维护成本,而且检索时逐文档判权会毁掉向量检索的性能。查看 / 提问 / 写入分开,是因为这三件事的风险不同:能看不等于能问(问答会消耗模型成本、也会产生跨文档的信息聚合),能问不等于能往里塞文档。
必然被追问
- 为什么权限判断要做两层?不冗余吗? 冗余是故意的。接口层是"正常路径的拒绝",SQL 层是"防御性兜底"。这两层的失效模式不相关:接口层失效于开发遗漏,SQL 层失效于查询构造错误,同时失效的概率远低于任何一层单独失效。安全上这叫纵深防御,成本只是多传一个参数。
- JWT 过期、登出、权限被回收怎么处理?特别是 SSE 长连接还开着的时候。 这是真问题。JWT 是无状态的,签发后回收不掉,所以:access token 设短有效期 + refresh token 换取;SSE 建连时校验一次,但长连接期间不会自动感知权限变更。我的处理是把 SSE 单次连接的生命周期限制在"一次问答"的范围内,不做长期挂着的连接,这样权限变更最多影响正在进行的一次请求。要更强就得引入服务端会话状态或 token 黑名单,代价是牺牲无状态性。
- 为什么选 SSE 不选 WebSocket? 这里的数据流是单向的(服务端往客户端推 token 和状态),SSE 就够了;它走标准 HTTP,能直接复用现有的网关、鉴权头、日志和限流,浏览器还自带断线重连。WebSocket 是双向协议,要额外处理协议升级、心跳、鉴权(握手阶段带不了自定义 header 就得走 query 或子协议),复杂度换不来收益。代价是 SSE 单向、且经典 HTTP/1.1 下有同域连接数限制。
- SSE 断线了怎么办? 分两种:入库进度断了无所谓,重连后从数据库 / Redis 读当前状态即可续显 —— 这是我把状态落库的另一个理由。问答生成断了则比较麻烦,要么让用户重问,要么把已生成的片段落库支持续传(用
Last-Event-ID定位)。首版是前者。 - 部署了多个实例,SSE 怎么拿到 worker 的进度? 必须走 Redis 共享(pub/sub 或轮询状态 key),不能用进程内内存。这一点在单机开发时不会暴露,一上多副本就出问题。
- RBAC 的角色和权限点是怎么存的? "用户 × 知识库 × 角色"关联表 + 角色到权限点的映射(代码里的枚举 / 配置,不落库),依赖注入里做
require_permission(知识库ID, 权限点)这样的检查。不做完整的权限引擎,因为权限点数量很少,上引擎是过度设计。
追到底会问
- 越权测试怎么做的? 说实话:以接口级用例为主,覆盖"无权限用户访问他人知识库的文档列表 / 检索 / 上传"这几条主路径。我不会声称做了完整的自动化安全测试。可以补一句更好的做法是把"检索结果里出现不可见知识库的 chunk"写成一条断言,跑在 CI 里,这比人工点更可靠。
- 一份文档同时属于多个知识库怎么办?权限取并集还是交集? 首版模型是一份文档只属于一个知识库,规避了这个问题(要共享就复制一份)。如果要支持多归属,权限应取交集视角下的并集 —— 用户在任一有权知识库下都能看到该文档,但引用要标明是从哪个知识库命中的,否则用户会困惑。这类问题诚实回答"当前模型不支持,如果要支持我会这么改",比硬编一个答案好。
- Nginx 后面 SSE 不流式怎么排? 典型症状是所有 token 憋到最后一次性吐出来。原因是反向代理的响应缓冲,要关掉对应 location 的 buffer,并确认没有中间层做 gzip 累积;应用侧也要注意别在中间加了会把整个响应收集起来的中间件。这个坑很常见,讲出来说明真部署过。
答不上来时的兜底话术 "权限我是按'接口层 + SQL 层双保险'来做的,安全测试的覆盖度我承认不够完整,主要是接口级用例。"
原理补课:FastAPI 进阶(依赖注入与鉴权) · 流式输出与 SSE · Redis 深入
四、关键技术决策与备选方案
| 决策点 | 我的选择 | 备选方案 | 为什么这么选 | 代价是什么 |
|---|---|---|---|---|
| 向量存储 | PostgreSQL + pgvector | Qdrant / Milvus / Chroma | 向量必须和知识库归属、文档状态、软删、权限做联合过滤,还要 JOIN 出引用信息;同库同事务,少一个运维组件 | 单实例扩展上限低于专用向量库;HNSW 索引重建成本高;高并发向量检索会和业务查询抢资源 |
| 异步任务 | ARQ + Redis | Celery + RabbitMQ / FastAPI BackgroundTasks / 自写 worker | 全链路 asyncio,ARQ 原生 async 不需要胶水;依赖只有 Redis;任务模型简单用不上 Celery 的重型能力。BackgroundTasks 不行是因为它跟着请求进程走,进程重启任务就没了 | 生态小、没有现成监控面板;队列可靠性弱于 RabbitMQ,必须靠状态表可重放来兜 |
| 流式协议 | SSE | WebSocket / 长轮询 | 数据流单向;走标准 HTTP 能复用网关、鉴权、日志、限流;浏览器自带重连 | 单向、无法客户端主动发消息;受同域连接数限制;反向代理缓冲需要额外配置 |
| 权限粒度 | 知识库级 RBAC(查看/提问/写入) | 文档级 ACL / PostgreSQL 行级安全 RLS | 企业资料本来按部门主题成组;文档级授权的维护成本和检索时的逐文档判权都不可接受。RLS 虽优雅但会把权限逻辑分散到数据库策略里,团队排查成本高 | 无法表达"同一知识库内某几份文档更敏感";要做只能再叠一层文档标签 |
| 文档删除 | 软删 + 检索侧过滤 | 物理删并级联清理向量 | 删除要在请求内立即返回,级联删大量 chunk 加索引维护是重操作;软删还保留了误删恢复的可能 | 表持续膨胀;每条检索路径都必须带过滤条件,漏一处就是事故;需要配套低峰清理任务 |
| 分块策略 | 结构感知优先 + 超长段落固定窗口 + overlap | 纯固定长度切分 / 模型语义分块 | 制度文档条款性强,按结构切能让一个 chunk 是一条完整规定,引用可读性好;纯固定长度会把条款切断 | 依赖解析器能拿到结构信息,扫描件和排版混乱的文档退化成固定长度;语义分块效果可能更好但每次入库都要额外模型成本 |
| 原始文件存储 | MinIO | 数据库大字段 / 本地磁盘 | 对象存储适合大文件,支持预签名 URL 让前端直传直下,数据库只存路径;多副本部署时本地磁盘不共享 | 多一个组件要运维;文件与数据库记录之间的一致性要自己保证(先落文件后写记录,孤儿文件靠清理任务) |
| 数据访问层 | SQLAlchemy 2.0 async + Alembic | 裸 SQL + 手写迁移 / Tortoise ORM | 模型定义、迁移、类型标注三者对齐,改字段能在调用点暴露;Alembic 让 schema 演进进版本库 | 异步下懒加载会抛错,关联数据必须显式预加载;复杂检索语句写 ORM 反而绕,这类地方我直接写 SQL |
| 首版检索方式 | 纯向量召回 | 向量 + BM25 混合召回 + RRF + Rerank | 三个月周期内优先保证入库流水线和权限这两条主干可用,检索先做单路跑通并用评测集量化 | 编号、专有名词这类字面匹配的问题会漏召回;Recall 上限受限,这是首版明确的短板 |
| Embedding 服务 | 可私有化部署的 OpenAI-compatible 接口 | 直接调公有云 API | 企业制度和方案资料不能出网,这是硬约束 | 需要自己扛部署和容量;模型可选范围小于公有云;换模型要全量重跑,所以 chunk 必须记录模型版本 |
| 引用粒度 | 答案级引用(文档 + 页码 + chunk) | 句级引用标记 | 实现稳、对模型指令遵循要求低、后处理容错简单 | 用户无法知道某个具体论断出自哪条证据;这是我明确知道的可改进项 |
五、故障与取舍
⚠️ 需替换为你真实遇到的问题
下面四条是这类项目通常会遇到的问题形态,写法(现象 → 定位 → 处理 → 沉淀)可以直接用,但内容必须换成你真的排查过的。面试官追问"当时日志里看到什么"、"是谁发现的"、"改完之后怎么验证"时,编的故事一定接不住。
1. 大文件入库把队列堵死,其他文档一直卡在"待处理"
- 现象:某次批量上传后,用户反馈小文件也几十分钟不进库,页面进度条不动。
- 定位:看 worker 日志发现所有并发槽位都被几份超大 PDF 占满;再看时序,解析阶段耗时远超 Embedding,说明瓶颈在 CPU 侧的解析而不是外部调用;同时发现解析是同步阻塞调用,把 asyncio 事件循环占住了,同一进程里其它 job 的 IO 也停摆。
- 处理:解析部分移到线程 / 进程池执行,不再阻塞事件循环;给单文件加大小和页数上限;把"大文件"和"普通文件"分成两个队列,避免大文件独占并发;文档状态补上"排队位置"让前端能显示等待。
- 沉淀:asyncio 里最危险的不是并发不够,是一个同步调用把整个循环卡住。此后所有第三方库接入前先确认它是不是阻塞的。
2. 换 Embedding 模型后老文档检索质量骤降
- 现象:升级 Embedding 模型后,新入库的文档问答正常,老文档几乎召回不到。
- 定位:新旧向量在同一张表里,但它们来自不同模型、不在同一语义空间,余弦距离没有可比性;分数分布完全错位,导致老文档永远排在后面。维度不同的情况会直接报错反而更容易发现,维度相同但模型不同才是最阴的。
- 处理:给 chunk 表加上"Embedding 模型标识 + 版本"字段,检索时只在同一模型空间内比较;老文档排队重跑 Embedding,重跑期间检索按模型分组各取 top-k 再合并(临时方案,明确标注为降级态)。
- 沉淀:向量是有版本的。任何写入向量的表都必须记录生成它的模型和版本,这条现在是我的默认规范。
3. SSE 在网关后面不流式,用户等到最后一次性看到全部答案
- 现象:本地开发流式正常,部署到测试环境后前端要等十几秒才一次性显示完整回答。
- 定位:先用
curl直连应用确认应用侧确实是逐段吐的,说明问题在中间层;查反向代理配置发现该 location 开着响应缓冲,代理把整个响应攒完才转发。 - 处理:关掉对应 location 的响应缓冲、确认没有中间层做 gzip 累积;同时在应用侧定期发心跳事件,防止长时间无数据被中间层判定空闲断连。
- 沉淀:流式功能的验收必须在带完整网关链路的环境上做,本地正常不代表线上正常。心跳是 SSE 的标配而不是可选项。
4. 软删的文档还能被检索到
- 现象:用户删掉一份作废的旧制度后,问答里仍然引用它。
- 定位:检索有两条代码路径(问答链路和一个后台的相似文档推荐),只有前者带了删除状态过滤,后者是后加的、漏了。
- 处理:把向量检索收敛成唯一入口函数,过滤条件在函数内部强制拼接,调用方无法绕过;补一条断言型测试:软删一份文档后检索结果中不得出现它的 chunk。
- 沉淀:安全和正确性相关的过滤条件不能靠"每个调用方记得写",要靠收口。这也是我在权限上做接口层 + SQL 层双保险的同一个思路。
六、数字口径待核实
使用说明
"简历示例值"这一列是当前简历上写着的数字,它们是占位值,不是事实。面试前必须逐行填第三列。填不出来的两种处理:要么把简历上那句话改成不带数字的表述,要么在面试里主动说"这是我按 X 口径估算的"。最忌讳的是报一个数字然后解释不了分子分母。
| 简历表述 | 简历示例值 | 真实值 | 这个指标该怎么算 | 证据在哪 |
|---|---|---|---|---|
| 支持 N 个知识库 | 6 个 | <待核实> | 上线后实际创建且有文档入库的知识库数(空库不算) | 知识库表按状态 count;或后台管理页截图 |
| 管理 N 份文档 | 3,200 份 | <待核实> | 文档表中入库成功且未删除的记录数;要说清是累计上传还是当前有效 | 文档表 count(*) where 状态=成功 and 未删除 |
| 约 N 个分块 | 28,000 个 | <待核实> | 分块表有效记录数;顺带能反推平均每文档分块数,面试官会拿这两个数交叉验证 | 分块表 count;平均值 = 分块数 / 文档数 |
| 评测样例条数 | 120 条 | <待核实> | 问题-标准答案对的数量;要能说出问题是谁提的、答案是谁定的、怎么分层(单文档/跨文档/无答案) | 评测集文件(CSV / JSON)本身 |
| Recall@5 | 86% | <待核实> | 分子 = 标准答案所在 chunk 出现在 top-5 里的问题数,分母 = 有标准答案的问题总数。必须说清"命中"是按 chunk ID 精确匹配还是按内容包含判定 | 评测脚本输出 + 一次完整跑分记录 |
| 引用完整性 | 91% | <待核实> | 分子 = 回答中所有引用都能映射到真实 chunk 且与论断相关的问题数,分母 = 产生了回答的问题数。要区分"引用编号有效率"(程序可算)和"引用相关性"(需人工或模型判定) | 评测脚本 + 人工抽检记录 |
| 人工检索耗时 | 10 分钟 → 2 分钟 | <待核实> | 改造前:抽样若干个真实查询任务,人工计时找到并确认答案的时间。改造后:从提问到看到带引用答案并确认可用的时间。样本量和抽样方式必须能讲 | 改造前的人工计时记录 / 业务方访谈;改造后的接口耗时日志 |
| 平均响应时长(v2 目标提到 2s 内) | 2s 内 | <待核实> | 建议改报 P95 首字延迟和 P95 完整回答时长两个数,平均值会被长尾拉偏 | 接口耗时埋点 / 访问日志 |
| 拒答率 | 未写 | <待核实> | 触发拒答分支的问答数 / 总问答数。这个数字建议主动补上,它证明拒答不是摆设 | 问答日志按拒答标记聚合 |
| 入库成功率 | 未写 | <待核实> | 首次入库成功文档数 / 总上传数;重试后最终成功率单列 | 文档状态分布统计 |
| 解析失败原因分布 | 未写 | <待核实> | 按失败原因分类计数(加密 / 扫描件 / 编码 / 超时 / 其它) | 文档表失败原因字段聚合 |
| 混合检索上线后 Recall 变化 | 未写 | <待核实> | 同一评测集下"纯向量"与"向量+BM25+RRF+Rerank"两组结果对比 —— 这是最能证明工程能力的一组数 | 两次评测跑分记录(这就是基线对比) |
七、这个项目会被挑战的地方
挑战 1:三个月做完数据建模、异步流水线、向量召回、链路编排、权限体系五块,可信吗
这是这个项目最大的攻击面,必须主动化解而不是等着被问。
应对思路(按这个顺序讲):
- 先承认范围与深度的关系:三个月能做完,是因为每块都做到了"生产可用的第一版",不是"做到极致"。数据模型是五张核心表不是完整的企业级权限中台;检索是单路向量不是混合检索加精排;权限是知识库级 RBAC 不是文档级 ACL。每块的边界我都能说清楚没做什么,这比声称全做完了可信得多。
- 说清复用:这不是从零起步。我在前一个 BI 项目和客服项目里已经沉淀了 FastAPI 工程骨架、SSE 输出、LLM 接入与重试降级、评测集的组织方式,这个项目真正的新增量是入库流水线和 pgvector 检索这两块。能复用什么,是三个月能交付的真实原因。
- 给出时间切分:第一段做数据模型和上传入库打通,第二段做检索和问答链路,第三段做权限、SSE 和评测调优。前两段各有一个可演示节点。
- 不夸大团队角色:如果有前端和运维配合,明确说出来。一个人三个月做完全栈五块 + 上线,比"我负责后端五块"更难让人信。
需你确认
这里有一个更硬的问题:简历上这个项目写 2026.04 - 2026.06,而亿达信息的整段任职是 2025.04 起。你必须先确定一个统一口径——这是亿达内部的第三个项目、还是任职结束后的独立项目。两个项目经历(BI 到 2026.04、这个从 2026.04 开始)首尾相接也会被问"是不是同时在做"。答案不需要复杂,但必须前后一致,且和工作经历那一栏对得上。
挑战 2:评测集是谁标的?有没有对比基线
- 诚实回答标注方:如果是你自己整理问题、从文档里找标准答案,就这么说,然后强调分层设计(单文档可答 / 需跨文档综合 / 库里根本没有答案的负样本),负样本这一层最能体现你懂评测 —— 它专门用来测拒答。
- 基线是弱点,也是机会:如果没做"不带检索直接问模型"的基线对比,承认。但要立刻给出你能补的最有价值的一组基线:同一评测集下"纯向量"与"向量 + BM25 + RRF + Rerank"的对比,以及不同 chunk size 的对比。能说出"该拿什么做基线",比真的有基线数据更能说明理解深度。
- 预防追问"样例这么少够不够":不够,样本量小的时候单个 case 就能让指标跳几个点,所以我不会用它做小幅优劣判断,只用来发现明显退化。这个态度比报一个漂亮的数字重要。
挑战 3:只有向量召回,是不是 RAG 只做了个入门版
- 承认首版单路召回,并说清短板具体在哪(编号、专有名词、缩写的字面匹配)。
- 说清为什么当时可以接受:主要问题类型是自然语言描述的制度和流程问题,向量召回覆盖得住;把有限时间投在入库可靠性和权限正确性上,是因为这两块出问题是事故,检索差一点只是体验问题。
- 说清后续怎么补以及为什么这样补(双路召回互补 → RRF 融合避免分数尺度不可比 → Reranker 解决"召回进来了但排不到前面")。
挑战 4:pgvector 撑不住怎么办 / 规模上限在哪
- 先给瓶颈判断顺序:向量索引的内存占用 → 单表规模导致的索引重建时间 → 高并发检索与业务查询抢连接和 CPU。
- 再给分级方案:先做 partial index 和分区;再做读写分离,检索走只读副本;真的到了单库扛不住的量级,就把向量层迁到专用向量库,代价是失去和关系数据的联合过滤能力,需要在应用层做两段式过滤。
- 重点是展示"我知道换存储的代价是什么",而不是背哪个向量库更快。
挑战 5:你不是算法出身,这些效果数字你自己能解释吗
- 主动把定位讲清楚:我做的是 RAG 的工程侧 —— 流水线、检索的正确性边界、失败兜底、评测的可重复执行。模型训练和算法改进不是我的产出。
- 但要证明"不碰算法不等于不懂效果":能讲清 Recall@K 的分子分母、能讲清为什么相似度绝对阈值不可靠、能讲清召回问题和生成问题怎么分层归因 —— 这三件事就足以说明你不是只会调 API。
- 面试官如果往模型微调、Embedding 训练方向深挖,坦白边界:"这块我了解原理但没有落地经验,我的价值在把模型能力封装成可运维的系统。"
挑战 6:这是有真实用户的系统,还是一个内部 demo
- 用运维证据说话:文档状态分布、失败原因分类、重试次数、拒答日志、真实用户提出的问题样本 —— 这些只有真跑过的系统才有。
- 如果用户量确实很小,直接说清覆盖了哪几个部门、什么类型的查询,不要往"全公司使用"上靠。范围小但真实,比范围大但空洞好答辩。
挑战 7:并发上来会怎样
- 分三段说瓶颈:入库侧瓶颈在 worker 的 CPU(解析)和外部 Embedding 服务的 QPS,靠队列削峰 + 水平扩 worker;检索侧瓶颈在向量索引的内存和数据库连接池,靠只读副本 + 连接池上限 + 检索结果缓存;生成侧瓶颈在 LLM 的并发配额,靠排队 + 超时降级(超时就退化成"只给检索到的文档列表,不生成答案")。
- 主动提一个容易被忽略的点:SSE 是长连接,并发问答数受连接数和 worker 数限制,同步阻塞式部署下每个连接占一个 worker,这是 ASGI 部署必须算的一笔账。
原理补课索引:RAG 流水线 · RAG 检索与召回 · 引用与溯源 · Agent 评测 · Worker 与异步任务 · Redis 深入 · FastAPI 进阶 · 流式输出与 SSE · 表结构设计 · 并发、事务与一致性