soupdb-vector 0.1.0__py3-none-any.whl
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.
- soupdb_vector/__init__.py +3 -0
- soupdb_vector/indexes/__init__.py +18 -0
- soupdb_vector/indexes/_record_file.py +154 -0
- soupdb_vector/indexes/brute_force.py +68 -0
- soupdb_vector/indexes/constants.py +34 -0
- soupdb_vector/indexes/hnsw.py +372 -0
- soupdb_vector/indexes/ivf.py +378 -0
- soupdb_vector/indexes/kmeans.py +105 -0
- soupdb_vector/indexes/metrics.py +76 -0
- soupdb_vector/indexes/sql_factory.py +235 -0
- soupdb_vector/indexes/store.py +144 -0
- soupdb_vector/multimedia/__init__.py +1 -0
- soupdb_vector/multimedia/audio_features.py +132 -0
- soupdb_vector/multimedia/build_vectors.py +190 -0
- soupdb_vector/multimedia/codebook.py +94 -0
- soupdb_vector/multimedia/descriptor_cache.py +52 -0
- soupdb_vector/multimedia/image_features.py +77 -0
- soupdb_vector/multimedia/manifest.py +111 -0
- soupdb_vector/multimedia/sql_profiles.py +145 -0
- soupdb_vector/multimedia/vector_file.py +88 -0
- soupdb_vector/mutable.py +99 -0
- soupdb_vector/plugin.py +145 -0
- soupdb_vector-0.1.0.dist-info/METADATA +13 -0
- soupdb_vector-0.1.0.dist-info/RECORD +26 -0
- soupdb_vector-0.1.0.dist-info/WHEEL +4 -0
- soupdb_vector-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Vector indexes for Parte 4: paged vector store, brute force, IVF and HNSW."""
|
|
2
|
+
|
|
3
|
+
from soupdb_vector.indexes.brute_force import BruteForceIndex
|
|
4
|
+
from soupdb_vector.indexes.hnsw import HNSWIndex
|
|
5
|
+
from soupdb_vector.indexes.ivf import IVFIndex
|
|
6
|
+
from soupdb_vector.indexes.metrics import COSINE, EUCLIDEAN, INNER_PRODUCT, METRICS
|
|
7
|
+
from soupdb_vector.indexes.store import VectorStore
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"COSINE",
|
|
11
|
+
"EUCLIDEAN",
|
|
12
|
+
"INNER_PRODUCT",
|
|
13
|
+
"METRICS",
|
|
14
|
+
"BruteForceIndex",
|
|
15
|
+
"HNSWIndex",
|
|
16
|
+
"IVFIndex",
|
|
17
|
+
"VectorStore",
|
|
18
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Array of fixed-size records over pages of a DiskManager.
|
|
2
|
+
|
|
3
|
+
Page 0 is a header (magic, record size, record count and a free area for the
|
|
4
|
+
owner's metadata); record ``i`` lives at page ``1 + i // per_page``. Fixed
|
|
5
|
+
size lets a record be addressed by arithmetic instead of a slot directory,
|
|
6
|
+
which is what vectors and HNSW adjacency lists need.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import struct
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
from engine.storage.buffer_manager import BufferManager
|
|
13
|
+
from engine.storage.disk_manager import DiskManager
|
|
14
|
+
|
|
15
|
+
_HEADER = struct.Struct("<4sHIQ")
|
|
16
|
+
_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def peek_meta(buffer_manager: BufferManager, magic: bytes, layout: struct.Struct) -> tuple:
|
|
20
|
+
"""Read the owner's metadata of an existing file before knowing its record size."""
|
|
21
|
+
frame = buffer_manager.pin(0)
|
|
22
|
+
try:
|
|
23
|
+
stored_magic = _HEADER.unpack_from(frame, 0)[0]
|
|
24
|
+
values = layout.unpack_from(frame, _HEADER.size)
|
|
25
|
+
finally:
|
|
26
|
+
buffer_manager.unpin(0)
|
|
27
|
+
if stored_magic != magic:
|
|
28
|
+
raise ValueError(f"not a {magic!r} file")
|
|
29
|
+
return values
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FixedRecordFile:
|
|
33
|
+
"""Append-only array of fixed-size records, read and written in place."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
disk_manager: DiskManager,
|
|
38
|
+
buffer_manager: BufferManager,
|
|
39
|
+
*,
|
|
40
|
+
record_size: int,
|
|
41
|
+
magic: bytes,
|
|
42
|
+
) -> None:
|
|
43
|
+
page_size = disk_manager.page_size
|
|
44
|
+
if record_size <= 0 or record_size > page_size:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"record of {record_size} bytes does not fit in a {page_size}-byte page"
|
|
47
|
+
)
|
|
48
|
+
if len(magic) != 4:
|
|
49
|
+
raise ValueError("magic must be exactly 4 bytes")
|
|
50
|
+
|
|
51
|
+
self._disk = disk_manager
|
|
52
|
+
self._buffer = buffer_manager
|
|
53
|
+
self._record_size = record_size
|
|
54
|
+
self._per_page = page_size // record_size
|
|
55
|
+
self._magic = magic
|
|
56
|
+
|
|
57
|
+
if disk_manager.page_count == 0:
|
|
58
|
+
disk_manager.allocate_page()
|
|
59
|
+
self._count = 0
|
|
60
|
+
self._write_header()
|
|
61
|
+
else:
|
|
62
|
+
frame = buffer_manager.pin(0)
|
|
63
|
+
try:
|
|
64
|
+
stored_magic, version, stored_size, count = _HEADER.unpack_from(frame, 0)
|
|
65
|
+
finally:
|
|
66
|
+
buffer_manager.unpin(0)
|
|
67
|
+
if stored_magic != magic or version != _VERSION:
|
|
68
|
+
raise ValueError(f"{disk_manager.path} is not a {magic!r} file")
|
|
69
|
+
if stored_size != record_size:
|
|
70
|
+
raise ValueError(f"record size on disk is {stored_size}, expected {record_size}")
|
|
71
|
+
self._count = count
|
|
72
|
+
|
|
73
|
+
def __len__(self) -> int:
|
|
74
|
+
return self._count
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def per_page(self) -> int:
|
|
78
|
+
return self._per_page
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def meta_capacity(self) -> int:
|
|
82
|
+
return self._disk.page_size - _HEADER.size
|
|
83
|
+
|
|
84
|
+
def read_meta(self, layout: struct.Struct) -> tuple:
|
|
85
|
+
frame = self._buffer.pin(0)
|
|
86
|
+
try:
|
|
87
|
+
return layout.unpack_from(frame, _HEADER.size)
|
|
88
|
+
finally:
|
|
89
|
+
self._buffer.unpin(0)
|
|
90
|
+
|
|
91
|
+
def write_meta(self, layout: struct.Struct, *values: object) -> None:
|
|
92
|
+
if layout.size > self.meta_capacity:
|
|
93
|
+
raise ValueError("metadata does not fit in the header page")
|
|
94
|
+
frame = self._buffer.pin(0)
|
|
95
|
+
layout.pack_into(frame, _HEADER.size, *values)
|
|
96
|
+
self._buffer.unpin(0, dirty=True)
|
|
97
|
+
|
|
98
|
+
def append(self, layout: struct.Struct, *values: object) -> int:
|
|
99
|
+
index = self._count
|
|
100
|
+
page_id, offset = self._locate(index)
|
|
101
|
+
if page_id == self._disk.page_count:
|
|
102
|
+
self._disk.allocate_page()
|
|
103
|
+
frame = self._buffer.pin(page_id)
|
|
104
|
+
layout.pack_into(frame, offset, *values)
|
|
105
|
+
self._buffer.unpin(page_id, dirty=True)
|
|
106
|
+
self._count += 1
|
|
107
|
+
self._write_header()
|
|
108
|
+
return index
|
|
109
|
+
|
|
110
|
+
def unpack(self, index: int, layout: struct.Struct, extra: int = 0) -> tuple:
|
|
111
|
+
"""Unpack ``layout`` at byte ``extra`` of record ``index``."""
|
|
112
|
+
page_id, offset = self._locate(self._check(index))
|
|
113
|
+
frame = self._buffer.pin(page_id)
|
|
114
|
+
try:
|
|
115
|
+
return layout.unpack_from(frame, offset + extra)
|
|
116
|
+
finally:
|
|
117
|
+
self._buffer.unpin(page_id)
|
|
118
|
+
|
|
119
|
+
def pack(self, index: int, layout: struct.Struct, *values: object, extra: int = 0) -> None:
|
|
120
|
+
page_id, offset = self._locate(self._check(index))
|
|
121
|
+
frame = self._buffer.pin(page_id)
|
|
122
|
+
layout.pack_into(frame, offset + extra, *values)
|
|
123
|
+
self._buffer.unpin(page_id, dirty=True)
|
|
124
|
+
|
|
125
|
+
def scan(self, layout: struct.Struct) -> Iterator[tuple[int, tuple]]:
|
|
126
|
+
"""Yield (index, values) for every record, pinning each page once."""
|
|
127
|
+
index = 0
|
|
128
|
+
page_id = 1
|
|
129
|
+
while index < self._count:
|
|
130
|
+
frame = self._buffer.pin(page_id)
|
|
131
|
+
try:
|
|
132
|
+
in_page = min(self._per_page, self._count - index)
|
|
133
|
+
for slot in range(in_page):
|
|
134
|
+
yield index + slot, layout.unpack_from(frame, slot * self._record_size)
|
|
135
|
+
finally:
|
|
136
|
+
self._buffer.unpin(page_id)
|
|
137
|
+
index += in_page
|
|
138
|
+
page_id += 1
|
|
139
|
+
|
|
140
|
+
def flush(self) -> None:
|
|
141
|
+
self._buffer.flush_all()
|
|
142
|
+
|
|
143
|
+
def _locate(self, index: int) -> tuple[int, int]:
|
|
144
|
+
return 1 + index // self._per_page, (index % self._per_page) * self._record_size
|
|
145
|
+
|
|
146
|
+
def _check(self, index: int) -> int:
|
|
147
|
+
if not 0 <= index < self._count:
|
|
148
|
+
raise IndexError(f"record {index} out of range (count {self._count})")
|
|
149
|
+
return index
|
|
150
|
+
|
|
151
|
+
def _write_header(self) -> None:
|
|
152
|
+
frame = self._buffer.pin(0)
|
|
153
|
+
_HEADER.pack_into(frame, 0, self._magic, _VERSION, self._record_size, self._count)
|
|
154
|
+
self._buffer.unpin(0, dirty=True)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Exact k-NN by scanning every stored vector.
|
|
2
|
+
|
|
3
|
+
It is the sequential baseline of the comparison 2.4.4 and the oracle the
|
|
4
|
+
recall of IVF and HNSW is measured against.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import heapq
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from engine.common.rid import RID
|
|
13
|
+
from engine.indexes.base import RankedIndex
|
|
14
|
+
from soupdb_vector.indexes.constants import DEFAULT_METRIC, VECTOR_BUFFER_CAPACITY
|
|
15
|
+
from soupdb_vector.indexes.metrics import norm, score_fn
|
|
16
|
+
from soupdb_vector.indexes.store import VectorStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BruteForceIndex(RankedIndex):
|
|
20
|
+
"""RankedIndex that scores every vector; memory is O(k), not O(N)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, store: VectorStore) -> None:
|
|
23
|
+
self._store = store
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def open(
|
|
27
|
+
cls,
|
|
28
|
+
path: str | Path,
|
|
29
|
+
dim: int | None = None,
|
|
30
|
+
*,
|
|
31
|
+
page_size: int = 4096,
|
|
32
|
+
buffer_capacity: int = VECTOR_BUFFER_CAPACITY,
|
|
33
|
+
) -> "BruteForceIndex":
|
|
34
|
+
return cls(
|
|
35
|
+
VectorStore.open(path, dim, page_size=page_size, buffer_capacity=buffer_capacity)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def store(self) -> VectorStore:
|
|
40
|
+
return self._store
|
|
41
|
+
|
|
42
|
+
def __len__(self) -> int:
|
|
43
|
+
return len(self._store)
|
|
44
|
+
|
|
45
|
+
def insert(self, vector: Sequence[float], rid: RID) -> None:
|
|
46
|
+
self._store.append(vector, rid)
|
|
47
|
+
|
|
48
|
+
def top_k(
|
|
49
|
+
self, query: Sequence[float], k: int, *, metric: str = DEFAULT_METRIC
|
|
50
|
+
) -> list[tuple[RID, float]]:
|
|
51
|
+
if k < 0:
|
|
52
|
+
raise ValueError(f"k must be non-negative, got {k}")
|
|
53
|
+
self._store.check_dim(query)
|
|
54
|
+
scorer = score_fn(metric)
|
|
55
|
+
query_norm = norm(query)
|
|
56
|
+
scored = (
|
|
57
|
+
(scorer(query, query_norm, vector, vector_norm), rid)
|
|
58
|
+
for _vid, rid, vector, vector_norm in self._store.scan()
|
|
59
|
+
)
|
|
60
|
+
best = heapq.nlargest(k, scored, key=_by_score)
|
|
61
|
+
return [(rid, value) for value, rid in best]
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
self._store.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _by_score(pair: tuple[float, Any]) -> float:
|
|
68
|
+
return pair[0]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Tunable parameters of the vector indexes.
|
|
2
|
+
|
|
3
|
+
Kept apart so the benchmarks can vary them without touching the structures.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
#: Métrica por defecto cuando la consulta no indica ``metric``.
|
|
7
|
+
DEFAULT_METRIC = "euclidean"
|
|
8
|
+
|
|
9
|
+
#: Páginas en el buffer pool de cada archivo cuando el índice abre sus propios managers.
|
|
10
|
+
VECTOR_BUFFER_CAPACITY = 64
|
|
11
|
+
|
|
12
|
+
#: Número de listas (centroides) del IVF; la regla habitual es ~sqrt(N).
|
|
13
|
+
IVF_NLIST = 32
|
|
14
|
+
|
|
15
|
+
#: Listas que se visitan por consulta.
|
|
16
|
+
IVF_NPROBE = 4
|
|
17
|
+
|
|
18
|
+
#: Iteraciones de Lloyd al entrenar los centroides.
|
|
19
|
+
IVF_KMEANS_ITERATIONS = 20
|
|
20
|
+
|
|
21
|
+
#: Tope de la muestra con la que se entrena K-Means; acota la memoria del entrenamiento.
|
|
22
|
+
IVF_TRAIN_SAMPLE = 10_000
|
|
23
|
+
|
|
24
|
+
#: Vecinos por nodo en las capas superiores de HNSW; la capa 0 guarda el doble.
|
|
25
|
+
HNSW_M = 16
|
|
26
|
+
|
|
27
|
+
#: Tamaño de la lista de candidatos al insertar.
|
|
28
|
+
HNSW_EF_CONSTRUCTION = 100
|
|
29
|
+
|
|
30
|
+
#: Tamaño de la lista de candidatos al consultar.
|
|
31
|
+
HNSW_EF_SEARCH = 64
|
|
32
|
+
|
|
33
|
+
#: Tope de niveles del grafo; con M=16 un nivel 16 es astronómicamente improbable.
|
|
34
|
+
HNSW_MAX_LEVEL = 16
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Hierarchical Navigable Small World graph persisted on pages (Malkov & Yashunin).
|
|
2
|
+
|
|
3
|
+
The index is a directory with three files:
|
|
4
|
+
|
|
5
|
+
- ``vectors.db``: a :class:`VectorStore`; the vid of a vector is its node id.
|
|
6
|
+
- ``layer0.db``: one fixed record per node with its top level, where its upper
|
|
7
|
+
layers start in ``upper.db`` and its up to ``2*M`` neighbors in layer 0.
|
|
8
|
+
- ``upper.db``: one fixed record per (node, layer >= 1) with up to ``M``
|
|
9
|
+
neighbors. A node of level ``L`` owns ``L`` consecutive records.
|
|
10
|
+
|
|
11
|
+
Fixed-size records make every neighbor list addressable by arithmetic, so a
|
|
12
|
+
hop of the greedy search costs one page pin.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import heapq
|
|
16
|
+
import math
|
|
17
|
+
import random
|
|
18
|
+
import struct
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from engine.common.rid import RID
|
|
23
|
+
from engine.indexes.base import RankedIndex
|
|
24
|
+
from engine.storage.buffer_manager import BufferManager
|
|
25
|
+
from engine.storage.disk_manager import DiskManager
|
|
26
|
+
from soupdb_vector.indexes._record_file import FixedRecordFile, peek_meta
|
|
27
|
+
from soupdb_vector.indexes.constants import (
|
|
28
|
+
DEFAULT_METRIC,
|
|
29
|
+
HNSW_EF_CONSTRUCTION,
|
|
30
|
+
HNSW_EF_SEARCH,
|
|
31
|
+
HNSW_M,
|
|
32
|
+
HNSW_MAX_LEVEL,
|
|
33
|
+
VECTOR_BUFFER_CAPACITY,
|
|
34
|
+
)
|
|
35
|
+
from soupdb_vector.indexes.metrics import METRICS, ScoreFn, norm, score_fn
|
|
36
|
+
from soupdb_vector.indexes.store import VectorStore
|
|
37
|
+
|
|
38
|
+
_LAYER0_MAGIC = b"SHN0"
|
|
39
|
+
_UPPER_MAGIC = b"SHNU"
|
|
40
|
+
#: M, ef_construction, metric, seed, entry point, max level.
|
|
41
|
+
_META = struct.Struct("<HHBQqi")
|
|
42
|
+
_NODE_HEAD = struct.Struct("<BqH")
|
|
43
|
+
_UPPER_HEAD = struct.Struct("<H")
|
|
44
|
+
_NO_NODE = -1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HNSWIndex(RankedIndex):
|
|
48
|
+
"""HNSW over pages; every node, vector and neighbor list lives on disk."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
path: str | Path,
|
|
53
|
+
dim: int | None = None,
|
|
54
|
+
*,
|
|
55
|
+
m: int = HNSW_M,
|
|
56
|
+
ef_construction: int = HNSW_EF_CONSTRUCTION,
|
|
57
|
+
metric: str = DEFAULT_METRIC,
|
|
58
|
+
seed: int = 0,
|
|
59
|
+
page_size: int = 4096,
|
|
60
|
+
buffer_capacity: int = VECTOR_BUFFER_CAPACITY,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Open (or create) the index stored in the directory ``path``.
|
|
63
|
+
|
|
64
|
+
``m``, ``ef_construction``, ``metric`` and ``seed`` only apply when the
|
|
65
|
+
index is created; reopening uses the values stored on disk.
|
|
66
|
+
"""
|
|
67
|
+
directory = Path(path)
|
|
68
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self._closed = False
|
|
70
|
+
self._disks: list[DiskManager] = []
|
|
71
|
+
|
|
72
|
+
vectors_disk = self._disk(directory / "vectors.db", page_size)
|
|
73
|
+
self._store = VectorStore(
|
|
74
|
+
vectors_disk, BufferManager(vectors_disk, capacity=buffer_capacity), dim
|
|
75
|
+
)
|
|
76
|
+
layer0_disk = self._disk(directory / "layer0.db", page_size)
|
|
77
|
+
layer0_buffer = BufferManager(layer0_disk, capacity=buffer_capacity)
|
|
78
|
+
|
|
79
|
+
if layer0_disk.page_count > 0:
|
|
80
|
+
m, ef_construction, metric_code, seed, entry, max_level = peek_meta(
|
|
81
|
+
layer0_buffer, _LAYER0_MAGIC, _META
|
|
82
|
+
)
|
|
83
|
+
metric = METRICS[metric_code]
|
|
84
|
+
else:
|
|
85
|
+
if m < 2:
|
|
86
|
+
raise ValueError(f"m must be at least 2, got {m}")
|
|
87
|
+
score_fn(metric)
|
|
88
|
+
entry, max_level = _NO_NODE, -1
|
|
89
|
+
|
|
90
|
+
self._m = m
|
|
91
|
+
self._m0 = 2 * m
|
|
92
|
+
self._ef_construction = ef_construction
|
|
93
|
+
self._metric = metric
|
|
94
|
+
self._seed = seed
|
|
95
|
+
self._entry = entry
|
|
96
|
+
self._max_level = max_level
|
|
97
|
+
self._insert_cache: dict[int, tuple[tuple[float, ...], float]] | None = None
|
|
98
|
+
# mL = 1/ln(M): así cada nivel tiene ~1/M de los nodos del nivel de abajo.
|
|
99
|
+
self._level_mult = 1.0 / math.log(m)
|
|
100
|
+
|
|
101
|
+
self._layer0_record = struct.Struct(f"{_NODE_HEAD.format}{self._m0}q")
|
|
102
|
+
self._upper_record = struct.Struct(f"{_UPPER_HEAD.format}{m}q")
|
|
103
|
+
self._layer0 = FixedRecordFile(
|
|
104
|
+
layer0_disk,
|
|
105
|
+
layer0_buffer,
|
|
106
|
+
record_size=self._layer0_record.size,
|
|
107
|
+
magic=_LAYER0_MAGIC,
|
|
108
|
+
)
|
|
109
|
+
upper_disk = self._disk(directory / "upper.db", page_size)
|
|
110
|
+
self._upper = FixedRecordFile(
|
|
111
|
+
upper_disk,
|
|
112
|
+
BufferManager(upper_disk, capacity=buffer_capacity),
|
|
113
|
+
record_size=self._upper_record.size,
|
|
114
|
+
magic=_UPPER_MAGIC,
|
|
115
|
+
)
|
|
116
|
+
self._write_meta()
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def dim(self) -> int:
|
|
120
|
+
return self._store.dim
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def metric(self) -> str:
|
|
124
|
+
return self._metric
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def max_level(self) -> int:
|
|
128
|
+
return self._max_level
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def disk_managers(self) -> tuple[DiskManager, ...]:
|
|
132
|
+
return tuple(self._disks)
|
|
133
|
+
|
|
134
|
+
def __len__(self) -> int:
|
|
135
|
+
return len(self._store)
|
|
136
|
+
|
|
137
|
+
def insert(self, vector: Sequence[float], rid: RID) -> None:
|
|
138
|
+
# Una inserción vuelve a puntuar los mismos vecinos al elegir y al podar listas;
|
|
139
|
+
# la caché vive solo durante esta inserción, así que la memoria no crece con N.
|
|
140
|
+
self._insert_cache = {}
|
|
141
|
+
try:
|
|
142
|
+
self._insert(vector, rid)
|
|
143
|
+
finally:
|
|
144
|
+
self._insert_cache = None
|
|
145
|
+
|
|
146
|
+
def _insert(self, vector: Sequence[float], rid: RID) -> None:
|
|
147
|
+
node = self._store.append(vector, rid)
|
|
148
|
+
query, query_norm = self._vector(node)
|
|
149
|
+
level = self._random_level(node)
|
|
150
|
+
|
|
151
|
+
upper_start = len(self._upper)
|
|
152
|
+
for _ in range(level):
|
|
153
|
+
self._upper.append(self._upper_record, 0, *self._padded([], self._m))
|
|
154
|
+
self._layer0.append(self._layer0_record, level, upper_start, 0, *self._padded([], self._m0))
|
|
155
|
+
|
|
156
|
+
if self._entry == _NO_NODE:
|
|
157
|
+
self._entry, self._max_level = node, level
|
|
158
|
+
self._write_meta()
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
scorer = score_fn(self._metric)
|
|
162
|
+
entry_points = [(self._score(scorer, query, query_norm, self._entry), self._entry)]
|
|
163
|
+
for layer in range(self._max_level, level, -1):
|
|
164
|
+
entry_points = self._search_layer(scorer, query, query_norm, entry_points, 1, layer)
|
|
165
|
+
|
|
166
|
+
for layer in range(min(level, self._max_level), -1, -1):
|
|
167
|
+
candidates = self._search_layer(
|
|
168
|
+
scorer, query, query_norm, entry_points, self._ef_construction, layer
|
|
169
|
+
)
|
|
170
|
+
capacity = self._capacity(layer)
|
|
171
|
+
chosen = self._select_neighbors(scorer, candidates, capacity)
|
|
172
|
+
self._write_neighbors(node, layer, [neighbor for _s, neighbor in chosen])
|
|
173
|
+
for link_score, neighbor in chosen:
|
|
174
|
+
self._link(scorer, neighbor, node, link_score, layer)
|
|
175
|
+
entry_points = candidates
|
|
176
|
+
|
|
177
|
+
if level > self._max_level:
|
|
178
|
+
self._entry, self._max_level = node, level
|
|
179
|
+
self._write_meta()
|
|
180
|
+
|
|
181
|
+
def top_k(
|
|
182
|
+
self,
|
|
183
|
+
query: Sequence[float],
|
|
184
|
+
k: int,
|
|
185
|
+
*,
|
|
186
|
+
metric: str | None = None,
|
|
187
|
+
ef: int = HNSW_EF_SEARCH,
|
|
188
|
+
) -> list[tuple[RID, float]]:
|
|
189
|
+
if k < 0:
|
|
190
|
+
raise ValueError(f"k must be non-negative, got {k}")
|
|
191
|
+
if ef <= 0:
|
|
192
|
+
raise ValueError(f"ef must be positive, got {ef}")
|
|
193
|
+
self._store.check_dim(query)
|
|
194
|
+
scorer = score_fn(metric or self._metric)
|
|
195
|
+
if k == 0 or self._entry == _NO_NODE:
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
query_norm = norm(query)
|
|
199
|
+
entry_points = [(self._score(scorer, query, query_norm, self._entry), self._entry)]
|
|
200
|
+
for layer in range(self._max_level, 0, -1):
|
|
201
|
+
entry_points = self._search_layer(scorer, query, query_norm, entry_points, 1, layer)
|
|
202
|
+
found = self._search_layer(scorer, query, query_norm, entry_points, max(ef, k), 0)
|
|
203
|
+
return [(self._store.rid(node), value) for value, node in found[:k]]
|
|
204
|
+
|
|
205
|
+
def neighbors(self, node: int, layer: int) -> list[int]:
|
|
206
|
+
"""Neighbor ids of a node in a layer; exposed for tests and the report."""
|
|
207
|
+
if layer == 0:
|
|
208
|
+
values = self._layer0.unpack(node, self._layer0_record)
|
|
209
|
+
return list(values[3 : 3 + values[2]])
|
|
210
|
+
values = self._upper.unpack(self._upper_index(node, layer), self._upper_record)
|
|
211
|
+
return list(values[1 : 1 + values[0]])
|
|
212
|
+
|
|
213
|
+
def level(self, node: int) -> int:
|
|
214
|
+
return self._layer0.unpack(node, _NODE_HEAD)[0]
|
|
215
|
+
|
|
216
|
+
def close(self) -> None:
|
|
217
|
+
if self._closed:
|
|
218
|
+
return
|
|
219
|
+
self._closed = True
|
|
220
|
+
self._write_meta()
|
|
221
|
+
self._store.flush()
|
|
222
|
+
self._layer0.flush()
|
|
223
|
+
self._upper.flush()
|
|
224
|
+
for disk in self._disks:
|
|
225
|
+
disk.close()
|
|
226
|
+
|
|
227
|
+
# --- internals ---------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _disk(self, path: Path, page_size: int) -> DiskManager:
|
|
230
|
+
disk = DiskManager(path, page_size=page_size)
|
|
231
|
+
self._disks.append(disk)
|
|
232
|
+
return disk
|
|
233
|
+
|
|
234
|
+
def _write_meta(self) -> None:
|
|
235
|
+
self._layer0.write_meta(
|
|
236
|
+
_META,
|
|
237
|
+
self._m,
|
|
238
|
+
self._ef_construction,
|
|
239
|
+
METRICS.index(self._metric),
|
|
240
|
+
self._seed,
|
|
241
|
+
self._entry,
|
|
242
|
+
self._max_level,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def _random_level(self, node: int) -> int:
|
|
246
|
+
# Semilla derivada del nodo: el nivel es reproducible aunque se cierre y reabra.
|
|
247
|
+
uniform = random.Random(self._seed * 1_000_003 + node).random()
|
|
248
|
+
return min(int(-math.log(1.0 - uniform) * self._level_mult), HNSW_MAX_LEVEL)
|
|
249
|
+
|
|
250
|
+
def _capacity(self, layer: int) -> int:
|
|
251
|
+
return self._m0 if layer == 0 else self._m
|
|
252
|
+
|
|
253
|
+
def _upper_index(self, node: int, layer: int) -> int:
|
|
254
|
+
level, start, _count = self._layer0.unpack(node, _NODE_HEAD)
|
|
255
|
+
if not 1 <= layer <= level:
|
|
256
|
+
raise ValueError(f"node {node} has no layer {layer}")
|
|
257
|
+
return start + layer - 1
|
|
258
|
+
|
|
259
|
+
def _padded(self, neighbors: list[int], capacity: int) -> list[int]:
|
|
260
|
+
return neighbors + [_NO_NODE] * (capacity - len(neighbors))
|
|
261
|
+
|
|
262
|
+
def _write_neighbors(self, node: int, layer: int, neighbors: list[int]) -> None:
|
|
263
|
+
if layer == 0:
|
|
264
|
+
level, start, _count = self._layer0.unpack(node, _NODE_HEAD)
|
|
265
|
+
self._layer0.pack(
|
|
266
|
+
node,
|
|
267
|
+
self._layer0_record,
|
|
268
|
+
level,
|
|
269
|
+
start,
|
|
270
|
+
len(neighbors),
|
|
271
|
+
*self._padded(neighbors, self._m0),
|
|
272
|
+
)
|
|
273
|
+
return
|
|
274
|
+
record = self._upper_index(node, layer)
|
|
275
|
+
self._upper.pack(
|
|
276
|
+
record, self._upper_record, len(neighbors), *self._padded(neighbors, self._m)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _vector(self, node: int) -> tuple[tuple[float, ...], float]:
|
|
280
|
+
cache = self._insert_cache
|
|
281
|
+
if cache is None:
|
|
282
|
+
return self._store.vector(node)
|
|
283
|
+
cached = cache.get(node)
|
|
284
|
+
if cached is None:
|
|
285
|
+
cached = cache[node] = self._store.vector(node)
|
|
286
|
+
return cached
|
|
287
|
+
|
|
288
|
+
def _score(
|
|
289
|
+
self, scorer: ScoreFn, query: Sequence[float], query_norm: float, node: int
|
|
290
|
+
) -> float:
|
|
291
|
+
vector, vector_norm = self._vector(node)
|
|
292
|
+
return scorer(query, query_norm, vector, vector_norm)
|
|
293
|
+
|
|
294
|
+
def _search_layer(
|
|
295
|
+
self,
|
|
296
|
+
scorer: ScoreFn,
|
|
297
|
+
query: Sequence[float],
|
|
298
|
+
query_norm: float,
|
|
299
|
+
entry_points: list[tuple[float, int]],
|
|
300
|
+
ef: int,
|
|
301
|
+
layer: int,
|
|
302
|
+
) -> list[tuple[float, int]]:
|
|
303
|
+
"""Best-first search in one layer; returns up to ef (score, node), best first."""
|
|
304
|
+
visited = {node for _score, node in entry_points}
|
|
305
|
+
# candidates: max-heap por score (negado); results: min-heap con los ef mejores.
|
|
306
|
+
candidates = [(-value, node) for value, node in entry_points]
|
|
307
|
+
heapq.heapify(candidates)
|
|
308
|
+
results = list(entry_points)
|
|
309
|
+
heapq.heapify(results)
|
|
310
|
+
while len(results) > ef:
|
|
311
|
+
heapq.heappop(results)
|
|
312
|
+
|
|
313
|
+
while candidates:
|
|
314
|
+
negated, node = heapq.heappop(candidates)
|
|
315
|
+
if -negated < results[0][0] and len(results) >= ef:
|
|
316
|
+
break
|
|
317
|
+
for neighbor in self.neighbors(node, layer):
|
|
318
|
+
if neighbor in visited:
|
|
319
|
+
continue
|
|
320
|
+
visited.add(neighbor)
|
|
321
|
+
value = self._score(scorer, query, query_norm, neighbor)
|
|
322
|
+
if len(results) < ef or value > results[0][0]:
|
|
323
|
+
heapq.heappush(candidates, (-value, neighbor))
|
|
324
|
+
heapq.heappush(results, (value, neighbor))
|
|
325
|
+
if len(results) > ef:
|
|
326
|
+
heapq.heappop(results)
|
|
327
|
+
return sorted(results, reverse=True)
|
|
328
|
+
|
|
329
|
+
def _select_neighbors(
|
|
330
|
+
self, scorer: ScoreFn, candidates: list[tuple[float, int]], capacity: int
|
|
331
|
+
) -> list[tuple[float, int]]:
|
|
332
|
+
"""Neighbor-selection heuristic (algorithm 4) with keepPrunedConnections.
|
|
333
|
+
|
|
334
|
+
A candidate is kept only if it is closer to the base node than to every
|
|
335
|
+
neighbor already kept; this favors links in different directions, which
|
|
336
|
+
is what keeps clustered data navigable. Pruned ones fill the remaining slots.
|
|
337
|
+
"""
|
|
338
|
+
if len(candidates) <= capacity:
|
|
339
|
+
return list(candidates)
|
|
340
|
+
kept: list[tuple[float, int]] = []
|
|
341
|
+
kept_vectors: list[tuple[Sequence[float], float]] = []
|
|
342
|
+
pruned: list[tuple[float, int]] = []
|
|
343
|
+
for value, node in candidates:
|
|
344
|
+
if len(kept) >= capacity:
|
|
345
|
+
break
|
|
346
|
+
vector, vector_norm = self._vector(node)
|
|
347
|
+
if all(scorer(vector, vector_norm, o, o_norm) < value for o, o_norm in kept_vectors):
|
|
348
|
+
kept.append((value, node))
|
|
349
|
+
kept_vectors.append((vector, vector_norm))
|
|
350
|
+
else:
|
|
351
|
+
pruned.append((value, node))
|
|
352
|
+
for pair in pruned:
|
|
353
|
+
if len(kept) >= capacity:
|
|
354
|
+
break
|
|
355
|
+
kept.append(pair)
|
|
356
|
+
return kept
|
|
357
|
+
|
|
358
|
+
def _link(
|
|
359
|
+
self, scorer: ScoreFn, node: int, new_neighbor: int, link_score: float, layer: int
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Add a back-link node -> new_neighbor, shrinking the list if it overflows."""
|
|
362
|
+
current = self.neighbors(node, layer)
|
|
363
|
+
capacity = self._capacity(layer)
|
|
364
|
+
if len(current) < capacity:
|
|
365
|
+
self._write_neighbors(node, layer, current + [new_neighbor])
|
|
366
|
+
return
|
|
367
|
+
base, base_norm = self._vector(node)
|
|
368
|
+
scored = [(self._score(scorer, base, base_norm, other), other) for other in current]
|
|
369
|
+
scored.append((link_score, new_neighbor))
|
|
370
|
+
scored.sort(reverse=True)
|
|
371
|
+
chosen = self._select_neighbors(scorer, scored, capacity)
|
|
372
|
+
self._write_neighbors(node, layer, [other for _s, other in chosen])
|