usearch 2.3.2 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/format.md CHANGED
@@ -1,48 +1,71 @@
1
- # USearch Format Specification
1
+ # USearch File Format Specification
2
2
 
3
- > ⚠️ **Note**: The format is expected to change in the 3.0 release to support sparse indexes and offer even faster startup times when `view`-ing large indexes from disk.
3
+ > ⚠️ __Important Note__: The file format is slated for updates in the upcoming 3.0 release. These changes aim to support sparse indexes and enhance startup performance when loading large indexes from disk using the `view` command.
4
4
 
5
- ## Table of Contents
5
+ ## Upcoming Version: v3
6
6
 
7
- - [Dense Indexes](#dense-indexes)
8
- - [Core](#core)
9
- - [Fragmented Storage](#fragmented-storage)
7
+ The v3 file format is designed for compatibility with Apache Arrow arrays and consists of an array of variable-length binary strings. The structure for a file with `N` entries is as follows:
10
8
 
11
- ## Dense Indexes
9
+ ### File Structure
10
+
11
+ 1. __File Header__: Metadata and other essential information.
12
+ 2. __Offset Array__: An array of `N+1` eight-byte unsigned integers (`uint64_t`).
13
+ - The entry at index `0 <= i < N` indicates the byte-level offset of the `i-th` vector in the file.
14
+ - The entry at index `N` specifies the total length of the binary file.
15
+ 3. __Data Chunks__: `N` binary data chunks, each starting with the vector itself, followed by index system data.
16
+
17
+ ### Data Retrieval
18
+
19
+ To retrieve the Binary Large Object (BLOB) corresponding to a specific vector, subtract the consecutive offsets indicated in the Offset Array.
20
+
21
+ ### Advantages of the v3 Format
22
+
23
+ The v3 format offers several benefits, making it more future-proof:
24
+
25
+ - __Flexibility__: Easily accommodates variable-length strings.
26
+ - __Performance__: Co-locates vectors with proximity graph entries for optimized data access.
27
+ - __Compatibility__: Simplifies memory-mapping and casting to an Apache Arrow array of vectors or strings.
28
+ - __Extensibility__: Enables external storage systems to reuse the underlying `index_gt` more extensively, particularly in database systems.
29
+
30
+ ---
31
+
32
+ ## Current: v2
33
+
34
+ ### Dense Indexes
12
35
 
13
36
  Dense index files consist of two main parts: [a binary matrix of all vectors](#matrix-blob) and the [index](#index-blob) itself.
14
37
 
15
- ### Matrix BLOB
38
+ #### Matrix BLOB
16
39
 
17
40
  The matrix BLOB is optionally prepended to the index file. It starts with the number of `rows` and `columns`, which can be either 32-bit or 64-bit unsigned integers.
18
41
 
19
42
  - The number of `rows` corresponds to the number of vectors in the index.
20
43
  - The number of `columns` corresponds to the number of bytes in a single vector.
21
44
 
22
- ### Index BLOB
45
+ #### Index BLOB
23
46
 
24
47
  The index BLOB is composed of three main sections:
25
48
 
26
- 1. **Metadata**: Occupies 64 bytes and contains the following fields:
49
+ 1. __Metadata__: Occupies 64 bytes and contains the following fields:
27
50
  1. 7-byte magic string `usearch` to infer the MIME type of the file.
28
51
  2. 3-byte version number, containing the `major`, `minor`, and `patch` release numbers.
29
52
  3. 1-byte enums for `metric`, `scalar`, `key`, and `compressed_slot` types.
30
53
  4. 8-byte integers for the number of present vectors, number of deleted vectors, and number of dimensions.
31
54
  5. 1-byte flags for multi-vector support.
32
55
 
33
- 2. **Levels**: Contains 1-byte integers representing the level each node occupies. This information is used to estimate memory requirements and to enable memory-mapping of the entire file.
56
+ 2. __Levels__: Contains 1-byte integers representing the level each node occupies. This information is used to estimate memory requirements and to enable memory-mapping of the entire file.
34
57
 
35
- 3. **Core**: Detailed in the [Core section](#core). This format is consistent across all types of indexes.
58
+ 3. __Core__: Detailed in the [Core section](#core). This format is consistent across all types of indexes.
36
59
 
37
- ## Fragmented Storage
60
+ ### Fragmented Storage
38
61
 
39
62
  The matrix BLOB can be stored separately and is not required to successfully open the file.
40
63
 
41
- ## Core
64
+ ### Core
42
65
 
43
66
  The core section of the index is serialized as a sequence of nodes, each representing a point in the index. This section immediately follows the `levels` block in the serialized format.
44
67
 
45
- ### Header
68
+ #### Header
46
69
 
47
70
  The core section begins with a header containing the following fields:
48
71
 
@@ -54,10 +77,10 @@ The core section begins with a header containing the following fields:
54
77
 
55
78
  These fields are serialized in a contiguous block of bytes, in the order listed.
56
79
 
57
- ### Levels
80
+ #### Levels
58
81
 
59
82
  Following the header, the `levels` block contains a sequence of 1-byte integers. Each integer represents the level of a node in the index. The number of levels is equal to the `size` field in the header.
60
83
 
61
- ### Nodes
84
+ #### Nodes
62
85
 
63
86
  After the `levels` block, the nodes are serialized. Each node is represented as a contiguous block of bytes. The size and format of these blocks are implementation-specific. Nodes are serialized in the same order as their corresponding levels in the `levels` block.
@@ -10,8 +10,8 @@
10
10
  #define UNUM_USEARCH_HPP
11
11
 
12
12
  #define USEARCH_VERSION_MAJOR 2
13
- #define USEARCH_VERSION_MINOR 3
14
- #define USEARCH_VERSION_PATCH 2
13
+ #define USEARCH_VERSION_MINOR 4
14
+ #define USEARCH_VERSION_PATCH 1
15
15
 
16
16
  // Inferring C++ version
17
17
  // https://stackoverflow.com/a/61552074
@@ -1,12 +1,11 @@
1
1
  #pragma once
2
2
  #include <stdlib.h> // `aligned_alloc`
3
3
 
4
- #include <functional> // `std::function`
5
- #include <numeric> // `std::iota`
6
- #include <shared_mutex> // `std::shared_mutex`
7
- #include <thread> // `std::thread`
8
- #include <unordered_set> // `std::unordered_multiset`
9
- #include <vector> // `std::vector`
4
+ #include <functional> // `std::function`
5
+ #include <numeric> // `std::iota`
6
+ #include <shared_mutex> // `std::shared_mutex`
7
+ #include <thread> // `std::thread`
8
+ #include <vector> // `std::vector`
10
9
 
11
10
  #include <usearch/index.hpp>
12
11
  #include <usearch/index_plugins.hpp>
@@ -93,6 +92,17 @@ struct index_dense_config_t : public index_config_t {
93
92
  bool exclude_vectors = false;
94
93
  bool multi = false;
95
94
 
95
+ /**
96
+ * @brief Allows you to reduce RAM consumption by avoiding
97
+ * reverse-indexing keys-to-vectors, and only keeping
98
+ * the vectors-to-keys mappings.
99
+ *
100
+ * ! This configuration parameter doesn't affect the serialized file,
101
+ * ! and is not preserved between runs. Makes sense for small vector
102
+ * ! representations that fit ina single cache line.
103
+ */
104
+ bool enable_key_lookups = true;
105
+
96
106
  index_dense_config_t(index_config_t base) noexcept : index_config_t(base) {}
97
107
 
98
108
  index_dense_config_t( //
@@ -399,7 +409,7 @@ class index_dense_gt {
399
409
  };
400
410
 
401
411
  /// @brief Multi-Map from keys to IDs, and allocated vectors.
402
- std::unordered_multiset<key_and_slot_t, lookup_key_hash_t, lookup_key_same_t> slot_lookup_;
412
+ flat_hash_multi_set_gt<key_and_slot_t, lookup_key_hash_t, lookup_key_same_t> slot_lookup_;
403
413
 
404
414
  /// @brief Mutex, controlling concurrent access to `slot_lookup_`.
405
415
  mutable shared_mutex_t slot_lookup_mutex_;
@@ -1149,7 +1159,7 @@ class index_dense_gt {
1149
1159
  */
1150
1160
  bool contains(key_t key) const {
1151
1161
  shared_lock_t lock(slot_lookup_mutex_);
1152
- return slot_lookup_.find(key_and_slot_t::any_slot(key)) != slot_lookup_.end();
1162
+ return slot_lookup_.contains(key_and_slot_t::any_slot(key));
1153
1163
  }
1154
1164
 
1155
1165
  /**
@@ -1203,7 +1213,7 @@ class index_dense_gt {
1203
1213
  free_keys_.push(slot);
1204
1214
  typed_->at(slot).key = free_key_;
1205
1215
  }
1206
- slot_lookup_.erase(matching_slots.first, matching_slots.second);
1216
+ slot_lookup_.erase(key);
1207
1217
  result.completed = matching_count;
1208
1218
 
1209
1219
  return result;
@@ -1239,14 +1249,15 @@ class index_dense_gt {
1239
1249
  // - present in `free_keys_`
1240
1250
  // - missing in the `slot_lookup_`
1241
1251
  // - marked in the `typed_` index with a `free_key_`
1252
+ matching_count = 0;
1242
1253
  for (auto slots_it = matching_slots.first; slots_it != matching_slots.second; ++slots_it) {
1243
1254
  compressed_slot_t slot = (*slots_it).slot;
1244
1255
  free_keys_.push(slot);
1245
1256
  typed_->at(slot).key = free_key_;
1257
+ ++matching_count;
1246
1258
  }
1247
1259
 
1248
- matching_count = std::distance(matching_slots.first, matching_slots.second);
1249
- slot_lookup_.erase(matching_slots.first, matching_slots.second);
1260
+ slot_lookup_.erase(key);
1250
1261
  result.completed += matching_count;
1251
1262
  }
1252
1263
 
@@ -1265,20 +1276,18 @@ class index_dense_gt {
1265
1276
  labeling_result_t result;
1266
1277
  unique_lock_t lookup_lock(slot_lookup_mutex_);
1267
1278
 
1268
- if (!multi() && slot_lookup_.count(key_and_slot_t::any_slot(to)))
1279
+ if (!multi() && slot_lookup_.contains(key_and_slot_t::any_slot(to)))
1269
1280
  return result.failed("Renaming impossible, the key is already in use");
1270
1281
 
1271
1282
  // The `from` may map to multiple entries
1272
1283
  while (true) {
1273
- auto slots_it = slot_lookup_.find(key_and_slot_t::any_slot(from));
1274
- if (slots_it == slot_lookup_.end())
1284
+ key_and_slot_t key_and_slot_removed;
1285
+ if (!slot_lookup_.pop_first(key_and_slot_t::any_slot(from), key_and_slot_removed))
1275
1286
  break;
1276
1287
 
1277
- compressed_slot_t slot = (*slots_it).slot;
1278
- key_and_slot_t key_and_slot{to, slot};
1279
- slot_lookup_.erase(slots_it);
1280
- slot_lookup_.insert(key_and_slot);
1281
- typed_->at(slot).key = to;
1288
+ key_and_slot_t key_and_slot_replacing{to, key_and_slot_removed.slot};
1289
+ slot_lookup_.try_emplace(key_and_slot_replacing); // This can't fail
1290
+ typed_->at(key_and_slot_removed.slot).key = to;
1282
1291
  ++result.completed;
1283
1292
  }
1284
1293
 
@@ -1293,11 +1302,18 @@ class index_dense_gt {
1293
1302
  */
1294
1303
  void export_keys(key_t* keys, std::size_t offset, std::size_t limit) const {
1295
1304
  shared_lock_t lock(slot_lookup_mutex_);
1296
- auto it = slot_lookup_.begin();
1297
1305
  offset = (std::min)(offset, slot_lookup_.size());
1298
- std::advance(it, offset);
1299
- for (; it != slot_lookup_.end() && limit; ++it, ++keys, --limit)
1300
- *keys = (*it).key;
1306
+ slot_lookup_.for_each([&](key_and_slot_t const& key_and_slot) {
1307
+ if (offset) {
1308
+ --offset;
1309
+ return;
1310
+ }
1311
+ if (limit) {
1312
+ *keys = key_and_slot.key;
1313
+ ++keys;
1314
+ --limit;
1315
+ }
1316
+ });
1301
1317
  }
1302
1318
 
1303
1319
  struct copy_result_t {
@@ -1720,7 +1736,7 @@ class index_dense_gt {
1720
1736
  bool reuse_node = free_slot != default_free_value<compressed_slot_t>();
1721
1737
  auto on_success = [&](member_ref_t member) {
1722
1738
  unique_lock_t slot_lock(slot_lookup_mutex_);
1723
- slot_lookup_.insert(key_and_slot_t{key, static_cast<compressed_slot_t>(member.slot)});
1739
+ slot_lookup_.try_emplace(key_and_slot_t{key, static_cast<compressed_slot_t>(member.slot)});
1724
1740
  if (copy_vector) {
1725
1741
  if (!reuse_node)
1726
1742
  vectors_lookup_[member.slot] = vectors_tape_allocator_.allocate(metric_.bytes_per_vector());
@@ -1842,19 +1858,23 @@ class index_dense_gt {
1842
1858
  count_removed += member.key == free_key_;
1843
1859
  }
1844
1860
 
1861
+ if (!count_removed && !config_.enable_key_lookups)
1862
+ return;
1863
+
1845
1864
  // Pull entries from the underlying `typed_` into either
1846
1865
  // into `slot_lookup_`, or `free_keys_` if they are unused.
1847
1866
  unique_lock_t lock(slot_lookup_mutex_);
1848
1867
  slot_lookup_.clear();
1849
- slot_lookup_.reserve(count_total - count_removed);
1868
+ if (config_.enable_key_lookups)
1869
+ slot_lookup_.reserve(count_total - count_removed);
1850
1870
  free_keys_.clear();
1851
1871
  free_keys_.reserve(count_removed);
1852
1872
  for (std::size_t i = 0; i != typed_->size(); ++i) {
1853
1873
  member_cref_t member = typed_->at(i);
1854
1874
  if (member.key == free_key_)
1855
1875
  free_keys_.push(static_cast<compressed_slot_t>(i));
1856
- else
1857
- slot_lookup_.insert(key_and_slot_t{key_t(member.key), static_cast<compressed_slot_t>(i)});
1876
+ else if (config_.enable_key_lookups)
1877
+ slot_lookup_.try_emplace(key_and_slot_t{key_t(member.key), static_cast<compressed_slot_t>(i)});
1858
1878
  }
1859
1879
  }
1860
1880