JusDB
Free DB Audit
NoSQL Databases

Sizing Vector Indexes in valkey-search: FLOAT16, BFLOAT16, and Shared Vector Storage

Size valkey-search vector indexes: where HNSW and FLAT memory goes, when FLOAT16 or BFLOAT16 fits, how 1.3 shares vectors on Valkey 9.1, and the downgrade trap to plan for.

JusDB Team
Published November 9, 2026
Updated November 10, 2026
18 min read

A vector index on Valkey lives in RAM, and on valkey-search 1.2 every vector in a HASH document is held twice: once in the hash field and once in the index. A million 768-dimension FLOAT32 embeddings is about 3 GB of raw vector data, so the duplicate alone can decide whether the dataset fits under maxmemory. valkey-search 1.3 attacks this from two sides. It adds the 2-byte element types FLOAT16 and BFLOAT16, and it stores each HASH vector once, shared by the keyspace and the index, through an API added in Valkey 9.1.0. Both come with conditions: 1.3 is still a release candidate, BFLOAT16 needs specific CPU support, and a 2-byte index cannot be loaded by an older module.

TL;DR
  • FLOAT16, BFLOAT16, and shared vector storage arrive in valkey-search 1.3.0, which needs Valkey 9.1.0 or later. As of November 2026 the newest build is 1.3.0-rc2, a release candidate.
  • valkey/valkey-bundle:latest (9.1.4) ships valkey-search 1.2.1. Only the 9.2.0-rc4 bundle carries 1.3.0-rc2.
  • A 2-byte type halves the vector bytes, but graph links and bookkeeping do not shrink. In the worked example the total drops by about 45%, not 50%.
  • FLOAT16 overflows above 65504 and suits normalized embeddings. BFLOAT16 keeps the FLOAT32 range with about 2 decimal digits and fails on CPUs without a BF16 SIMD path.
  • An index that declares FLOAT16 or BFLOAT16 records a minimum module version of 1.3.0, which blocks a rollback to 1.2.

Scope and review date: November 2026. This post covers valkey-search 1.2.1 (in valkey-bundle 9.1.4) and 1.3.0-rc2 on Valkey 9.1.x, for HNSW and FLAT indexes on HASH and JSON documents. Versions, defaults, field names, and memory layouts were checked against the valkey.io documentation, the 1.3 release notes, and the 1.3 branch source on 10 November 2026. The commands were not run in a lab, and the memory figures are arithmetic, not measurements, so rehearse on staging.


Which valkey-search version has FLOAT16, BFLOAT16, and shared vectors?

All three come from the same release line. The valkey-search 1.3.0-rc1 release notes (6 October 2026) add FLOAT16 and BFLOAT16 for FLAT and HNSW indexes on HASH and JSON documents, and shared vector memory for HASH documents, on by default. 1.3.0-rc2 followed on 26 October 2026. Both need Valkey core 9.1.0 or higher. As of 10 November 2026 there is no 1.3.0 GA tag; the newest GA release is 1.2.1 (7 July 2026).

The valkey.io search pages describe FLOAT16 without a version label, and the bundle lags them. The valkey-bundle versions.json maps each tag to its modules:

Bundle tagValkey servervalkey-searchPushed
latest, 9.1, 9.1.49.1.31.2.127 Oct 2026
9.0.7, 8.1.119.0.7, 8.1.111.0.327 Oct 2026
9.2.0-rc49.2.0-rc2 (release candidate)1.3.0-rc25 Nov 2026

To run 1.3 today, use the 9.2.0-rc4 bundle (a release-candidate server and module) or build libsearch.so from the 1.3.0-rc2 tag for a Valkey 9.1.x server. Neither is a GA stack. If you are moving vector indexes off Redis Stack, see the sibling post on moving off Redis Stack to valkey-bundle.

bash
valkey-cli MODULE LIST
valkey-cli INFO SERVER | grep valkey_version

Interpretation: MODULE LIST shows the search module and its packed ver. The bundle documentation shows ver=10000 for 1.0.0, and a 1.3 build reports a higher number. Check every primary and replica; each node loads its own module.

Where does a vector index use memory?

