fix(KB): stop partial_stall warning firing on atypical ZIMs (link-out/PDF-heavy)

The Stored Files "partial stall" warning compares chunks in Qdrant against an
expected count from the ratio registry. The registry has an empty-pattern
catch-all (100 chunks/MB) that matches any filename, so a ZIM that matches no
specific pattern still gets a size-based estimate. For archives that are mostly
PDFs, images, or link-out stubs (e.g. irp.fas.org military-medicine), byte size
wildly over-predicts embeddable text: a 75 MB ZIM estimates ~7,236 chunks but
produces ~1, tripping a false "ingestion may have stalled" warning that re-embed
can't clear.

The catch-all is fine for rough aggregate disk-cost estimates, but it should not
drive a per-file stall signal. Add an `ignoreCatchAll` option to the ratio
lookup that excludes the empty-pattern row (returning null when only the
fallback would match), and use it in the warnings path so partial_stall only
fires when the registry has a *specific* expectation for the file. Files that
match a real pattern (wikipedia_, devdocs_, ifixit_, ...) are unaffected;
disk-cost/batch estimates keep using the fallback.

Closes #913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-06-05 21:03:10 -07:00 committed by jakeaturner
parent 25000b9869
commit 3977c723c2
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
4 changed files with 64 additions and 7 deletions

View File

@ -49,10 +49,22 @@ export default class KbRatioRegistry extends BaseModel {
return findChunksPerMb(filename, rows)
}
/** Estimate total chunks for a file of the given size on disk. */
static async estimateChunks(filename: string, fileSizeBytes: number): Promise<number | null> {
/**
* Estimate total chunks for a file of the given size on disk.
*
* `ignoreCatchAll` excludes the empty-pattern fallback, returning `null` for
* filenames that only the catch-all would match. The partial_stall warning
* uses this so it never flags ZIMs the registry can't specifically
* characterize (e.g. PDF/link-out-heavy archives whose byte size wildly
* over-predicts embeddable chunks). See #913.
*/
static async estimateChunks(
filename: string,
fileSizeBytes: number,
opts: { ignoreCatchAll?: boolean } = {}
): Promise<number | null> {
const rows = await this.all()
return estimateChunkCount(filename, fileSizeBytes, rows)
return estimateChunkCount(filename, fileSizeBytes, rows, opts)
}
/**

View File

@ -1242,9 +1242,14 @@ export class RagService {
const fileSizeBytes = sizeByPath.get(source) ?? 0
const chunksInQdrant = chunksBySource.get(source) ?? 0
const fileName = source.split(/[/\\]/).pop() ?? source
// ignoreCatchAll: the partial_stall warning must only fire when the
// registry has a *specific* expectation for this file. The empty-pattern
// fallback (100 chunks/MB) over-predicts wildly for atypical ZIMs that
// are mostly PDFs/images/link-outs (e.g. military-medicine), producing
// false "ingestion stalled" warnings. Suppress Warning B in that case. (#913)
const expectedChunks =
fileSizeBytes > 0
? await KbRatioRegistry.estimateChunks(fileName, fileSizeBytes)
? await KbRatioRegistry.estimateChunks(fileName, fileSizeBytes, { ignoreCatchAll: true })
: null
const warnings = decideWarnings({ fileSizeBytes, chunksInQdrant, expectedChunks })

View File

@ -66,10 +66,21 @@ export function estimateBatch(
*
* Returns `null` if no row matches and no empty-string fallback is present
* caller decides whether to surface "unknown" or use its own default.
*
* `ignoreCatchAll` excludes the empty-string catch-all row from matching, so a
* filename that only the fallback would have matched returns `null` instead.
* Callers that need a *specific* expectation (e.g. the partial_stall warning,
* which must not fire on atypical ZIMs the registry can't actually characterize)
* pass this; rough aggregate estimates (disk cost) leave it off. See #913.
*/
export function findChunksPerMb(filename: string, rows: RatioRow[]): number | null {
export function findChunksPerMb(
filename: string,
rows: RatioRow[],
opts: { ignoreCatchAll?: boolean } = {}
): number | null {
let best: RatioRow | null = null
for (const row of rows) {
if (opts.ignoreCatchAll && row.pattern === '') continue
if (!filename.startsWith(row.pattern)) continue
if (best === null || row.pattern.length > best.pattern.length) {
best = row
@ -88,9 +99,10 @@ export function findChunksPerMb(filename: string, rows: RatioRow[]): number | nu
export function estimateChunkCount(
filename: string,
fileSizeBytes: number,
rows: RatioRow[]
rows: RatioRow[],
opts: { ignoreCatchAll?: boolean } = {}
): number | null {
const ratio = findChunksPerMb(filename, rows)
const ratio = findChunksPerMb(filename, rows, opts)
if (ratio === null) return null
const megabytes = fileSizeBytes / (1024 * 1024)
return Math.round(ratio * megabytes)

View File

@ -66,6 +66,34 @@ test('estimateChunkCount returns null when no match and no fallback', () => {
)
})
test('ignoreCatchAll: returns null for filenames only the empty-pattern fallback would match (#913)', () => {
// military-medicine matches no specific prefix; without ignoreCatchAll it would
// hit the 100 chunks/MB fallback and wildly over-predict, falsely tripping
// the partial_stall warning on a PDF/link-out-heavy ZIM.
assert.equal(
findChunksPerMb('irp.fas.org_en_military-medicine_2026-05.zim', SEEDED_ROWS, {
ignoreCatchAll: true,
}),
null
)
assert.equal(
estimateChunkCount('irp.fas.org_en_military-medicine_2026-05.zim', 75 * 1024 * 1024, SEEDED_ROWS, {
ignoreCatchAll: true,
}),
null
)
})
test('ignoreCatchAll: still honors a specific (non-empty) pattern match', () => {
// A file that matches a real prefix should be unaffected by ignoreCatchAll.
assert.equal(
findChunksPerMb('wikipedia_en_simple_all_nopic_2026-02.zim', SEEDED_ROWS, {
ignoreCatchAll: true,
}),
270
)
})
test('estimateBatch sums chunks and bytes for matched files', () => {
const files = [
// 100 MB devdocs -> 110,000 chunks