ClickHouse Stores Vectors as Bit-Plane Subcolumns So a Search Reads Only What It Needs

The QBit column stores a float vector bit-transposed into 32 FixedString bit planes exposed as subcolumns, and an analyzer pass rewrites the distance function so only the p planes its precision needs are read from disk.

TL;DR: A QBit column does not store a vector of floats. It stores the bit planes of those floats: 32 FixedString columns for Float32, where plane j holds bit j of every vector in the column. A distance function with precision p only needs the top p planes, so the analyzer rewrites the call to reference only those subcolumns — and the storage layer reads p/32 of the bytes.

Vector search has an obvious implementation and an obvious problem.

The obvious implementation: keep the vectors as Array(Float32), and for a query vector compute the L2 distance to every stored vector. The obvious problem: a 1024-dimensional Float32 vector is 4 KiB, and a scan of a million vectors touches 4 GiB. Exact search over that is not a query, it is a batch job.

Approximate search fixes this with a separate index — an HNSW graph, an IVF list — and then you have two problems: the index is approximate, and the index has to be maintained, serialized, and kept consistent with the data.

ClickHouse’s QBit type takes a different route. It does not build a separate index. It changes the layout of the column itself so that a partial read is a meaningful thing to do.

The layout

The type’s own documentation describes it:

cpp
// src/Columns/ColumnQBit.h:11
/** Column that represents bit-transposed vectors for efficient vector search operations.
  *
  * This column type stores array data in a bit-transposed layout, where bits are grouped by position
  * rather than by vector element. For example, with Float32 vectors, there are 32 groups (one for each bit),
  * and each group contains the corresponding bit from all vector elements.
  *
  * This column is designed to store the output of the transposeBits() function calls, which convert one element within
  * a regular array into this bit-transposed format. Currently supported numeric types include:
  * - Float64 (64 bit groups)
  * - Float32 (32 bit groups)
  * - BFloat16 (16 bit groups)
  * - Int8 (8 bit groups)
  *
  * Internal structure:
  * - For a Float32 array, the underlying storage is a tuple of 32 FixedString columns
  * - Each FixedString column stores all bits from a specific bit position (0-31)
  * - The length of each FixedString equals the number of vector elements (padded to a multiple of 8)
  *
  * A key optimization for vector search operations is the ability to read only p bit groups
  * (p < 32 for Float32) to reconstruct vectors with p bits of precision. This allows for:
  * - Reduced I/O operations during search
  * - Progressive refinement of search results
  * - Adjustable precision-performance tradeoffs

The column’s own picture of itself:

text
// src/Columns/ColumnQBit.h:35
  * Visual representation:
  *
  * ┌──────┐     ┌─────────────────────────────────────────┐
  * │ QBit │────▶│Tuple(FixedString, FixedString, ... x32) │
  * └──────┘     └─────────────────────────────────────────┘
  *                 │            │                │
  *                 ▼            ▼                ▼
  *              ┌────┐       ┌────┐           ┌────┐
  *              │Bit0│       │Bit1│    ...    │Bit31
  *              └────┘       └────┘           └────┘

and the state is just a tuple plus two numbers:

cpp
// src/Columns/ColumnQBit.h:54
    /// The actual storage for the data
    WrappedPtr tuple;
    /// Number of elements in the original vectors. We will store dimension elements padded to a multiple of 8 (padding elements are 0)
    size_t dimension = 0;
    /// Number of dimensions stored together in one group of streams. Equal to `dimension` when the QBit is not strided.
    size_t stride = 0;

The stride is the Matryoshka knob: it splits the dimensions into groups that live in separate substreams, so a query that only cares about the first 128 dimensions of a 1024-dimensional embedding reads one group instead of eight.

cpp
// src/DataTypes/DataTypeQBit.h:19
    /* Number of dimensions stored together in one group of streams. When equal to `dimension` (the default), QBit is not strided:
     * every bit plane spans all dimensions in a single stream. Otherwise the `dimension` dimensions are split into
     * `dimension / stride` contiguous groups, each group's bit planes stored in separate streams, allowing to read fewer
     * dimensions (e.g. for Matryoshka embeddings). */
    const size_t stride;

Note what the transposition does not do: it does not change the number of bits. A Float32 vector of dimension 1024 is 32 768 bits before and after. The transposition is a pure reordering, and its entire purpose is to make partial reads meaningful.

The transposition

The kernel is a per-value function: given one float, scatter its bits into the planes.