Each indexed vector costs memory in four places:

  • The vector bytes: DIM times 4 for FLOAT32, or times 2 for FLOAT16 and BFLOAT16. In 1.3 each vector sits in a reference-counted record with an 8-byte header.
  • The keyspace copy: the hash field the application wrote. On 1.2 it is a second full copy; on 1.3, for HASH documents, it is a reference to the same record.
  • HNSW graph links: in the 1.3 source (third_party/hnswlib/hnswalg.h), each node stores up to 2*M 4-byte neighbour ids on layer 0, a 4-byte count, an 8-byte vector pointer, and an 8-byte label: 152 bytes at the default M of 16. Upper layers add M*4+4 bytes each, reached with probability 1/M per layer.
  • Bookkeeping: each slot of capacity gets a mutex, a level counter, and a pointer, and maps between keys and labels add more per document. A FLAT index has no graph, only a pointer and a label per vector plus its key map.

Where the bytes of one HASH vector live on valkey-search 1.3

Follow an HSET into the keyspace, the shared vector record, and the HNSW node; the dashed path is the second copy that 1.2 kept

Vector memory layout in valkey-search 1.3 Step 1: the application writes a vector with HSET into a hash field. Step 2: on valkey-search 1.3 the hash field holds a reference to a single vector record, which holds the DIM times 2 or 4 bytes of the vector. Step 3: the HNSW graph node points to the same record and adds its own links, pointer, and label. On valkey-search 1.2 the index kept a second copy of the vector, shown as a retired box below the record. JSON documents keep their own copy on 1.3. Application HSET doc:1 emb Hash field a reference Vector record DIM x 2 or 4 bytes HNSW node links, pointer, label 1 2 3 Index copy 1.2 only JSON documents keep their own copy on 1.3 1.3 on HASH: one copy, shared by the keyspace and every index that uses it

Lead: sharing removes a whole vector copy per document, and a 2-byte type halves the copy that remains. The graph and bookkeeping bytes stay the same in both cases.

Capacity matters as well as the document count. The per-slot arrays are sized to the capacity, and a full index grows by search.hnsw-block-size slots, 10240 by default per the search monitoring page. A very large INITIAL_CAP reserves memory before any document arrives.

bash
valkey-cli FT.INFO docs_idx

Interpretation: under the vector attribute, read capacity (slots allocated), size (valid vectors), data_type, dimensions, and m. state shows backfill_paused_by_oom if backfill runs out of memory (FT.INFO reference).

FLOAT32, FLOAT16, or BFLOAT16: which element type should you use?

Per the search data formats page, FLOAT16 uses 1 sign, 5 exponent, and 10 mantissa bits: more precise, but anything above 65504 in magnitude overflows to infinity. BFLOAT16 is the top 16 bits of a FLOAT32 (1, 8, and 7 bits), so it keeps the full range and gives up precision. The documentation's rule of thumb: normalized or bounded embeddings suit FLOAT16, and data with a wide dynamic range suits BFLOAT16.

Both types are lossy: each element is rounded at ingestion. From the bit counts, rounding moves an element by at most about 0.05% of its value in FLOAT16 and about 0.4% in BFLOAT16. The documentation says only that recall "may therefore differ slightly" and publishes no recall figures, so measure it on your own data.

The three vector element types in valkey-search 1.3

Switch types to compare size, range, precision, and constraints for a 768-dimension embedding

FLOAT32

PropertyFLOAT32
One 768-dimension vector3,072 bytes
Largest finite magnitudeAbout 3.4e38
Approximate decimal digitsAbout 7
CPU requirementNone
Loads on modules before 1.3.0Yes
Best fitThe reference for recall tests

FLOAT16

PropertyFLOAT16
One 768-dimension vector1,536 bytes
Largest finite magnitude65504
Approximate decimal digitsAbout 3
CPU requirementNone
Loads on modules before 1.3.0No
Best fitNormalized or bounded embeddings

BFLOAT16

PropertyBFLOAT16
One 768-dimension vector1,536 bytes
Largest finite magnitudeAbout 3.4e38
Approximate decimal digitsAbout 2
CPU requirementHaswell or newer on x86-64; NEON-BF16 or SVE-BF16 on ARM
Loads on modules before 1.3.0No
Best fitValues with a wide dynamic range

