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 main — pk_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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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