cpp
// src/DataTypes/Serializations/SerializationQBit.h:105
    /** Does bit transposition. This is general (inaccurate) idea of how the bits are mapped
      *     1st bit of 1st element -> 1st bit of 1st word
      *     1st bit of 2nd element -> 2nd bit of 1st word
      *     ...
      *     1st bit of 16th element -> 16th bit of 1st word
      *     2nd bit of 1st element  -> 1st bit of 2nd word
      *     etc.
      *
      * In practice, this is what happens
      *
      *   f_{i,j} = bit j of float i
      *
      *       ◄── First FixedString Column ──►   ◄── Second FixedString Column ──►
      *                                        │
      *     ╔════════════════╤════════════════╗ ╔════════════════╤════════════════╗
      *     ║  Upper Byte    │  Lower Byte    ║ ║  Upper Byte    │  Lower Byte    ║
      * ... ╟────────────────┼────────────────╢ ╟────────────────┼────────────────╢ ...
      *     ║ f₀,₈ ... f₀,₁₅ │ f₀,₀ ... f₀,₇  ║ ║ f₁,₈ ... f₁,₁₅ │ f₁,₀ ... f₁,₇  ║
      *     ╚════════════════╧════════════════╝ ╚════════════════╧════════════════╝
      *
      * Notes:
      *  - transposeBits() is a per-value kernel: it maps one float at row i into the bit planes.
      *  - FixedString columns are MSB first, so first column contains the most significant bits of all Floats.
      *  - Bit planes are emitted MSB→LSB overall (higher j first).
      *  - Within each 8-row pack the row order is flipped (…, 16..23, 8..15, 0..7).
      *  - Motivation: this is faster to unpack in the scalar algorithm.
      */

The “row order is flipped” line is the subtle one, and the implementation explains it:

cpp
// src/DataTypes/Serializations/SerializationQBit.cpp:467
template <typename Word>
void SerializationQBit::transposeBits(Word src, const size_t row_i, const size_t total_bits, char * const * __restrict dst)
{
    /// Fast out on common all-zeros case
    if (!src)
        return;

    /// (row_i ^ 7) maps 0 -> 7, 1 -> 6, ..., 7 -> 0. Same with higher numbers: 15 -> 8, etc. Required for our row ordering
    const size_t bit_index = (total_bits - 1) - (row_i ^ 7);
    const size_t byte_pos = bit_index / 8;
    const uint8_t bit_mask = static_cast<uint8_t>(1u << (bit_index % 8));
    constexpr size_t bits_per_word_minus_one = sizeof(Word) * 8 - 1;

    /// Process only the set bits
    while (src)
    {
        /// Index of the lowest set bit
        auto trailing_zeros = std::countr_zero(src);
        size_t col_idx = bits_per_word_minus_one - trailing_zeros;
        dst[col_idx][byte_pos] |= bit_mask;
        /// Clear that bit
        src &= src - 1;
    }
}

Three optimisations in nine lines:

  1. All-zero fast path. A zero float contributes nothing; skip it.
  2. Only the set bits. countr_zero finds the lowest set bit and src &= src - 1 clears it, so the loop runs once per set bit, not once per bit. A typical float has ~half its bits set, and a denormal-heavy vector has far fewer.
  3. The row flip. row_i ^ 7 reverses the order within each group of eight rows. This is not cosmetic: the unpacking kernel reads eight rows' worth of a plane as one 64-bit word and wants to write them out with a simple descending pointer, so the transposed order makes the write side a straight-line loop.

The caller — the cast from Array(Float32) to QBit — writes one row at a time, transposing each float into the planes:

cpp
// src/Functions/FunctionsConversion.cpp:1457
        /// Transpose bits right inside the FixedStrings. Dimension `i` belongs to stride group `i / stride` and is written into
        /// that group's `size` bit planes (tuple indices [group * size, group * size + size)).
        for (size_t i = 0; i < n; ++i)
        {
            Word w = 0;

            FloatType v = data[prev_offset + i];
            std::memcpy(&w, &v, sizeof(Word));

            const size_t group = i / stride;
            const size_t local_i = i - group * stride;
            SerializationQBit::transposeBits<Word>(w, local_i, padded_stride, row_ptrs.data() + group * size);
        }

The unpacking kernel

Going the other way — turning planes back into vectors — is the hot path of every distance query. The generic version is a byte-at-a-time scatter:

