失败模式
这个项目里最贵的故障不是报错,而是不报错:视觉腿一直 0 个点、文档永远停在"处理中"、df 虚高让 idf 变负、改库可见性后旧 payload 仍然被召回。它们都不会让测试变红,也不会让接口 500,只会让某一天的用户觉得"搜不到东西"。所以排障顺序应该先问"哪条链路在静默失败",而不是先看日志里有没有 ERROR。
一张分类表

| 类别 | 典型表现 | 会不会报错 | 先看哪里 |
|---|---|---|---|
| 检索腿降级 | 结果变少、某类题总是搜不到 | 不报错,skipped 有原因 | /search/debug 的 legs |
| 摄取失败 | 文档 failed、状态消息 | 任务 failed,文档可能没翻 | /jobs、documents.status_msg |
| 状态卡住 | 一直"处理中" | 不报错 | 巡检的"卡住的文档"、worker 启动补扫 |
| 索引漂移 | 删了还能搜到、点数对不上 | 不报错 | /admin/consistency 分腿 |
| 权限/越权 | 看到了不该看的 | 不报错 | 三档判据、payload 的旧 visibility |
| 运维依赖 | 视觉服务没起、模型没加载 | 检索 200 但视觉 skipped | /health、/tmp/pkh_visual.log |
检索腿降级
四种腿各有各的跳过条件,且都不该让检索 500:
- 视觉腿:服务没配、没起、模型没加载完、编码超过 2 秒、并发排队超时。API 进程恒不加载 colSmol,所以"API 里视觉腿不可用"是正常状态,不是故障。
skipped会说清是"未配置"、"连不上"还是"模型未就绪"。 - dense:
index_models记录的模型/维度与当前配置不一致。这不是"随机变差",是两个向量空间里算相似度,排序基础坏了。修法是把配置改回原模型,或重建索引。 - sparse:查询词全不在词表(OOV),分词后没有可查的 token。这不是错误,是这一腿没有候选。
- trgm:query 短于 3 字,或语句超过 800ms。超时来自
word_similarity只能 seq scan;库大时它会拖死 500ms 预算,所以封顶后降级,文本召回仍由 dense/sparse 承担。 - 精排:模型加载失败或推理失败,保持原顺序,
skipped带原因。
判断"是没跑还是没命中"只看 skipped:null 是跑了但零命中,字符串是没跑。把两者混在一起,会把"视觉服务没起"读成"库里没有照片"。
摄取失败
IngestError.retryable 决定去向:
永久错误(第一次就 failed)
- 格式不支持(应该在网关就 415,但目录导入/聊天导入可能绕过)。
- blob 缺失:内容寻址存储与 DB 不一致,重试不会让文件回来。
- 解析为空:不是扫描件、也没有文本层。以前会静默
ready一个空文档,现在直接失败。 - 超过 2000 页;扫描件超过 50 页。
- 页级任务页码越界。
- LibreOffice 未安装(预览任务)。
瞬时错误(回 pending,最多 3 次)
- 向量库暂时不可用、网络抖动。
- Office 转换失败(
ConversionError)。 - 视觉写侧走远端服务时的不可达/503/超时。
一个容易误判的点:Office 预览失败不动文档状态。文档照样能搜、能引用、能下载;把预览失败当成摄取失败,会让人以为文件没导入成功。反过来,带框预览需要坐标,坐标是预览任务补的;预览没跑完时带框预览报 415 并说清去看任务状态,不是 500。
文档卡在"处理中"
两种半:
- 任务失败但文档没翻:
ingest重试耗尽、embed_visual全部失败。修复前文档永远pending/parsing。 - 任务成功但文档没翻:worker 在
finish_job提交之后、文档结算提交之前被杀。窗口只有几毫秒,但一旦发生就是永久。
现在两半由同一个判定覆盖:每个 ingest/embed_visual 到终态都调 settle_doc_state,成功和失败两条路径都调;启动时 sweep_stuck_docs 补扫"中间态却没有任何待办任务"的文档。
判 failed 的条件是"该有的页级点不够,且没有未完成视觉任务"。thumbnail/pdf_preview 没完成不算——它们不影响内容能不能搜。scan_pages IS NULL 的老文档如果视觉任务全部失败,也判 failed,因为那个 pending 承诺没人能兑现。
索引漂移
三种漂移:
- 孤儿点:Qdrant 有、PG 里连 kb_id 都没有。常见于硬删用户/库(测试清理、级联删除),点没人清。重建时按三条腿清掉。
- 非当前点:同名版本被顶替或文档软删,旧点还在。宽限 5 分钟后由 worker 扫掉;不扫的话会占召回名额,让对账在 30 天里一直黄。
- 缺索引:PG 有 chunk、某条腿没有点。文本腿可以重建;视觉腿只能重新摄取(
reparse),因为页级向量要重读原件 + colSmol 前向。
对账要分腿看。顶层 consistent 是 dense ∪ sparse 的并集口径,"只有一条腿坏了"在顶层看起来一致。视觉腿的期望点来自 documents.scan_pages,不是 is_scanned——后者只说"这份要走视觉路径",说不出"该有几个点"。
对账不自动修。摄取时 chunk 行先 flush 未提交、点已 upsert,那个窗口里的"孤儿点"其实是正在写的;自动删会留下永久索引空洞。修复走 POST /admin/reindex(文本腿)或 reparse(视觉腿)。
权限与越权的形状
历史上有两次真实越权,形状值得记住:
- 把三档可见性合成一个
IN:visibility.in_(("workspace","global")),所有 workspace 库对任何用户可见。 - 把限定条件追加成另一条
or:workspace 分支没有成员资格,或工作区软删的EXISTS被写成第四条or。限定条件完全无效,但 SQL 看起来"有这一条"。
现在防回归的方式是:
- 三档必须分别判定,workspace 的两个条件在同一个
AND里; - Qdrant 过滤只允许
kb_id,services/下任何过滤字典不得用visibility/workspace_id/source_kind/owner_id做 key; - payload 里的
visibility是摄取时写死的,改库可见性不回写——一旦有人按它过滤,陈旧值立刻越权; - 无权限与不存在同码,阻断存在性探测。
运维依赖
- 视觉服务没起:检索 200,视觉腿
skipped。首次加载约 13 秒,dev.sh等不到它也会正常退出;那段时间视觉腿不可用是正常的。 - 模型不在本地:
HF_HUB_OFFLINE=1下加载会失败或极慢。换机器先准备模型缓存。 - 连接池不够:一次 hybrid 最多四条连接,池按并发检索数 × 4 估;不够时请求排队,延迟变差但不会算错。
- 磁盘写满:上传仍 202,blob 写失败在 handler 里,用户看到的是"文档一直 pending"。巡检的磁盘项是少数能提前发现它的地方。
- 旧 worker 残留:
dev.sh stop杀不干净uv run派生子进程,旧 worker 继续用旧代码领任务,症状是"改了没生效"。重启前先确认没有残留。 - 备份没演练:只跑
backup.sh看它成功,证明不了能恢复。必须定期restore.sh --scratch。
排障顺序
遇到"搜不到":
GET /api/v1/health看available_legs、queue、index_models;POST /kb/{id}/search/debug看每条腿的n/ms/skipped;- 如果某腿
skipped,按原因修依赖;如果全跑了但零命中,看 query 与库范围; - 如果点数与文档数对不上,
GET /admin/consistency分腿看缺/多; - 如果文档一直处理中,
GET /jobs?doc_id=看任务终态,巡检看"卡住的文档"。
遇到"看到了不该看的":
- 先确认
GET /kb是否列出了它——列表用visible_kb_clause; - 再确认检索结果——Qdrant 过滤只按
kb_id,范围来自readable_kb_ids; - 检查 payload 里的
visibility/workspace_id是否被谁拿去做过滤; - 检查工作区软删的
EXISTS是否被写成独立or。
遇到"改了没生效":
- 后端代码改动是否
dev.sh restart,有没有残留 worker; - 切分策略/PII 开关只对之后摄取的文档生效,旧文档要
reparse; - 新增 payload 字段有重摄取欠账,旧点没有该字段;
- 别名模式下要确认当前别名指向哪个物理集合。
已知的静默失败清单
这些不会让任何测试变红,只能靠巡检或对账发现:
kb_visual恒 0 个点(视觉腿坏了,看起来像"库里没照片")。documents.status永远停在中间态(任务终态没搬过去)。corpus_stats.n_docs与 chunk 行数不一致(df 虚高,idf 被压平)。- 索引模型与配置不一致(dense 腿静默错排序)。
- 备份目录存在但恢复不出来(没演练)。
- 上传返回 202 但 blob 写失败(磁盘满/权限错)。
- 旧 worker 用旧代码领任务("改了没生效")。