fix(vectors): a zero-norm vector is not a vector, canonical side included, plus the sanctioned unvector door
The engine-pair seam law: a zero-norm vector never crosses an engine
boundary. The index belt already refused to insert one, but the canonical
write and the vectored-noun ledger still counted it, so a near-empty store
whose only vectored row was zero-norm read "1 canonical vectored vs 0
indexed" and threw a not-ready error at open, and a store's own legacy
zero-norm VFS root could trip the same gate before its VFS-init-time cure
ever ran.
- add()/update() (single and transact()) now normalize an explicit
real all-zero vector to the unvectored [] shape before the dimension
pin, the ledger flag, and the index ops ever see it (loud, one warn per
write, canonical write still succeeds).
- The legacy counts.json derivation walk (scanVectoredNounCount) excludes
a persisted zero-norm row, matching the live ledger's definition.
- A legacy zero-norm VFS root now migrates at open, before the vector-leg
gate evaluates, via one O(1) fixed-path read (torn-tolerant — skips
rather than aborting init on a torn root, letting the recovery walk
heal it) — independent of whether a VirtualFileSystem is ever
constructed this session.
- update({ id, vector: [] }) (and the same op inside transact()) is now
the sanctioned, idempotent unvector door: index removal, exactly-once
ledger decrement, no re-embed, and it clears a pending deferred-embed
marker rather than leaving it to re-vectorize the row later. The
combination with deferEmbedding is a typed refusal.
- JsHnswVectorIndex.rebuild() now skips a zero-norm/empty persisted
vector when repopulating from canonical (the same belt the live
add/replace paths already had), and health()'s index-parity check now
compares HNSW size against the vectored-noun ledger rather than the
raw metadata-entry count, since a store's VFS root is permanently
unvectored by design.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fd6b4ce4ff
commit
0de7665930
6 changed files with 810 additions and 53 deletions
|
|
@ -10,7 +10,7 @@ import {
|
|||
Vector,
|
||||
VectorDocument
|
||||
} from '../coreTypes.js'
|
||||
import { euclideanDistance, calculateDistancesBatch } from '../utils/index.js'
|
||||
import { euclideanDistance, calculateDistancesBatch, isZeroNormVector } from '../utils/index.js'
|
||||
import type { BaseStorage } from '../storage/baseStorage.js'
|
||||
import { getGlobalCache, UnifiedCache } from '../utils/unifiedCache.js'
|
||||
import { prodLog } from '../utils/logger.js'
|
||||
|
|
@ -1768,6 +1768,33 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
|
|||
// Process all nouns at once
|
||||
for (const nounData of result.items) {
|
||||
try {
|
||||
// THE ZERO-NORM LAW / THE INDEX BELT — bulk-rebuild leg: a row
|
||||
// holding no REAL vector (the "unvectored" `[]` shape — a
|
||||
// deferred embed's stub, or a row the sanctioned unvector door
|
||||
// rewrote) must never enter the index, mirroring the guard
|
||||
// AddToVectorIndexOperation/ReplaceInVectorIndexOperation already
|
||||
// enforce on the live transactional write paths. This matters
|
||||
// HERE specifically: a row can be unvectored (canonical vector
|
||||
// rewritten to `[]`) while its PERSISTED HNSW graph metadata
|
||||
// (`getVectorIndexData` — level/connections) is still present
|
||||
// from before the unvector, so `hnswData`'s mere presence below
|
||||
// is not proof the row belongs in the index — only the
|
||||
// canonical vector itself is authoritative. Checked against
|
||||
// `nounData.vector` (the FULL loaded vector), never the
|
||||
// to-be-truncated `noun.vector` below, so this holds regardless
|
||||
// of the eager/lazy preload strategy chosen further down.
|
||||
if (!Array.isArray(nounData.vector) || nounData.vector.length === 0) {
|
||||
continue
|
||||
}
|
||||
if (isZeroNormVector(nounData.vector)) {
|
||||
prodLog.warn(
|
||||
`[HNSW] rebuild(): skipping entity ${nounData.id} — persisted vector is ` +
|
||||
`zero-norm (a zero-norm vector is not a vector and never crosses an ` +
|
||||
`engine boundary)`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Load HNSW graph data for this entity
|
||||
const hnswData = await this.storage.getVectorIndexData(nounData.id)
|
||||
|
||||
|
|
|
|||
Reference in a new issue