cpp
// src/DataTypes/Serializations/SerializationQBit.h:152
/// Generic kernel that untransposes one bit plane. Defined in the header s.t. it inlines into hot loops.
DECLARE_DEFAULT_CODE(
    template <typename T>
    ALWAYS_INLINE void untransposeBitPlaneImpl(const UInt8 * __restrict src, T * __restrict dst, size_t stride_len, T bit_mask)
    {
        const size_t bytes_per_fs = stride_len / 8;
        ssize_t row_base = stride_len - 1;

        for (size_t b = 0; b < bytes_per_fs; ++b, row_base -= 8)
        {
            const uint8_t v = src[b];

            /// Fast out on common all-zeros case
            if (!v)
                continue;

            for (int i = 0; i < 8; ++i)
            {
                /// Mask is 0...0 if current bit is 0, 1...1 if it is 1. Use it to avoid a branch
                T mask = static_cast<T>(-T((v >> i) & 1));
                dst[row_base - 7 + i] |= (mask & bit_mask);
            }
        }
    })

Note the branchless trick: mask is all-ones or all-zeros, so mask & bit_mask is the target bit or nothing, and the |= is unconditional.

The AVX-512 version does eight rows at a time with a mask register:

cpp
// src/DataTypes/Serializations/SerializationQBit.cpp:498
DECLARE_X86_64_V4_SPECIFIC_CODE(
    static void untransposeBitPlaneFloat64Impl(const UInt8 * __restrict src, UInt64 * __restrict dst, size_t stride_len, UInt64 bit_mask)
    {
        const size_t bytes_per_fs = stride_len / 8;
        ssize_t row_base = stride_len - 1;

        const __m512i bmask = _mm512_set1_epi64(bit_mask);

        /// By default clang-21 with X86_64_V4 unrolls this loop (it didn't with AVX512F) and that makes it slower.
        /// Prevent unrolling to achieve better performance.
        _Pragma("nounroll") for (size_t b = 0; b < bytes_per_fs; ++b, row_base -= 8)
        {
            uint8_t v = src[b];
            if (!v)
                continue;

            __mmask8 k = v;
            uint64_t * row_ptr = dst + row_base - 7;

            __m512i cur = _mm512_loadu_si512(reinterpret_cast<const __m512i *>(row_ptr));
            __m512i upd = _mm512_or_si512(cur, bmask);
            cur = _mm512_mask_mov_epi64(cur, k, upd);
            _mm512_storeu_si512(reinterpret_cast<__m512i *>(row_ptr), cur);
        }
    })

k = v turns the eight bits of the byte into an 8-bit mask register; the masked move then ORs the target bit into exactly the rows whose bit was set. The kernel is resolved once, outside the hot loop, so the runtime CPU check is not paid per plane.

Precision: reading fewer planes

Here is the payoff. L2DistanceTransposed(qbit, reference, precision) takes a precision argument — the number of bit planes to use.

