|
| 1 | +# MEP: Local Format — Pluggable On-Node Data Formats for Sealed Segments |
| 2 | + |
| 3 | +- **Created:** 2026-03-05 |
| 4 | +- **Author(s):** @zhicheng |
| 5 | +- **Status:** Draft |
| 6 | +- **Component:** QueryNode | DataNode | Storage |
| 7 | +- **Related Issues:** TBD |
| 8 | +- **Released:** TBD |
| 9 | + |
| 10 | +## Summary |
| 11 | + |
| 12 | +Introduce a **local_format** field-level property that controls how sealed segment data is stored and accessed on query nodes. The default format is the existing Milvus row-oriented layout (RowChunk); the first alternative is **Vortex**, a compressed columnar format that reduces memory and disk footprint while maintaining query performance through on-demand decompression. |
| 13 | + |
| 14 | +## Motivation |
| 15 | + |
| 16 | +Milvus sealed segments currently store field data in an uncompressed, row-oriented binary layout (RowChunk). This works well for random access but has two drawbacks: |
| 17 | + |
| 18 | +1. **Memory pressure**: All field data must be fully decompressed in memory (or mmap'd at full size). For large scalar fields (VARCHAR, JSON), this can consume significant RAM. |
| 19 | +2. **No format flexibility**: The on-node data format is hard-coded. There is no way to choose a format that trades CPU (decompression) for memory/disk savings. |
| 20 | + |
| 21 | +The **local_format** feature addresses this by: |
| 22 | + |
| 23 | +- Allowing users to specify `local_format=vortex` per field at collection creation time. |
| 24 | +- Keeping compressed data on disk (via mmap) and decompressing only the accessed portion on demand. |
| 25 | +- Providing a unified data access interface (`ChunkDataView`) so upper layers are format-agnostic. |
| 26 | + |
| 27 | +## Public Interfaces |
| 28 | + |
| 29 | +### Collection Creation |
| 30 | + |
| 31 | +A new type parameter `local_format` is added to field schemas: |
| 32 | + |
| 33 | +```python |
| 34 | +schema.add_field( |
| 35 | + field_name="description", |
| 36 | + datatype=DataType.VARCHAR, |
| 37 | + max_length=65535, |
| 38 | + local_format="vortex" # new parameter |
| 39 | +) |
| 40 | +``` |
| 41 | + |
| 42 | +Valid values: `"row"` (default), `"vortex"`. |
| 43 | + |
| 44 | +Applies to **non-primary-key scalar fields only**. This includes all non-vector, non-PK types: INT8/16/32/64, FLOAT, DOUBLE, BOOL, VARCHAR, JSON, ARRAY, BSON, etc. Vector fields and primary key fields ignore this parameter. |
| 45 | + |
| 46 | +### Storage Configuration |
| 47 | + |
| 48 | +A global storage format config controls the file format written to object storage: |
| 49 | + |
| 50 | +```yaml |
| 51 | +common: |
| 52 | + storage: |
| 53 | + format: vortex # options: parquet, vortex |
| 54 | +``` |
| 55 | +
|
| 56 | +When `format=vortex`, the write path (DataNode flush / compaction) produces Vortex-encoded files for column groups that contain `local_format=vortex` fields. Other column groups continue to use Parquet. |
| 57 | + |
| 58 | +### Relationship between `storage.format` and `local_format` |
| 59 | + |
| 60 | +These two configs are independent and serve different purposes: |
| 61 | + |
| 62 | +- **`storage.format`** controls the **write path** — what file format is used when flushing/compacting new segments to object storage (S3). Changing this config only affects newly written files; existing files on S3 are not affected. |
| 63 | +- **`local_format`** controls the **read path** — how query nodes load and access field data from sealed segments. |
| 64 | + |
| 65 | +`local_format=vortex` requires Storage V3 and the actual file on S3 to be in vortex format. If a segment's files are still in Parquet format (e.g., written before `storage.format` was changed to vortex), the segment **gracefully falls back to Row format** loading — no error is raised. This means `local_format` is a "preferred" format, not a hard requirement. Only when the on-disk file is actually vortex will the vortex loading path be used. |
| 66 | + |
| 67 | +## Design Details |
| 68 | + |
| 69 | +### 1. Architecture Overview |
| 70 | + |
| 71 | +``` |
| 72 | +┌─────────────────────────────────────────────────────────┐ |
| 73 | +│ Query / Expression │ |
| 74 | +│ chunk_view<T>() unified API │ |
| 75 | +├─────────────────────────────────────────────────────────┤ |
| 76 | +│ AnyDataView (type erasure) │ |
| 77 | +├──────────────────────┬──────────────────────────────────┤ |
| 78 | +│ ContiguousDataView │ VortexDataView │ |
| 79 | +│ (RowChunk path) │ (VortexChunk path) │ |
| 80 | +│ data in memory or │ lazy decompression │ |
| 81 | +│ mmap'd flat buffer │ via Reader/ChunkReader │ |
| 82 | +├──────────────────────┴──────────────────────────────────┤ |
| 83 | +│ Chunk Layer │ |
| 84 | +│ RowChunk (ScalarChunk, StringChunk, ...) │ |
| 85 | +│ VortexChunk (lightweight, no decompressed data) │ |
| 86 | +├─────────────────────────────────────────────────────────┤ |
| 87 | +│ GroupChunkTranslator (Parquet) │ |
| 88 | +│ VortexGroupChunkTranslator (Vortex) │ |
| 89 | +├─────────────────────────────────────────────────────────┤ |
| 90 | +│ Object Storage (S3 / MinIO / Local) │ |
| 91 | +│ Parquet files | Vortex files │ |
| 92 | +└─────────────────────────────────────────────────────────┘ |
| 93 | +``` |
| 94 | +
|
| 95 | +### 2. Column Group Splitting |
| 96 | +
|
| 97 | +Fields with `local_format=vortex` are split into a separate column group from default (row) format fields. This is handled by `LocalFormatPolicy` in the column group splitting pipeline: |
| 98 | +
|
| 99 | +``` |
| 100 | +DefaultPolicies execution order: |
| 101 | +1. SystemColumnPolicy — split system fields (RowID, Timestamp) + PK |
| 102 | +2. AvgSizePolicy — split large fields by avg size |
| 103 | +3. SelectedDataTypePolicy — split vector / text fields (each gets own group) |
| 104 | +4. LocalFormatPolicy — split vortex fields into separate group |
| 105 | +5. RemanentShortPolicy — merge remaining short fields |
| 106 | +``` |
| 107 | +
|
| 108 | +This ensures vortex fields and non-vortex fields are never mixed in the same column group file. |
| 109 | +
|
| 110 | +### 3. Write Path |
| 111 | +
|
| 112 | +When `storage.format=vortex`, the packed writer (via Rust FFI to milvus-storage) encodes column groups in Vortex format. The Vortex file is a single file per column group, containing all fields in that group. |
| 113 | +
|
| 114 | +The binlog path structure is unchanged: |
| 115 | +``` |
| 116 | +insert_log/{collID}/{partID}/{segID}/{groupID}/{logID} |
| 117 | +``` |
| 118 | +
|
| 119 | +Where `groupID` is the column group ID (assigned by split policies), and `logID` is the allocated log ID. |
| 120 | +
|
| 121 | +### 4. Read Path — VortexGroupChunkTranslator |
| 122 | +
|
| 123 | +On query node load, the segment loading path checks per-field `local_format`: |
| 124 | +
|
| 125 | +- If all fields in a column group have `local_format=vortex` AND the file format is vortex → use `VortexGroupChunkTranslator` |
| 126 | +- Otherwise → use standard `GroupChunkTranslator` (Parquet path) |
| 127 | +
|
| 128 | +**Key design: BufferFileSystem as data bridge** |
| 129 | +
|
| 130 | +The milvus-storage `Reader` API reads data through an `arrow::fs::FileSystem` abstraction. It does not accept raw memory buffers directly. To enable decompression from in-memory (or mmap'd) data, we implement a custom `BufferFileSystem` (a subclass of `arrow::fs::FileSystem`) that serves compressed vortex data from memory buffers as if they were files. |
| 131 | +
|
| 132 | +The flow is: |
| 133 | +
|
| 134 | +``` |
| 135 | +S3 download → Arrow Buffer (in-memory) |
| 136 | + → BufferFileSystem::Register("mem://path", buffer) |
| 137 | + → register BufferFileSystem in milvus-storage FilesystemCache (key="mem") |
| 138 | + → Reader resolves "mem://path" via FilesystemCache → BufferFileSystem |
| 139 | + → BufferFileSystem::OpenInputFile() → BufferReader (zero-copy) |
| 140 | + → Reader/ChunkReader decompress from buffer on demand |
| 141 | +``` |
| 142 | +
|
| 143 | +This same pattern extends to mmap: instead of downloading into an Arrow Buffer, the vortex file is written to local disk and mmap'd. The mmap pointer is wrapped as `arrow::Buffer::Wrap(mmap_ptr, size)` and registered in BufferFileSystem identically. The Reader operates on the mmap'd data transparently — the only difference is where the underlying bytes reside (heap vs mmap'd pages). |
| 144 | +
|
| 145 | +`BufferFileSystem` is a read-only singleton that implements: |
| 146 | +- `Register(path, buffer)` / `Unregister(path)` — manage path-to-buffer mappings |
| 147 | +- `OpenInputFile(path)` → `arrow::io::BufferReader` (zero-copy read from buffer) |
| 148 | +- `GetFileInfo(path)` → file size from buffer |
| 149 | +- `type_name()` → `"mem"` (used as FilesystemCache key) |
| 150 | +
|
| 151 | +**VortexGroupChunkTranslator construction:** |
| 152 | +
|
| 153 | +1. Download vortex files from object storage into Arrow Buffers (in-memory) |
| 154 | +2. Register buffers in `BufferFileSystem` singleton with `mem://` paths |
| 155 | +3. Register `BufferFileSystem` in milvus-storage `FilesystemCache` with key `"mem"` (idempotent) |
| 156 | +4. Rewrite column group file paths from `s3://...` to `mem://...` |
| 157 | +5. Create milvus-storage `Reader` from rewritten column groups — Reader resolves `mem://` paths through `FilesystemCache` → `BufferFileSystem` |
| 158 | +6. Extract row group metadata (sizes, row counts) via `ChunkReader` |
| 159 | +7. Merge row groups into cache cells (4 row groups per cell, same as Parquet) |
| 160 | +
|
| 161 | +**VortexGroupChunkTranslator::get_cells():** |
| 162 | +
|
| 163 | +For each requested cell: |
| 164 | +1. Create a shared `ChunkReader` for the cell |
| 165 | +2. For each field in the column group, create a `VortexChunk` holding: |
| 166 | + - Shared `Reader` (for point queries via `take()`) |
| 167 | + - Shared `ChunkReader` (for bulk decompression via `get_chunks()`) |
| 168 | + - Row group indices for this cell |
| 169 | + - Column index in batch, row start offset |
| 170 | +
|
| 171 | +### 5. VortexChunk — Lightweight Chunk |
| 172 | +
|
| 173 | +`VortexChunk` extends `Chunk` but holds **no decompressed data**. It is a lightweight handle: |
| 174 | +
|
| 175 | +```cpp |
| 176 | +class VortexChunk : public Chunk { |
| 177 | + // These throw NotImplemented — callers must use GetAnyDataView() |
| 178 | + const char* ValueAt(int64_t idx) const override; |
| 179 | + const char* Data() const override; |
| 180 | +
|
| 181 | + // Returns lazy VortexDataView — decompression on demand |
| 182 | + AnyDataView GetAnyDataView() const override; |
| 183 | + AnyDataView GetAnyDataView(int64_t offset, int64_t length) const override; |
| 184 | +
|
| 185 | +private: |
| 186 | + std::shared_ptr<Reader> reader_; // for point queries |
| 187 | + std::shared_ptr<ChunkReader> chunk_reader_; // for bulk decompression |
| 188 | + std::vector<int64_t> chunk_indices_; // row group indices |
| 189 | + int column_in_batch_; // column position |
| 190 | + int64_t row_start_; // global row offset |
| 191 | +}; |
| 192 | +``` |
| 193 | + |
| 194 | +### 6. VortexDataView — Lazy Decompression |
| 195 | + |
| 196 | +Each `GetAnyDataView()` call returns a type-specific `VortexDataView` that decompresses on demand: |
| 197 | + |
| 198 | +| DataView Class | Data Types | operator[](idx) | Data() (bulk) | |
| 199 | +|---|---|---|---| |
| 200 | +| `VortexNumericDataView<T>` | int8–64, float, double | `Reader::take({row_start+idx})` | `ChunkReader::get_chunks()` → memcpy | |
| 201 | +| `VortexBoolDataView` | bool | `take()` → BooleanArray | `get_chunks()` → bit unpack | |
| 202 | +| `VortexStringDataView` | string_view (VARCHAR, BSON) | `take()` → BinaryArray | `get_chunks()` → string_view array | |
| 203 | +| `VortexJsonDataView` | Json | `take()` → BinaryArray → Json | `get_chunks()` → Json array | |
| 204 | +| `VortexArrayDataView` | ArrayView (ARRAY) | `take()` → ListArray → ArrayView | `get_chunks()` → ArrayView array | |
| 205 | + |
| 206 | +All types share `VortexDataViewCore` which encapsulates: |
| 207 | +- Point query: `Reader::take()` for single-row access |
| 208 | +- Bulk query: `ChunkReader::get_chunks()` for full decompression |
| 209 | +- `data_offset` field for range sub-views (`GetAnyDataView(offset, length)`) |
| 210 | + |
| 211 | +**Decompressed data lifetime**: Data is held in the `VortexDataView` instance (mutable lazy fields). When the `AnyDataView` is destroyed, decompressed data is freed. This keeps memory usage bounded to the currently active query's working set. |
| 212 | + |
| 213 | +**Thread safety**: |
| 214 | +- `Reader` and `ChunkReader` (from milvus-storage) are **thread-safe** (Rust `Send + Sync`). Multiple VortexChunks safely share the same instances. |
| 215 | +- `VortexDataView` instances are **not thread-safe**. Internal mutable lazy caches have no locking. This matches the usage pattern: each query thread holds its own `PinWrapper<DataView>` instance, so concurrent access to a single DataView does not occur. |
| 216 | + |
| 217 | +### 7. Unified Data Access — ChunkDataView |
| 218 | + |
| 219 | +The `ChunkDataView<T>` interface provides format-agnostic data access: |
| 220 | + |
| 221 | +```cpp |
| 222 | +template <typename T> |
| 223 | +class ChunkDataView : public BaseDataView { |
| 224 | + virtual const T& operator[](int64_t idx) const = 0; // point access |
| 225 | + virtual const T* Data() const = 0; // bulk access |
| 226 | + int64_t RowCount() const; |
| 227 | + const bool* ValidData() const; |
| 228 | +}; |
| 229 | +``` |
| 230 | +
|
| 231 | +Two concrete implementations: |
| 232 | +- `ContiguousDataView<T>`: For RowChunk — wraps raw pointer to in-memory/mmap'd data |
| 233 | +- `VortexDataView` variants: For VortexChunk — lazy decompression |
| 234 | +
|
| 235 | +Upper layers (expression evaluation, search, retrieve) call `chunk_view<T>()` which returns a `PinWrapper<shared_ptr<ChunkDataView<T>>>`. They never know which format underlies the data. |
| 236 | +
|
| 237 | +### 8. Mmap Support (WIP) |
| 238 | +
|
| 239 | +> This section is work-in-progress. Details will be finalized in a follow-up iteration. |
| 240 | +
|
| 241 | +**Current state**: Vortex files are downloaded into Arrow Buffers in RAM. |
| 242 | +
|
| 243 | +**Planned design**: Mmap the compressed vortex file to local disk. |
| 244 | +
|
| 245 | +``` |
| 246 | +S3 download → write to local file → mmap(PROT_READ, MAP_SHARED) |
| 247 | + → arrow::Buffer::Wrap(mmap_ptr, size) // zero-copy |
| 248 | + → register in BufferFileSystem |
| 249 | + → Reader works normally over mmap'd data |
| 250 | +``` |
| 251 | +
|
| 252 | +Key differences from Parquet mmap: |
| 253 | +
|
| 254 | +| | Parquet mmap | Vortex mmap | |
| 255 | +|---|---|---| |
| 256 | +| What's on disk | Decompressed flat data | Compressed vortex file | |
| 257 | +| Disk usage | = raw data size | << raw data size | |
| 258 | +| Access pattern | Direct pointer read | Decompress on access | |
| 259 | +| CPU overhead | None | Decompression per access | |
| 260 | +| Page-in granularity | OS 4KB pages | OS 4KB pages | |
| 261 | +
|
| 262 | +The OS transparently manages page-in/page-out at 4KB granularity. Compressed data that hasn't been accessed recently is evicted from physical RAM by the OS without application involvement. Decompressed data is temporary (lives only in VortexDataView lifetime) and freed after use. |
| 263 | +
|
| 264 | +Memory footprint ≈ size of one decompressed row group batch, regardless of total segment size. |
| 265 | +
|
| 266 | +## Compatibility, Deprecation, and Migration Plan |
| 267 | +
|
| 268 | +- **Backward compatible**: Fields without `local_format` default to `"row"` (existing behavior). |
| 269 | +- **Mixed format**: A collection can have some fields with `local_format=vortex` and others with default. They are stored in separate column groups. |
| 270 | +- **Upgrade path**: Existing segments remain in Parquet/row format. New segments written with `storage.format=vortex` use the vortex format. Compaction can convert old segments to new format. |
| 271 | +- **Downgrade**: If `local_format=vortex` fields are loaded by a Milvus version that doesn't support vortex, loading will fail with a clear error message. |
| 272 | +
|
| 273 | +## Test Plan |
| 274 | +
|
| 275 | +- **Unit tests**: ChunkDataView, VortexDataView, VortexChunk GetAnyDataView for all scalar types |
| 276 | +- **Integration tests**: Insert → flush → load → query/search/retrieve with vortex VARCHAR, INT, JSON fields |
| 277 | +- **Mmap tests**: Verify vortex mmap path produces correct results with memory-constrained scenarios |
| 278 | +- **Compatibility tests**: Mixed format collections (some fields vortex, some row) |
| 279 | +- **Performance benchmarks**: Compare query latency and memory usage between row and vortex formats |
| 280 | +
|
| 281 | +## References |
| 282 | +
|
| 283 | +- [Apache Vortex](https://github.com/spiraldb/vortex) — Compressed columnar format |
| 284 | +- [milvus-storage](https://github.com/milvus-io/milvus-storage) — Milvus storage layer with Vortex support |
| 285 | +- Milvus ChunkDataView design: `internal/core/src/common/ChunkDataView.h` |
| 286 | +- Milvus CacheSlot architecture: `cachinglayer/CacheSlot.h` |
0 commit comments