Lead: for normalized text embeddings, FLOAT16 is the safer 2-byte choice: more precise than BFLOAT16 and no CPU requirement. Source: the data formats page.

The client writes the 2-byte blob, so the conversion happens in your code, and numpy turns an out-of-range value into inf without an error. Check a real sample first.

python
import numpy as np

emb = np.load("sample_embeddings.npy").astype(np.float32)   # shape (n, 768)
print("max |x|:", np.abs(emb).max())                        # must stay well below 65504
f16 = emb.astype(np.float16)
print("overflowed:", np.isinf(f16).sum())
print("max rel. error:", np.nanmax(np.abs(f16 - emb) / np.maximum(np.abs(emb), 1e-6)))

Interpretation: if nothing overflows, FLOAT16 can hold the data, and the last line shows how much rounding it adds. Elements below about 6e-5 fall into FLOAT16's subnormal range and show larger relative errors.

Does your CPU support BFLOAT16 indexes?

The data formats page says BFLOAT16 needs a SIMD BF16 conversion path: Haswell or newer on x86-64, or NEON-BF16 or SVE-BF16 on ARM. Without one, creating a BFLOAT16 index fails with an error, and so does loading one from an RDB file. So a replica or restore target on an older ARM generation cannot load the index. On AWS, the Graviton3 families (c7g, m7g, r7g, c7gn, Hpc7g) support bfloat16. Check Graviton2 and any other generation on the instance itself.

bash
# ARM64 Linux lists bf16 / svebf16 in the Features line; x86-64 Haswell added avx2
grep -m1 -o -w -E 'bf16|svebf16|avx2' /proc/cpuinfo

# The real test: a tiny BFLOAT16 index on a standalone instance of that type
valkey-cli FT.CREATE bf16_probe ON HASH PREFIX 1 bf16probe: \
  SCHEMA v VECTOR FLAT 6 TYPE BFLOAT16 DIM 4 DISTANCE_METRIC L2
valkey-cli FT.DROPINDEX bf16_probe

Interpretation: the /proc/cpuinfo line is a hint; the module's own check is what counts. Probe a standalone instance of each type, because in cluster mode an index definition spreads to every node. If any type rejects it, use FLOAT16 or FLOAT32.

How does shared vector storage remove the second copy?

Valkey 9.1.0 added ValkeyModule_HashSetStringRef. The modules API reference says it sets a hash field to a non-owning reference to a module-owned buffer, and that valkey-search uses it "to avoid maintaining two copies of the indexed vectors". From the 1.3.0-rc1 notes and the 1.3 source:

  • Sharing applies to HASH documents only. JSON indexes can use 2-byte types, but the document keeps its own array.
  • It is on by default. search.enable-vector-sharing is read at module load and is marked hidden in the source; leave it alone.
  • A vector used by several indexes is stored once, and identical updates reuse a registered copy (vector_registry_dedup_cnt).
  • Dropping an index queues its vectors to be unshared, giving the hash fields owned copies again (vector_registry_pending_unshare_cnt).
bash
valkey-cli INFO SEARCH | grep -E 'vector_registry'

Interpretation: search_vector_registry_sharing_active:1 means sharing is on, search_vector_registry_shared_externally_cnt should track your indexed HASH vectors, and the _errors counter should stay at 0. The INFO SEARCH metrics page lists these without the search_ prefix, and the older monitoring page uses a different search_vector_externing section, so confirm names on your build.

How much memory will 1 million 768-dimension vectors need?

This worked example is arithmetic, not a measurement. It uses the 1.3 HNSW layout at M 16 on 64-bit x86 Linux (a 40-byte std::mutex), HASH documents, and an assumed 100 bytes per document for the key and label maps. It leaves out the Valkey key, other fields, replicas, and fork headroom.