cpp
// src/Functions/array/distanceTransposed.cpp:1153
            /// Untranspose, for each stride group, its `precision` bit planes into that group's slice of every row of the block
            for (size_t group = 0; group < num_groups; ++group)
            {
                for (size_t bit = 0; bit < precision; ++bit)
                {
                    const auto & col = assert_cast<const ColumnFixedString &>(*planes[group * precision + bit].column);
                    Word bit_mask = static_cast<Word>(Word(1) << (sizeof(Word) * 8 - 1 - bit));

Only precision planes are untransposed. The dropped planes are not “approximated” — they are not read at all. The precision is a knob on the I/O.

Turning p bits back into a number is a separate problem — the dropped bits have to be rounded, not floored, or every reconstructed value is biased towards zero — and the kernel does it with a single centre_fill bit (src/Functions/array/distanceTransposed.cpp:1121). The rest of this article is about the storage side: how the planes are laid out and how a partial read reaches the disk.

The analyzer rewrite: this is where the I/O is saved

The function only reads precision planes in memory. To save I/O, the query has to be rewritten so that the storage layer only opens the substreams of those planes.

That is what DistanceTransposedPartialReadsPass does. The QBit column is a tuple of FixedString subcolumns, one per plane, and the pass replaces

sql
L2DistanceTransposed(vec, [0.1, 0.2, ...], 8)

with a call that takes the first 8 planes as separate column references:

cpp
// src/Analyzer/Passes/DistanceTransposedPartialReadsPass.cpp:179
        auto add_plane = [&](size_t tuple_idx)
        {
            /// Tuple element indices are 1-based in the subcolumn syntax. When the QBit column is Nullable, the bit-plane
            /// subcolumn is Nullable(FixedString) too, which lets the per-row null map reach the distance function.
            auto plane_type = qbit->getNestedTupleElementType();
            if (is_nullable)
                plane_type = makeNullable(plane_type);
            NameAndTypePair column{qbit_node->getColumnName() + "." + std::to_string(tuple_idx + 1), plane_type};
            new_args.push_back(std::make_shared<ColumnNode>(column, qbit_node->getColumnSource()));
        };

For the strided (Matryoshka) case it picks the planes of the groups it needs:

cpp
// src/Analyzer/Passes/DistanceTransposedPartialReadsPass.cpp:190
        if (is_strided)
        {
            const size_t num_groups = used_dims / stride;
            /// Group-major order: for each stride group, its first `precision` bit planes (tuple index = group * element_size + bit).
            for (size_t group = 0; group < num_groups; ++group)
                for (size_t bit = 0; bit < precision; ++bit)
                    add_plane(group * element_size + bit);
        }
        else
        {
            for (size_t bit = 0; bit < precision; ++bit)
                add_plane(bit);
        }

Because a FixedString plane is a subcolumn of the QBit column, and MergeTree parts store substreams separately with their own marks, reading only planes 0..7 means only those substreams are opened. The other 24 are never touched — not filtered, not decompressed, not read.

The rewrite is guarded by a setting:

cpp
// src/Core/Settings.cpp:8211
    DECLARE(Bool, optimize_qbit_distance_function_reads, true, R"(
Replace distance functions on `QBit` data type with equivalent ones that only read the columns necessary for the calculation from the storage.
)", 0, \

(At precision 1 there is a further special case — the distance degenerates into a Hamming count over the sign bits — but that is about the arithmetic, not about the layout.)

The SQL

sql
CREATE TABLE vectors
(
    id   UInt64,
    vec  QBit(Float32, 1024)
)
ENGINE = MergeTree
ORDER BY id;
sql
-- exact-ish: all 32 planes are read
SELECT id FROM vectors
ORDER BY L2DistanceTransposed(vec, [0.1, 0.2, ...]) ASC
LIMIT 10;

-- approximate: 8 of 32 planes, a quarter of the bytes
SELECT id FROM vectors
ORDER BY L2DistanceTransposed(vec, [0.1, 0.2, ...], 8) ASC
LIMIT 10;

and with a Matryoshka-style embedding, where the first dimensions are the most informative:

sql
CREATE TABLE vectors
(
    id   UInt64,
    vec  QBit(Float32, 1024, 128)
)
ENGINE = MergeTree
ORDER BY id;

-- reads only the first stride group
SELECT id FROM vectors
ORDER BY L2DistanceTransposed(vec, [0.1, 0.2, ...], 16, 128) ASC
LIMIT 10;

The fourth argument is the number of dimensions to read.

Trade-offs

What it buys: a distance query at precision p reads roughly p/32 of the column, with no separate index to build, maintain, or keep consistent. The “index” is the column.

What it costs:

  • The distances are approximate by construction. Precision 8 on a Float32 means 8 bits of the mantissa. Whether that is enough depends entirely on the embedding and the query distribution.
  • Transposition is not free on the write path. Every float has to be scattered bit by bit into 32 planes. The countr_zero loop makes it proportional to the number of set bits, but it is still per-value work.
  • The column is not comparable or usable as a float vector. It has to be cast back (CAST(vec, 'Array(Float32)')) to be read as a vector, and that cast untransposes every plane.
  • Bit planes compress differently. A plane of “the 5th bit of every float” is much more compressible than a run of floats, because neighbouring values often share their high bits. That is a bonus, but it also means the compressed size is data-dependent.
  • Only Int8, BFloat16, Float32 and Float64 elements are supported — the type needs a fixed element width to have a well-defined number of planes.
  • NULL rows are skipped rather than transposed, which means a Nullable QBit column has to keep its null map in every plane subcolumn.

Wrapping up

The one thing to remember: the QBit column is a transposed vector column, and transposition is what makes “read 8 bits of precision” a well-defined operation on a column rather than a special case bolted onto a distance function.

The part people get wrong: precision is not a rounding parameter for the computation, it is a parameter for the I/O. precision = 8 does not compute an exact distance and round it — it never reads the other 24 planes.

What to go do about it: if you have a vector search workload, the question is not “which index” but “what precision is good enough”. Start with precision = 16 on a Float32 embedding and compare the recall of the top-10 against an exact scan; then lower it until recall drops. The optimize_qbit_distance_function_reads setting (on by default) makes sure the low precision actually reaches the storage layer.

ClickhouseInternalsVector searchPerformanceSimd