Adding a multivector named vector to an indexed collection breaks it (regression in 1.19.0): "Cannot merge vector storage: source is not a f32 multi-dense storage"
Current Behavior
Adding a multivector named vector to an existing collection breaks the collection. The new vector is added with PUT /collections/{name}/vectors/{vector_name} (new in 1.18) and populated with PUT /points/vectors. This happens when the collection's segments are already indexed:
- Collection status goes red and stays red. The segment count grows instead of shrinking.
- The optimizer logs this repeatedly (22 times within 60 s in the repro below):
ERROR collection::update_workers::optimization_worker: Optimization error: Service runtime error: Cannot merge vector storage: source is not a f32 multi-dense storage - Every query on the new multivector fails:
{"status":{"error":"Wrong input: Conversion between multi and regular vectors failed"}}
Adding a regular dense vector in the same way works, and so does adding a sparse vector. Declaring the multivector at collection creation also works.
This is a regression in 1.19.0. The same script is fine on 1.18.3.
| Version | Add multivector to indexed collection | Add dense vector (control) |
|---|---|---|
| 1.18.3 | green, query OK, 0 merge errors | — |
| 1.19.0 | red, query error, 22 merge errors | — |
| 1.19.1 | red, query error, 22 merge errors | green, query OK |
Steps to Reproduce
docker run -d -p 6333:6333 qdrant/qdrant:v1.19.1- Create a collection with one dense vector
a(256-d, Cosine). Setindexing_threshold: 100so segments get indexed quickly. - Upsert 5,000 points and wait until the collection is green with
indexed_vectors_count = 5000. PUT /collections/mv_add_repro/vectors/mvwith{"dense": {"size": 256, "distance": "Cosine", "multivector_config": {"comparator": "max_sim"}}}. The response isok.- Set
mv(3 rows each) on 2,500 points withPUT /collections/mv_add_repro/points/vectors?wait=true. - Wait about 60 s. Check the collection status and the logs, and query
using: "mv".
A self-contained script (Python stdlib only, random data) is below. There's no quantization and nothing on disk; the default in-RAM vector is enough to trigger it. We first saw it on a larger collection with int8 quantization and on-disk vectors, and it behaved the same.
Output on 1.19.1:
1. create collection: ok
2. 5000 points upserted; status=green indexed_vectors_count=5000 segments=4
3. PUT /collections/mv_add_repro/vectors/mv (multi): ok
4. set 'mv' on 2500 points via PUT /points/vectors
5. after 60 s: status=red segments=6 points_with_mv=2500
query on 'mv': {'error': 'Wrong input: Conversion between multi and regular vectors failed'}The same script with --kind dense on 1.19.1, and with --kind multi on 1.18.3, ends with status=green segments=4 and query on 'mv': ok.
#!/usr/bin/env python3
"""Repro: adding a multivector named vector to an existing collection whose segments are already indexed.
Python stdlib only. Usage: repro.py [--url http://localhost:6333] [--on-disk] [--quant] [--kind multi|dense]"""
import argparse, json, random, time, urllib.request, urllib.error
ap = argparse.ArgumentParser()
ap.add_argument("--url", default="http://localhost:6333"); ap.add_argument("--on-disk", action="store_true")
ap.add_argument("--quant", action="store_true"); ap.add_argument("--kind", default="multi", choices=["multi", "dense"])
a = ap.parse_args(); B = a.url.rstrip("/") + "/collections/"
def req(method, path, body=None):
r = urllib.request.Request(B + path, data=json.dumps(body).encode() if body is not None else None, method=method,
headers={"content-type": "application/json"})
try: return json.load(urllib.request.urlopen(r, timeout=120))
except urllib.error.HTTPError as e: return json.loads(e.read() or b"{}")
C, D = "mv_add_repro", 256
random.seed(1); vec = lambda: [random.random() for _ in range(D)]
req("DELETE", C)
cfg = {"vectors": {"a": {"size": D, "distance": "Cosine", "on_disk": a.on_disk}},
"optimizers_config": {"default_segment_number": 4, "indexing_threshold": 100}}
if a.quant: cfg["quantization_config"] = {"scalar": {"type": "int8", "quantile": 0.99, "always_ram": True}}
print("1. create collection:", req("PUT", C, cfg)["status"])
for b in range(10):
req("PUT", C + "/points?wait=true", {"points": [{"id": b * 500 + i, "vector": {"a": vec()}} for i in range(500)]})
t = time.time()
while time.time() - t < 300:
r = req("GET", C)["result"]
if r["status"] == "green" and (r.get("indexed_vectors_count") or 0) >= 5000: break
time.sleep(2)
print(f"2. 5000 points upserted; status={r['status']} indexed_vectors_count={r.get('indexed_vectors_count')} segments={r['segments_count']}")
spec = {"dense": {"size": D, "distance": "Cosine"}}
if a.kind == "multi": spec["dense"]["multivector_config"] = {"comparator": "max_sim"}
print(f"3. PUT /collections/{C}/vectors/mv ({a.kind}):", req("PUT", C + "/vectors/mv", spec).get("status"))
mk = (lambda: [vec(), vec(), vec()]) if a.kind == "multi" else vec
for b in range(10):
req("PUT", C + "/points/vectors?wait=true", {"points": [{"id": b * 500 + i, "vector": {"mv": mk()}} for i in range(0, 500, 2)]})
print("4. set 'mv' on 2500 points via PUT /points/vectors")
time.sleep(60)
r = req("GET", C)["result"]
q = req("POST", C + "/points/query", {"query": ([vec(), vec()] if a.kind == "multi" else vec()), "using": "mv", "limit": 3})
cnt = req("POST", C + "/points/count", {"exact": True, "filter": {"must": [{"has_vector": "mv"}]}})
print(f"5. after 60 s: status={r['status']} segments={r['segments_count']} points_with_mv={cnt.get('result', {}).get('count')}")
print(" query on 'mv':", "ok" if q.get("status") == "ok" else q.get("status"))Expected Behavior
The added multivector behaves the same as a multivector declared at creation, and as it does on 1.18.3: the collection returns to green, segments merge, and MaxSim queries on the new vector work.
Possible Solution
A guess: the storage created for the new vector in existing immutable/indexed segments ignores multivector_config and is a regular dense storage, while appendable segments get a proper multi-dense storage. Merges then fail on the type mismatch, and queries fail converting the query.
It appeared in 1.19.0, which changed immutable-segment storage (single-file mmap by default, memory tiers). That change may be the path that drops the multivector config.
Deleting the added vector (DELETE /collections/{name}/vectors/mv) let the optimizer recover the collection in our case.
Context (Environment)
- Qdrant
v1.19.0andv1.19.1(official Docker images), single node, Docker Desktop on macOS arm64.v1.18.3is not affected. - We wanted to add a per-point multivector to a live collection with the new add-vector API, instead of rebuilding the collection. The workaround is to declare every vector at creation, then rebuild and swap by alias.
Source: qdrant/qdrant