python
# Arithmetic only. valkey-search 1.3 HNSW layout, M=16, x86-64 Linux, HASH docs.
N, DIM, M = 1_000_000, 768, 16
level0 = ((2 * M * 4 + 4 + 7) // 8) * 8 + 8 + 8  # 2M links + count (aligned), pointer, label
upper = (M * 4 + 4) / (M - 1)                    # expected upper-layer link bytes
slot = 8 + 4 + 40                                # upper-layer pointer, level, mutex
header = 8                                       # vector record header
maps = 100                                       # ASSUMPTION: key and label maps
overhead = level0 + upper + slot + header + maps # about 316 bytes

for name, b, copies in [("1.2-style FLOAT32, 2 copies", 4, 2),
                        ("1.3 FLOAT32, shared", 4, 1),
                        ("1.3 FLOAT16, shared", 2, 1)]:
    print(f"{name}: {N * (DIM * b * copies + overhead) / 1e9:.2f} GB")

Interpretation: the script prints about 6.46 GB, 3.39 GB, and 1.85 GB. Sharing saves one full vector per document, and FLOAT16 halves the remaining copy, but the roughly 316 bytes of overhead stay. So FLOAT16 is about 45% smaller than shared FLOAT32, not 50%, and the lower the dimension count, the smaller the saving.

Two adjustments turn this into a node budget. Indexes are node-local: the search overview says every primary and replica builds its own, so each replica needs the same budget as its primary. And M 32 raises the layer-0 links from 132 to 260 bytes per vector. To replace the 100-byte assumption, load a 100,000-document sample on staging, divide the change in search_used_memory_bytes by the sample size, and subtract the vector bytes.

Why can't a FLOAT16 index go back to valkey-search 1.2?

The data formats page says an index that declares FLOAT16 or BFLOAT16 records a minimum module version of 1.3.0, and an older module refuses to load it rather than misread the 2-byte elements. FLOAT32-only indexes are unaffected. In practice:

  • No module rollback with the index in place. Before going back to 1.2.1, the GA latest bundle, drop the 2-byte index or recreate it as FLOAT32.
  • No core rollback below 9.1. 1.3 needs Valkey 9.1.0, so a return to 9.0 also means a module that cannot read the index.
  • Mixed-version clusters. Finish the module upgrade on every node before anyone creates a 2-byte index.

The application has a trap too. Query vectors are always binary blobs in the index's declared type, even for JSON indexes, so a client still sending FLOAT32 blobs to a FLOAT16 index gets an error.

bash
valkey-cli FT.SEARCH docs16 "*=>[KNN 10 @emb16 \$q]" PARAMS 2 q "$FLOAT32_BLOB" DIALECT 2
# Error text quoted from the data formats page (3-dimension example):
# Error parsing vector similarity parameters: query vector blob size (12) does not
# match index's expected size (6).

Interpretation: the sizes are the blob you sent and DIM times the bytes per element. On ingestion the mismatch is quieter: the key is left out of the index for that field. Watch hash_indexing_failures in FT.INFO while writers move to the new encoding.

Release-candidate code

1.3.0-rc2 fixed a cluster bug in which nodes rejected index metadata as "requiring a newer module version". Expect more fixes before GA, and keep 2-byte indexes in staging until 1.3.0 GA ships.

How do you monitor index memory and reclaim deleted vectors?

INFO SEARCH reports search_used_memory_bytes for the module and search_index_reclaimable_memory for memory held by deleted vectors. FT.INFO gives per-index capacity, size, and user_indexed_memory, and INFO memory shows the result against maxmemory. Per the 1.3 notes, queued mutations now reserve memory so maxmemory checks account for pending ingestion.

Creating a FLOAT16 index and reading its memory fields

Illustrative output, trimmed to the memory-related fields of FT.INFO and INFO SEARCH; values in angle brackets are placeholders, not measurements

valkey@vs13-node-1:~
$ valkey-cli FT.CREATE docs16 ON HASH PREFIX 1 doc: SCHEMA emb16 \ VECTOR HNSW 6 TYPE FLOAT16 DIM 768 DISTANCE_METRIC COSINEOK$ valkey-cli FT.INFO docs16num_docs 1000000data_type FLOAT16dimensions 768capacity <slots, grows by hnsw-block-size>size 1000000user_indexed_memory <bytes>state ready ok$ valkey-cli INFO SEARCH | grep -E 'memory|sharing|shared_ext'search_index_reclaimable_memory:0search_used_memory_bytes:<bytes>search_used_memory_human:<size>search_vector_registry_shared_externally_cnt:1000000search_vector_registry_shared_externally_errors:0search_vector_registry_sharing_active:1 ok

Lead: the real FT.INFO output is nested key and value arrays, and INFO SEARCH groups fields under section headers. Field names are from the FT.INFO reference and the INFO SEARCH metrics page.

Deletes do not give HNSW memory back. A deleted node becomes a tombstone, and the developer config hnsw-allow-replace-deleted defaults to false in the 1.3 source, so the slot is not reused. The valkey-search README gives the recovery: start with skip-rdb-load, which loads index schemas from the RDB but not their contents and rebuilds vector indexes by backfill, while TAG and NUMERIC indexes keep working.

ini
# valkey.conf, one replica at a time; the README says this is set at module load
loadmodule /usr/lib/valkey/libsearch.so --skip-rdb-load yes

Interpretation: after the restart, vector queries on that node see only the keys backfilled so far, so keep traffic off it until FT.INFO reports state ready. Then remove the flag, or the next restart rebuilds again. Do it when search_index_reclaimable_memory is a large share of search_used_memory_bytes.

How do you size and roll out a 2-byte index?

An index's TYPE is fixed at creation, and each HASH blob must match it. Moving from FLOAT32 to FLOAT16 therefore means writing 2-byte vectors into a new field, building a second index beside the old one, and switching queries. Memory peaks while both exist.

FLOAT32 to FLOAT16 on valkey-search 1.3: sizing and rollout

Eight steps with no write pause; memory peaks during steps 5 to 7, while both indexes exist

  1. Measure the embedding range

    Check the maximum value and overflow on a real sample. Choose BFLOAT16 only for wide-range data.

  2. Confirm versions on every node

    Every primary and replica runs Valkey 9.1.0 or later with valkey-search 1.3.

    valkey-cli MODULE LIST
  3. Probe BFLOAT16 if you need it

    Create and drop a tiny BFLOAT16 index on a standalone instance of every instance type, including restore targets.

  4. Budget memory per node

    Add vector bytes, measured overhead, the old index during the overlap, and fork headroom, then compare with maxmemory on each node.

  5. Dual-write the 2-byte field

    Writers set both fields; a backfill job converts existing documents.

    HSET doc:1 emb <float32 blob> \ emb16 <float16 blob>
  6. Create the FLOAT16 index and wait

    Each node backfills its own copy. Wait for state ready on every node.

    FT.CREATE docs16 ON HASH PREFIX 1 doc: \ SCHEMA emb16 VECTOR HNSW 6 TYPE FLOAT16 \ DIM 768 DISTANCE_METRIC COSINE
  7. Compare recall against FLOAT32

    Run one query set on both indexes at your production EF_RUNTIME and compare the top-K overlap.

  8. Switch queries, then retire FLOAT32

    Send FLOAT16 query blobs to docs16, drop the old index, and remove the old field.

    FT.DROPINDEX docs_idx

Rollback point: until step 8 the FLOAT32 index still serves every query, so rolling back is a repoint. Once docs16 exists, a module downgrade below 1.3.0 requires dropping it first.

For step 7, this script runs each test vector against both indexes and reports the mean top-10 overlap.

python
import numpy as np, valkey

r = valkey.Valkey(host="staging-valkey", port=6379)

def knn(index, field, blob, k=10):
    res = r.execute_command("FT.SEARCH", index, f"*=>[KNN {k} @{field} $q]",
                            "PARAMS", 2, "q", blob, "NOCONTENT", "DIALECT", 2)
    return set(res[1:])

queries = np.load("query_sample.npy").astype(np.float32)
overlap = [len(knn("docs_idx", "emb", v.tobytes()) &
               knn("docs16", "emb16", v.astype(np.float16).tobytes())) / 10
           for v in queries]
print("mean recall@10 vs FLOAT32:", np.mean(overlap))

Interpretation: the FLOAT32 HNSW index is itself approximate, so for an absolute figure compare both with exact neighbours from a FLAT index. If overlap is low, try a higher EF_RUNTIME before ruling out FLOAT16.

Frequently asked questions

Does valkey-search support FLOAT16 vectors?

Yes, from valkey-search 1.3.0, which needs Valkey 9.1.0 or later. As of November 2026, 1.3.0 exists only as release candidates, the newest being 1.3.0-rc2 from 26 October 2026. The GA valkey/valkey-bundle:latest image (9.1.4) ships 1.2.1, which supports FLOAT32 only; the 9.2.0-rc4 bundle carries 1.3.0-rc2.

How much memory does FLOAT16 save in valkey-search?

It halves the vector bytes: a 768-dimension vector drops from 3,072 to 1,536 bytes. Graph links and bookkeeping do not shrink, so the total saving is smaller. For a million 768-dimension vectors with HNSW at M=16 and shared storage, the arithmetic gives about 3.39 GB for FLOAT32 and 1.85 GB for FLOAT16, about 45% less. These are not measured figures.

Should I use FLOAT16 or BFLOAT16 for embeddings?

Use FLOAT16 for normalized or bounded embeddings. It keeps about 3 decimal digits and has no CPU requirement, but values above 65504 overflow. BFLOAT16 keeps the FLOAT32 range with about 2 digits, so it suits wide-range data, and it needs a BF16 SIMD path on every node that creates or loads the index.

Can I downgrade valkey-search after creating a FLOAT16 index?

Not with the index in place. An index that declares FLOAT16 or BFLOAT16 records a minimum module version of 1.3.0, and older modules refuse to load it. Drop it or recreate it as FLOAT32 before you downgrade. FLOAT32-only indexes are unaffected. Because 1.3 needs Valkey 9.1.0, a core rollback to 9.0 hits the same limit.

Does shared vector storage work with JSON documents?

No. The valkey-search 1.3.0-rc1 release notes limit shared vector memory to HASH documents, where one stored copy is referenced by the hash field and every index that uses it. JSON indexes can still use FLOAT16 and BFLOAT16 to halve the index copy, but the JSON document keeps its own array.

Key takeaways

  • FLOAT16, BFLOAT16, and shared vectors need valkey-search 1.3 on Valkey 9.1.0 or later; as of November 2026 that is 1.3.0-rc2, and the GA bundle ships 1.2.1.
  • Vector memory is the vector bytes, the keyspace copy, graph links, and bookkeeping. Sharing removes the second copy on HASH documents; a 2-byte type halves the one left.
  • Budget every primary and replica separately, plus both indexes during a migration and fork headroom.
  • Check the embedding range before choosing FLOAT16, and probe BFLOAT16 on every instance type, including restore targets.
  • A 2-byte index blocks a rollback below module 1.3.0 and core 9.1.0, so keep the FLOAT32 index until recall is measured.

Plan Valkey vector index sizing with JusDB

JusDB's Valkey performance tuning team can review your vector index memory budget against maxmemory, replicas, and fork headroom, plan a FLOAT32 to FLOAT16 migration with a recall check and a rollback point, and help run it on staging first.

Bring your index definitions, dimension and document counts, node types, and the Valkey and valkey-search versions on each node. Your team approves every production change.

Share this article

Database engineering notes

Articles like this one, in your inbox. No spam, unsubscribe anytime.

JusDB Team

Official JusDB content team

Keep reading

Moving a Multi-Database Valkey Instance into a 9.x Cluster

Move a Valkey instance that uses SELECT 1 to 15 into a 9.x cluster: keep or remap databases, copy each one, verify counts per database, and keep a way back.

Redis18 minNov 10, 2026
Read

Valkey Keyspace Notifications in Production: Expired Events, hexpired and Cluster Mode

Run Valkey keyspace notifications in production: why expired and hexpired events lag the TTL, how to subscribe on every cluster node, and when a stream is safer.

Redis20 minNov 10, 2026
Read

Valkey GLIDE in Production: AZ-Affinity Reads and Migrating from Legacy Clients

Configure Valkey GLIDE AZ-affinity reads on servers and clients, learn what GLIDE 2.5.3 does not support, and migrate from redis-py, Jedis, ioredis, or go-redis one service at a time.

Redis20 minNov 10, 2026
Read