Skip to content

[Bug]: A doc without a vector reads back a fake all-zero vector — missing values are indistinguishable from genuine zeros #691

Description

@YongqiYin

Description

Vector fields can be declared nullable through the C-family APIs (zvec_field_schema_create(name, data_type, nullable, dimension); C++ FieldSchema(name, VECTOR_*, dimension, nullable, index_params)). The Python SDK hardcodes nullable=false and rejects vector-less docs at doc validation, so the scenario is created via C/C++ — but such a collection can be read from any API. zvec forces with-id mode for every vector index (engine_helper.hpp calls with_use_id_map(false) on the shared param builder), so key == physical slot id. To keep slots aligned with row ids, the write path fills a missing row with a zero-vector placeholder registered under kInvalidKey (FlatStreamerEntity::add_vector_with_id). Therefore nothing fails, nothing is misaligned, and search never recalls the placeholder — it is simply returned as data. The problem: a missing value is indistinguishable from a genuine all-zero vector, even though the kInvalidKey marker is already stored in the flat index's keys array; the read path just never checks it.

Steps to Reproduce

Append to tests/db/iterator_test.cc, then ninja iterator_test && ./bin/iterator_test --gtest_filter='*ReproIssue691*':

TEST_F(IteratorTest, ReproIssue691) {
  auto schema = std::make_shared<CollectionSchema>("demo");
  schema->add_field(std::make_shared<FieldSchema>(
      "vec", DataType::VECTOR_FP32, 4, true /*nullable*/,
      std::make_shared<FlatIndexParams>(MetricType::IP)));
  CollectionOptions options;
  options.read_only_ = false;
  auto coll =
      Collection::CreateAndOpen(iter_test_path, *schema, options).value();

Doc d1, d2;
std::vector<float> zeros{0.0f, 0.0f, 0.0f, 0.0f};
d1.set_pk("pk_1"); // no vector: field left out
d2.set_pk("pk_2");
d2.set<std::vector<float>>("vec", zeros); // genuine all-zero vector
std::vector<Doc> docs{d1, d2};
ASSERT_TRUE(coll->insert(docs).has_value());
coll->flush();

auto show = [](const char *how, const std::string &pk, const auto &v) {
std::cout << how << " " << pk << ": ";
if (!v.has_value()) {
std::cout << "vec unset" << std::endl;
return;
}
std::cout << "vec =";
for (float x : v.value()) std::cout << " " << x;
std::cout << std::endl;
};

auto fetched = coll->fetch({"pk_1", "pk_2"}, std::nullopt, true).value();
for (const char *pk : {"pk_1", "pk_2"})
show("fetch ", pk, fetched[pk]->get<std::vector<float>>("vec"));

auto it = coll->create_iterator().value();
while (auto doc = it->next().value())
show("iterate", doc->pk(), doc->get<std::vector<float>>("vec"));
it->close();
coll->destroy();
}

Actual output on mainpk_1 (no vector) and pk_2 (a real all-zero vector) are indistinguishable on both read paths:

fetch   pk_1: vec = 0 0 0 0
fetch   pk_2: vec = 0 0 0 0
iterate pk_1: vec = 0 0 0 0
iterate pk_2: vec = 0 0 0 0

Expected

fetch   pk_1: vec unset
fetch   pk_2: vec = 0 0 0 0
iterate pk_1: vec unset
iterate pk_2: vec = 0 0 0 0

Suggested fix

Propagate the stored marker on read — no storage-format and no write-path change (~110 lines across 9 files, plus tests): flat get_vector_by_key returns IndexError_NoExist for a kInvalidKey slot → Index::_dense_fetch / _sparse_fetch pass it through (real failures stay RuntimeError) → VectorColumnIndexer::Fetch maps it to Status::NotFound (block-lookup failures stay InternalError, so corruption is never read as missingness) → fetch(), create_iterator()/next() and the SQL vector fetch leave the field unset / emit NULL. Coverage, measured with that patch (300 docs, one vector-less; every other doc reads back correctly in all cells). The declared index type is only built by optimize() / create_index(), while writing segments and flushed blocks always use flat (segment.cc forces MakeDefaultVectorIndexParams):

stage declared Flat declared HNSW declared IVF
before flush, and after flush() unset unset unset
after optimize() unset 0 0 0 0 0 0 0 0

So the patch fixes the default index (flat, dense and sparse) and every not-yet-optimized segment, but the same doc flips back to all zeros once the declared HNSW/IVF/Vamana index is built. Reason: on optimize(), HNSW/IVF are rebuilt via merge and the zero-vector placeholder is carried over as a genuine entry (the missing row's id is re-added with a zero vector), so get_vector_by_id(id) returns that zero vector — the kInvalidKey marker that flat keeps in its keys array is not represented in the rebuilt index, so the read side has nothing to test. A complete fix needs an index-agnostic presence record at the DB layer (e.g. a per-block bitmap of vector-less rows, remapped on merge like the delete bitmap). IVF additionally needs IVFEntity::key_to_id to stop returning one sentinel for both "key not found" and "keys-segment read failure". A complete fix therefore needs either an index-agnostic presence record at the DB layer (e.g. a per-block bitmap of vector-less rows, remapped on merge like the delete bitmap), or per-algorithm existence checks — or, if optional vectors are not meant to be supported, rejecting vector-less docs at insert, as the Python/Node SDKs already do.

Severity

Medium-low: no crash, no failure, no misalignment, no search pollution. The impact is silent data fabrication on the read path — exports, statistics and ETL checks see a "vector" that was never written — for explicitly nullable vector fields created through the C-family APIs.

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

Status
Backlog

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions