You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
EmbListOffset(const size_t* lims, size_t rows) infers the end of lims by scanning for the first element equal to rows. Empty lists do not advance the cumulative offset, so a run of empty lists at the tail of a segment appears in lims as a run of repeated values equal to rows — and the loop swallows all of them, pushing back only one.
The resulting EmbListOffset is short by exactly the number of trailing empty lists, which makes num_el() (and therefore ExternalCount(), added in #1793) smaller than the segment's real row count.
EmbListOffset(constsize_t* lims, size_t rows) {
size_t idx = 0;
assert(lims[idx] == 0);
assert(rows > 0);
while (lims[idx] < rows) { // <-- infers array length from a sentinel valueassert(idx == 0 || lims[idx] >= lims[idx - 1]);
offset.push_back(lims[idx]);
idx++;
}
assert(lims[idx] == rows); // always true: the loop stops at the first match
offset.push_back(lims[idx]); // <-- only one entry is pushed back
}
The build path reaches it here:
include/knowhere/index/index_node.h:424 — BuildEmbList(dataset, cfg, lims, dataset->GetRows(), ...); the 4th argument is the flattened vector count, used as the sentinel.
Formally: for n rows with t trailing empty lists, lims[n-t] … lims[n] all equal the total. The loop stops at index n-t, so offset ends up with n-t+1 entries and num_el() == n-t. The loss is exactly t. When the last row is non-empty, t == 0 and nothing is lost — which is why this stays dormant in the usual ColBERT-style workload where every row has vectors.
Impact
Once the offset meta is short, the index is short. Two distinct symptoms:
Filtered search fails hard. The caller passes a row-domain bitset sized to the segment's row count; Index<T>::Search compares it against ExternalCount() and returns Status::invalid_args:
failed to search: invalid args: bitset size should be <= external count,
but we get bitset size: 24808, external count: 24803
Unfiltered search silently under-recalls. When the filter matches everything, the caller passes an empty BitsetView, so the if (!bitset_.empty()) guard skips the check entirely and the search runs against an index that is missing those rows. No error, no warning.
The second one is the more dangerous of the two, since nothing surfaces it.
Also worth noting: both asserts in that constructor are plain assert, compiled out under NDEBUG — and even with them enabled neither would fire, because the loop stops precisely where lims[idx] == rows holds. The truncation is completely silent.
Reproduction
No compaction or large dataset needed. The only requirement is that the last rows of the segment have an empty list for the emb-list field.
Create a collection with an ARRAY<STRUCT{vector FLOAT_VECTOR(d), ...}> field, not nullable.
Insert N rows where the first rows carry vectors and the last k rows carry an empty array.
Flush, build the index with a MAX_SIM metric, load.
Search with an embedding-list query plus any scalar filter that matches a proper subset of rows.
Expected: bitset size: N, external count: N-k. An unfiltered search on the same collection succeeds but never returns the last k rows.
Note on nullable
This is not a nullable-handling bug — it is triggered specifically because the field is not nullable. When nullable is on, the caller skips invalid rows entirely, so they never enter lims and no repeated tail values are produced. With nullable off, an empty array is a legitimate zero-length list and must stay in lims, which is what collides with the sentinel scan.
Fix
A correct constructor already exists on main, introduced by #1673 (7cc9d4be), which takes the element count explicitly instead of scanning for a sentinel:
Consider removing the 2-argument constructor outright — it cannot know the array length and is unsound whenever an empty list can appear.
Replace the two asserts with checks that survive release builds.
Please do not relax the bitset_.size() > ExternalCount() check as a workaround — that check is correct and is what caught this; loosening it would convert a hard failure into a silent wrong-result path.
All scalar indexes on the segment load with num_rows = 24808; the emb-list vector index reports ExternalCount() = 24803.
Field is a non-nullable ARRAY<STRUCT{vector FLOAT_VECTOR(1152), path, model}>; 999 of the rows hold vectors and the rest hold empty arrays.
Ruled out: NULLs (is null returns 0, is not null returns all rows), empty-list skipping at build (that would give 999, not 24803), and nullable-row compaction (the field carries no nullable flag).
#1793 is what made this diagnosable — switching the comparison from Count() (flattened vectors) to ExternalCount() (lists) changed the reported number from a meaningless data count: 5412 to external count: 24803, i.e. off by exactly the trailing empty run. It reported the problem accurately; it did not fix the artifact.
Summary
EmbListOffset(const size_t* lims, size_t rows)infers the end oflimsby scanning for the first element equal torows. Empty lists do not advance the cumulative offset, so a run of empty lists at the tail of a segment appears inlimsas a run of repeated values equal torows— and the loop swallows all of them, pushing back only one.The resulting
EmbListOffsetis short by exactly the number of trailing empty lists, which makesnum_el()(and thereforeExternalCount(), added in #1793) smaller than the segment's real row count.https://github.com/zilliztech/knowhere/blob/branch_v305/include/knowhere/emb_list_utils.h#L30-L41
The build path reaches it here:
include/knowhere/index/index_node.h:424—BuildEmbList(dataset, cfg, lims, dataset->GetRows(), ...); the 4th argument is the flattened vector count, used as the sentinel.src/index/index_node.cc:402—EmbListOffset doc_offset(lims, num_rows);Worked example
8 rows; rows 0–2 hold 2/3/2 vectors, rows 3–7 are empty lists. Total flattened vectors
rows = 7.Formally: for
nrows withttrailing empty lists,lims[n-t] … lims[n]all equal the total. The loop stops at indexn-t, sooffsetends up withn-t+1entries andnum_el() == n-t. The loss is exactlyt. When the last row is non-empty,t == 0and nothing is lost — which is why this stays dormant in the usual ColBERT-style workload where every row has vectors.Impact
Once the offset meta is short, the index is short. Two distinct symptoms:
Filtered search fails hard. The caller passes a row-domain bitset sized to the segment's row count;
Index<T>::Searchcompares it againstExternalCount()and returnsStatus::invalid_args:Unfiltered search silently under-recalls. When the filter matches everything, the caller passes an empty
BitsetView, so theif (!bitset_.empty())guard skips the check entirely and the search runs against an index that is missing those rows. No error, no warning.The second one is the more dangerous of the two, since nothing surfaces it.
Also worth noting: both
asserts in that constructor are plainassert, compiled out underNDEBUG— and even with them enabled neither would fire, because the loop stops precisely wherelims[idx] == rowsholds. The truncation is completely silent.Reproduction
No compaction or large dataset needed. The only requirement is that the last rows of the segment have an empty list for the emb-list field.
ARRAY<STRUCT{vector FLOAT_VECTOR(d), ...}>field, not nullable.Expected:
bitset size: N, external count: N-k. An unfiltered search on the same collection succeeds but never returns the last k rows.Note on nullable
This is not a nullable-handling bug — it is triggered specifically because the field is not nullable. When nullable is on, the caller skips invalid rows entirely, so they never enter
limsand no repeated tail values are produced. With nullable off, an empty array is a legitimate zero-length list and must stay inlims, which is what collides with the sentinel scan.Fix
A correct constructor already exists on
main, introduced by #1673 (7cc9d4be), which takes the element count explicitly instead of scanning for a sentinel:But it is not on
branch_v305:Suggested actions:
branch_v305, or at minimum threadnum_elthroughBuildEmbListand switch to the 3-argument constructor.asserts with checks that survive release builds.Please do not relax the
bitset_.size() > ExternalCount()check as a workaround — that check is correct and is what caught this; loosening it would convert a hard failure into a silent wrong-result path.Observed in production
b0d70ae093, knowhere pin7d74caa09(i.e. carrying fix: validate embedding-list filter bitsets against external ID count #1793).deltalog count = 0.num_rows = 24808; the emb-list vector index reportsExternalCount() = 24803.ARRAY<STRUCT{vector FLOAT_VECTOR(1152), path, model}>; 999 of the rows hold vectors and the rest hold empty arrays.is nullreturns 0,is not nullreturns all rows), empty-list skipping at build (that would give 999, not 24803), and nullable-row compaction (the field carries no nullable flag).#1793 is what made this diagnosable — switching the comparison from
Count()(flattened vectors) toExternalCount()(lists) changed the reported number from a meaninglessdata count: 5412toexternal count: 24803, i.e. off by exactly the trailing empty run. It reported the problem accurately; it did not fix the artifact.