epzero 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- epzero-0.5.0/CHANGELOG.md +129 -0
- {epzero-0.4.0 → epzero-0.5.0}/CMakeLists.txt +51 -23
- {epzero-0.4.0 → epzero-0.5.0}/PKG-INFO +96 -7
- {epzero-0.4.0 → epzero-0.5.0}/bench/bench_recall.cpp +3 -1
- epzero-0.5.0/bench/regression.json +58 -0
- epzero-0.5.0/fuzz/fuzz_metadata.cpp +23 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/make_seeds.cpp +12 -2
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/auto.h +39 -0
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/distance.h +6 -0
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/hnsw.h +46 -1
- epzero-0.5.0/include/epzero/metadata.h +92 -0
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/text.h +13 -3
- {epzero-0.4.0 → epzero-0.5.0}/pyproject.toml +6 -3
- {epzero-0.4.0 → epzero-0.5.0}/python/README.md +88 -6
- {epzero-0.4.0 → epzero-0.5.0}/python/epzero/__init__.py +2 -1
- {epzero-0.4.0 → epzero-0.5.0}/python/epzero/auto.py +5 -2
- epzero-0.5.0/python/epzero/haystack.py +283 -0
- epzero-0.5.0/python/epzero/ir.py +257 -0
- {epzero-0.4.0 → epzero-0.5.0}/python/epzero/langchain.py +50 -7
- epzero-0.5.0/python/epzero/llamaindex.py +243 -0
- {epzero-0.4.0 → epzero-0.5.0}/python/epzero_py.cpp +371 -22
- {epzero-0.4.0 → epzero-0.5.0}/python/tests/test_auto.py +76 -0
- {epzero-0.4.0 → epzero-0.5.0}/python/tests/test_epzero.py +78 -2
- epzero-0.5.0/python/tests/test_haystack.py +74 -0
- epzero-0.5.0/python/tests/test_ir.py +128 -0
- {epzero-0.4.0 → epzero-0.5.0}/python/tests/test_langchain.py +17 -0
- epzero-0.5.0/python/tests/test_llamaindex.py +91 -0
- epzero-0.5.0/python/tests/test_metadata.py +67 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/auto.cpp +162 -1
- {epzero-0.4.0 → epzero-0.5.0}/src/brute_force.cpp +6 -1
- {epzero-0.4.0 → epzero-0.5.0}/src/database.cpp +70 -23
- {epzero-0.4.0 → epzero-0.5.0}/src/distance.cpp +22 -21
- epzero-0.5.0/src/distance_f16c.cpp +72 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/distance_neon.cpp +36 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/distance_scalar.cpp +16 -0
- epzero-0.5.0/src/half.h +60 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/hnsw.cpp +233 -37
- {epzero-0.4.0 → epzero-0.5.0}/src/kernels.h +7 -0
- epzero-0.5.0/src/metadata.cpp +295 -0
- epzero-0.5.0/src/platform.h +154 -0
- epzero-0.5.0/src/porter.h +233 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/text.cpp +36 -8
- {epzero-0.4.0 → epzero-0.5.0}/tests/auto_test.cpp +129 -0
- epzero-0.5.0/tests/data/v4-from-0.4.0.epzero +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/tests/database_test.cpp +2 -2
- epzero-0.5.0/tests/half_test.cpp +157 -0
- {epzero-0.4.0 → epzero-0.5.0}/tests/hnsw_test.cpp +66 -0
- epzero-0.5.0/tests/metadata_test.cpp +116 -0
- {epzero-0.4.0 → epzero-0.5.0}/tests/text_test.cpp +32 -0
- {epzero-0.4.0 → epzero-0.5.0}/wasm/epzero_wasm.cpp +52 -2
- epzero-0.4.0/CHANGELOG.md +0 -54
- {epzero-0.4.0 → epzero-0.5.0}/bench/bench_adaptive.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/bench/bench_churn.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/bench/bench_distance.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/bench/bench_filter.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/bench/bench_guard.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/common.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/fuzz_graph.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/fuzz_snapshot.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/fuzz_text.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/fuzz_wal.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/regressions/graph-empty-with-entry-point +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/regressions/graph-entry-level-overflow +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/regressions/graph-upper-link-to-base-node +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/regressions/snapshot-empty-with-entry-point +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/fuzz/regressions/snapshot-huge-capacity +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/brute_force.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/database.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/dataset.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/parallel.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/include/epzero/recall.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/server/node_main.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/dataset.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/distance_avx2.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/distance_avx512.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/distance_avx512vnni.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/distance_avxvnni.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/distance_wasm.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/pca.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/rabitq.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/recall.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/src/visited_pool.h +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/tests/dataset_test.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/tests/distance_test.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/tests/recall_test.cpp +0 -0
- {epzero-0.4.0 → epzero-0.5.0}/tools/export_graph.cpp +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
An index that stays accurate as its data changes, fixed in place; metadata and keyword search in the
|
|
6
|
+
engine; LlamaIndex and Haystack beside LangChain; retrieval evaluation; and Windows. Files written by
|
|
7
|
+
0.4 load unchanged.
|
|
8
|
+
|
|
9
|
+
**Staying accurate after the build.**
|
|
10
|
+
|
|
11
|
+
- `index.recalibrate(queries)` (C++: `epzero::recalibrate`): re-measures the beam width for the
|
|
12
|
+
recall target on real queries and saves it in the profile, without rebuilding.
|
|
13
|
+
- `index.health()` (C++: `check_health`): live and deleted vectors, growth since calibration, and how
|
|
14
|
+
much of the data the principal directions still capture, with advice in plain words: compact,
|
|
15
|
+
recalibrate, or rebuild.
|
|
16
|
+
- `RecallGuard(index)` takes its target and starting beam width from the profile.
|
|
17
|
+
- `index.check_queries(queries)` (C++: `check_queries`) says whether queries look like the data: text
|
|
18
|
+
queries against image vectors sit 2.2-2.3× farther from their nearest neighbours than rows of the data,
|
|
19
|
+
against 0.99-1.09× for queries like the data, and recall calibrated on the data then falls to 0.74-0.80
|
|
20
|
+
for a 0.95 target. Its advice: `recalibrate(queries)`, which restored 0.945-0.958 on VIBE's
|
|
21
|
+
text-to-image sets.
|
|
22
|
+
- `index.requantize()` (C++: `HnswIndex::requantize`, then `refine_links`): for vectors added since the
|
|
23
|
+
build that lie outside the ranges the 8-bit codes were fitted on. It refits the ranges on the live
|
|
24
|
+
vectors, re-encodes them and reconnects the graph, in about half a rebuild's time; after a 4 sd drift
|
|
25
|
+
it brought the beam width for recall 0.95 from 200 back to a rebuild's 12. `health()` reports
|
|
26
|
+
`code_error` and advises it above 0.01.
|
|
27
|
+
- `index.get_vector(label)` returns a stored vector.
|
|
28
|
+
|
|
29
|
+
**Information retrieval.** `epzero.ir`: a `Collection` of documents with string ids, retrieved by vector,
|
|
30
|
+
by keyword (BM25) or both (reciprocal rank fusion), growing and shrinking after the build; TREC run files
|
|
31
|
+
in and out; relevance judgements in TREC or BEIR form; nDCG@k, MAP, recall, precision and MRR, matching
|
|
32
|
+
trec_eval (tested against it); and a BEIR dataset loader.
|
|
33
|
+
|
|
34
|
+
**Stemming.** `TextIndex` reduces English words to their Porter stems, so "connections" matches
|
|
35
|
+
"connected" (`stemmer="none"` keeps words as written). It is the reference implementation's algorithm,
|
|
36
|
+
the one Lucene and Anserini use, and agrees with NLTK's on all 39,011 words of SciFact's and NFCorpus's
|
|
37
|
+
vocabularies. nDCG@10 for BM25 went from 0.660 to 0.688 on SciFact and from 0.305 to 0.321 on NFCorpus
|
|
38
|
+
(BEIR's Anserini baselines: 0.665 and 0.325), and for hybrid search with all-MiniLM-L6-v2 from 0.682 to
|
|
39
|
+
0.713 and from 0.344 to 0.354. Stemmed text indexes are saved as text format 2; files saved before load
|
|
40
|
+
unstemmed, as they were written.
|
|
41
|
+
|
|
42
|
+
**Fixes.** `hybrid_search` searched with a fixed beam width of 64 and the default re-rank, ignoring the
|
|
43
|
+
index's calibrated profile; it now uses the profile unless `ef` is given. Calibration could settle on a
|
|
44
|
+
beam width below k, which only k-sized searches had measured.
|
|
45
|
+
|
|
46
|
+
**File format 5.** Indexes with a profile or 16-bit vectors are written as version 5; 0.4's version 4
|
|
47
|
+
files are still read (a file written by 0.4.0 is part of the test suite). See `docs/file-format.md`.
|
|
48
|
+
|
|
49
|
+
**Indexes grow.** `add()` no longer fails when an index is full: it grows the capacity (at least
|
|
50
|
+
doubling), copying the nodes and keeping the graph; `index.reserve(n)` and `index.capacity` do it ahead.
|
|
51
|
+
The LangChain, LlamaIndex and Haystack stores, which rebuilt their whole graph each time they filled, now
|
|
52
|
+
grow in place: loading 40,000 documents in batches of 100 went from 6.0 s to 3.4 s. Searches on other
|
|
53
|
+
threads wait for a growth, which moves the index's memory; with a RecallGuard attached, growing must be
|
|
54
|
+
done beforehand with `reserve()`.
|
|
55
|
+
|
|
56
|
+
**Metadata in the engine.** `epzero.Metadata` (C++: `MetadataIndex`) keeps str, int, float and bool fields
|
|
57
|
+
per label, with an inverted index per field and ordered numeric values, and `select(where)` returns the
|
|
58
|
+
labels that pass ({field: value}, {field: [values]}, or `$eq`, `$ne`, `$in`, `$nin`, `$gt`, `$gte`, `$lt`,
|
|
59
|
+
`$lte`, `$exists`) for a filtered search. The LangChain store now runs dict filters there instead of
|
|
60
|
+
checking every document in Python. Saved in its own file format, fuzzed in CI.
|
|
61
|
+
|
|
62
|
+
**Memory.** `half_vectors=True` keeps the full vectors as 16-bit floats: about half an index's memory,
|
|
63
|
+
at some speed (up to 17% on GIST1M) and recall (0.003-0.004 on OpenAI embeddings), so never chosen
|
|
64
|
+
automatically. Full-precision search is 4-9% faster, as vector addresses are resolved once per query.
|
|
65
|
+
|
|
66
|
+
**LlamaIndex.** `epzero.llamaindex.EpzeroVectorStore` (`pip install "epzero[llamaindex]"`): nodes with
|
|
67
|
+
their text and metadata, filters in the engine, `delete_ref_doc`, and persistence.
|
|
68
|
+
|
|
69
|
+
**Haystack.** `epzero.haystack.EpzeroDocumentStore` and `EpzeroEmbeddingRetriever`
|
|
70
|
+
(`pip install "epzero[haystack]"`): Haystack filters in the engine where the answer is provably the same,
|
|
71
|
+
and Haystack's document-store test suite passes.
|
|
72
|
+
|
|
73
|
+
**Windows.** The engine builds with MSVC and its tests run on Windows in CI; wheels for Windows (x64).
|
|
74
|
+
|
|
75
|
+
**Easier to read.** `Index`, `Metadata`, `TextIndex` and `Database` print what they hold (size, metric,
|
|
76
|
+
compression, calibrated beam width), and shape and name errors say what was passed.
|
|
77
|
+
|
|
78
|
+
## 0.4.0
|
|
79
|
+
|
|
80
|
+
**Automatic configuration.** `epzero.build(vectors, metric=...)` (C++: `epzero::build_auto` in
|
|
81
|
+
`epzero/auto.h`) configures an index from its data and the machine it is built on:
|
|
82
|
+
|
|
83
|
+
- From the data: 8-bit codes, whether to walk the graph on principal directions (at 256 dimensions or
|
|
84
|
+
more, when few directions hold most of the variance), the build passes, the re-rank, and the beam
|
|
85
|
+
width (`ef`) that reaches a recall target, calibrated on rows held out of the graph or on queries
|
|
86
|
+
you pass (`queries=`).
|
|
87
|
+
- From the machine: the prefetch setting, timed on this CPU, and every core.
|
|
88
|
+
- Measured on an Intel Xeon Gold 6226 and an AMD Threadripper PRO 9985WX: as fast as hand-tuned
|
|
89
|
+
settings (within 1-5%), recall 0.949-0.957 on real queries for a 0.95 target, and 1.3-4.1× Glass's
|
|
90
|
+
queries per second on GIST1M, OpenAI DBpedia and DEEP10M.
|
|
91
|
+
- Every setting can be fixed instead (`epzero.build(vectors, nav_dim=0, M=32)`, or `epzero.plan()`,
|
|
92
|
+
edit, build). `index.plan` shows each setting and why it was chosen.
|
|
93
|
+
- The choices are saved inside the index (`IndexProfile`, file format v4), and `search()` uses them
|
|
94
|
+
when called without settings. A loaded index re-applies its prefetch setting only on the same kind
|
|
95
|
+
of CPU (`epzero.machine()` names it).
|
|
96
|
+
|
|
97
|
+
**Faster search and builds.**
|
|
98
|
+
|
|
99
|
+
- `nav_dim`: walk the graph on 8-bit codes of principal directions, re-ranking with the full vectors.
|
|
100
|
+
With the other changes here, 2.8-4.2× Glass's queries per second on GIST1M and 1.6-2.7× on OpenAI
|
|
101
|
+
DBpedia embeddings at equal recall, on Intel and AMD servers.
|
|
102
|
+
- `refine_links()`: a second build pass in which every node searches the finished graph and
|
|
103
|
+
reconnects; `fill_free_links()` gives free link slots to two-hop neighbours.
|
|
104
|
+
- `fast_build`: build on the 8-bit codes.
|
|
105
|
+
- `rerank_z`: stop the re-rank early once the remaining candidates can't reach the top k.
|
|
106
|
+
- Queries are projected onto principal directions on the integer kernel; the AVX2 and AVX-VNNI integer
|
|
107
|
+
kernels finish with vector loads instead of a scalar tail.
|
|
108
|
+
- The prefetch tuner can fetch a record's every cache line; on a Xeon Gold 6226 that was worth up to
|
|
109
|
+
1.7× on some datasets.
|
|
110
|
+
- PCA runs as blocked matrix products (choosing directions 5× faster on GIST1M), training runs on
|
|
111
|
+
every core, and index memory is zeroed on every core, which spreads it across NUMA nodes.
|
|
112
|
+
|
|
113
|
+
**Fixes.**
|
|
114
|
+
|
|
115
|
+
- `Database`: a write that could not be applied (the database full, or not yet trained) was written to
|
|
116
|
+
the log first, after which every open failed replaying it. Writes are now checked before they are
|
|
117
|
+
logged, and replacing a vector in a full database frees its slot before compacting.
|
|
118
|
+
- `RecallGuard` searched, and measured its reference, with the default re-rank depth; it now uses the
|
|
119
|
+
index's own settings and re-ranks the reference's whole beam. `explain()` and `trace()` likewise.
|
|
120
|
+
- The LangChain store copied every stored vector on each add (quadratic in the store's size).
|
|
121
|
+
- `pip install epzero` from source failed on machines with gRPC installed: it tried to build the
|
|
122
|
+
cluster node, whose protocol files aren't in the package.
|
|
123
|
+
|
|
124
|
+
**Compatibility.** Indexes without a profile are still written in the format older versions read
|
|
125
|
+
(v2, or v3 with principal directions); automatically configured ones are v4, which 0.3 cannot load.
|
|
126
|
+
`search()`'s `k`, `ef`, `rerank` and `rerank_z` now default to the index's profile, which for an index
|
|
127
|
+
built by hand is the old defaults (10, 64, 2 × k, off).
|
|
128
|
+
|
|
129
|
+
Earlier releases are described by their git tags (`v0.2.0`, `v0.3.0`).
|
|
@@ -52,6 +52,7 @@ add_library(epzero_engine
|
|
|
52
52
|
src/distance.cpp
|
|
53
53
|
src/distance_scalar.cpp
|
|
54
54
|
src/hnsw.cpp
|
|
55
|
+
src/metadata.cpp
|
|
55
56
|
src/recall.cpp
|
|
56
57
|
src/database.cpp
|
|
57
58
|
src/text.cpp
|
|
@@ -60,20 +61,38 @@ add_library(epzero_engine
|
|
|
60
61
|
)
|
|
61
62
|
target_include_directories(epzero_engine PUBLIC include PRIVATE src)
|
|
62
63
|
target_link_libraries(epzero_engine PUBLIC Threads::Threads)
|
|
63
|
-
|
|
64
|
+
if(MSVC)
|
|
65
|
+
target_compile_options(epzero_engine PRIVATE /W3 /utf-8 /permissive- /Zc:__cplusplus)
|
|
66
|
+
target_compile_definitions(epzero_engine PRIVATE _CRT_SECURE_NO_WARNINGS NOMINMAX)
|
|
67
|
+
else()
|
|
68
|
+
target_compile_options(epzero_engine PRIVATE -Wall -Wextra -Wpedantic)
|
|
69
|
+
endif()
|
|
64
70
|
set_target_properties(epzero_engine PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
|
65
71
|
|
|
66
72
|
# SIMD kernels get ISA flags per file; the rest of the library stays baseline so the binary
|
|
67
73
|
# runs on any CPU of the architecture and picks the best kernel at runtime.
|
|
68
74
|
if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64|amd64")
|
|
69
|
-
target_sources(epzero_engine PRIVATE src/distance_avx2.cpp src/distance_avx512.cpp)
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
target_sources(epzero_engine PRIVATE src/distance_avx2.cpp src/distance_avx512.cpp src/distance_f16c.cpp)
|
|
76
|
+
if(MSVC) # MSVC allows any intrinsic; /arch only lets the compiler use the ISA on its own
|
|
77
|
+
set_source_files_properties(src/distance_f16c.cpp src/distance_avx2.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX2")
|
|
78
|
+
set_source_files_properties(src/distance_avx512.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX512")
|
|
79
|
+
else()
|
|
80
|
+
set_source_files_properties(src/distance_f16c.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
|
|
81
|
+
set_source_files_properties(src/distance_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mpopcnt")
|
|
82
|
+
set_source_files_properties(src/distance_avx512.cpp PROPERTIES COMPILE_OPTIONS "-mavx512f")
|
|
83
|
+
endif()
|
|
72
84
|
# VNNI integer kernels, where the toolchain can build them: the compiler must know the
|
|
73
85
|
# instructions (GCC 11+, Clang 12+ for AVX-VNNI) and so must the assembler it hands them to
|
|
74
86
|
# (binutils 2.36+ for AVX-VNNI; RHEL 9's 2.35 doesn't), so each check compiles and links a use.
|
|
75
87
|
include(CheckCXXSourceCompiles)
|
|
76
|
-
|
|
88
|
+
if(MSVC)
|
|
89
|
+
set(EPZERO_AVXVNNI_FLAGS "/arch:AVX2")
|
|
90
|
+
set(EPZERO_AVX512VNNI_FLAGS "/arch:AVX512")
|
|
91
|
+
else()
|
|
92
|
+
set(EPZERO_AVXVNNI_FLAGS "-mavx2 -mavxvnni")
|
|
93
|
+
set(EPZERO_AVX512VNNI_FLAGS "-mavx512f -mavx512bw -mavx512vnni")
|
|
94
|
+
endif()
|
|
95
|
+
set(CMAKE_REQUIRED_FLAGS "${EPZERO_AVXVNNI_FLAGS}")
|
|
77
96
|
check_cxx_source_compiles([[
|
|
78
97
|
#include <immintrin.h>
|
|
79
98
|
int main(int argc, char**) {
|
|
@@ -81,7 +100,7 @@ if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64|amd64")
|
|
|
81
100
|
a = _mm256_dpbusd_avx_epi32(a, a, a);
|
|
82
101
|
return _mm256_extract_epi32(a, 0);
|
|
83
102
|
}]] EPZERO_TOOLCHAIN_AVXVNNI)
|
|
84
|
-
set(CMAKE_REQUIRED_FLAGS "
|
|
103
|
+
set(CMAKE_REQUIRED_FLAGS "${EPZERO_AVX512VNNI_FLAGS}")
|
|
85
104
|
check_cxx_source_compiles([[
|
|
86
105
|
#include <immintrin.h>
|
|
87
106
|
int main(int argc, char**) {
|
|
@@ -92,12 +111,14 @@ if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64|amd64")
|
|
|
92
111
|
unset(CMAKE_REQUIRED_FLAGS)
|
|
93
112
|
if(EPZERO_TOOLCHAIN_AVXVNNI)
|
|
94
113
|
target_sources(epzero_engine PRIVATE src/distance_avxvnni.cpp)
|
|
95
|
-
|
|
114
|
+
string(REPLACE " " ";" flags "${EPZERO_AVXVNNI_FLAGS}")
|
|
115
|
+
set_source_files_properties(src/distance_avxvnni.cpp PROPERTIES COMPILE_OPTIONS "${flags}")
|
|
96
116
|
set_property(SOURCE src/distance.cpp APPEND PROPERTY COMPILE_DEFINITIONS EPZERO_HAVE_AVXVNNI)
|
|
97
117
|
endif()
|
|
98
118
|
if(EPZERO_TOOLCHAIN_AVX512VNNI)
|
|
99
119
|
target_sources(epzero_engine PRIVATE src/distance_avx512vnni.cpp)
|
|
100
|
-
|
|
120
|
+
string(REPLACE " " ";" flags "${EPZERO_AVX512VNNI_FLAGS}")
|
|
121
|
+
set_source_files_properties(src/distance_avx512vnni.cpp PROPERTIES COMPILE_OPTIONS "${flags}")
|
|
101
122
|
set_property(SOURCE src/distance.cpp APPEND PROPERTY COMPILE_DEFINITIONS EPZERO_HAVE_AVX512VNNI)
|
|
102
123
|
endif()
|
|
103
124
|
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64")
|
|
@@ -119,8 +140,9 @@ if(EPZERO_BUILD_TESTS)
|
|
|
119
140
|
FetchContent_MakeAvailable(googletest)
|
|
120
141
|
|
|
121
142
|
enable_testing()
|
|
122
|
-
add_executable(epzero_tests tests/auto_test.cpp tests/distance_test.cpp tests/hnsw_test.cpp tests/recall_test.cpp tests/dataset_test.cpp tests/database_test.cpp tests/text_test.cpp)
|
|
143
|
+
add_executable(epzero_tests tests/auto_test.cpp tests/distance_test.cpp tests/half_test.cpp tests/hnsw_test.cpp tests/metadata_test.cpp tests/recall_test.cpp tests/dataset_test.cpp tests/database_test.cpp tests/text_test.cpp)
|
|
123
144
|
target_link_libraries(epzero_tests PRIVATE epzero_engine GTest::gtest_main)
|
|
145
|
+
target_compile_definitions(epzero_tests PRIVATE EPZERO_TEST_DATA="${CMAKE_CURRENT_SOURCE_DIR}/tests/data")
|
|
124
146
|
if(EMSCRIPTEN)
|
|
125
147
|
# Runs under Node (CMAKE_CROSSCOMPILING_EMULATOR); workers are pre-spawned for the
|
|
126
148
|
# concurrency tests and the real filesystem backs the save/load tests.
|
|
@@ -146,7 +168,7 @@ if(EPZERO_BUILD_BENCH)
|
|
|
146
168
|
endif()
|
|
147
169
|
|
|
148
170
|
if(EPZERO_BUILD_FUZZ)
|
|
149
|
-
foreach(target snapshot graph wal text)
|
|
171
|
+
foreach(target snapshot graph wal text metadata)
|
|
150
172
|
add_executable(fuzz_${target} fuzz/fuzz_${target}.cpp)
|
|
151
173
|
target_link_libraries(fuzz_${target} PRIVATE epzero_engine)
|
|
152
174
|
target_link_options(fuzz_${target} PRIVATE -fsanitize=fuzzer)
|
|
@@ -193,20 +215,26 @@ if(EPZERO_BUILD_NODE AND NOT EMSCRIPTEN)
|
|
|
193
215
|
endif()
|
|
194
216
|
|
|
195
217
|
if(EMSCRIPTEN)
|
|
196
|
-
# ES
|
|
197
|
-
|
|
198
|
-
|
|
218
|
+
# ES modules for the browser demo: `import createEpzero from "./epzero.js"`. epzero-lab.js adds the
|
|
219
|
+
# automatic configuration (build_auto) for the benchmark lab, which is 36 KB more compressed: kept out
|
|
220
|
+
# of the module every other page loads first.
|
|
221
|
+
foreach(web_target epzero_web epzero_lab)
|
|
222
|
+
add_executable(${web_target} wasm/epzero_wasm.cpp)
|
|
223
|
+
target_link_libraries(${web_target} PRIVATE epzero_engine)
|
|
224
|
+
target_link_options(${web_target} PRIVATE
|
|
225
|
+
-lembind -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createEpzero -sENVIRONMENT=web,worker -sSTACK_SIZE=1MB
|
|
226
|
+
# No eval / new Function in the generated bindings, so the site's Content Security Policy can
|
|
227
|
+
# forbid it (only WebAssembly compilation is allowed).
|
|
228
|
+
-sDYNAMIC_EXECUTION=0)
|
|
229
|
+
if(EPZERO_WASM_THREADS)
|
|
230
|
+
# Pre-spawn the workers: the module's own thread blocks while a build runs, so it cannot
|
|
231
|
+
# create new ones on demand.
|
|
232
|
+
target_link_options(${web_target} PRIVATE "-sPTHREAD_POOL_SIZE=Math.min(navigator.hardwareConcurrency,16)")
|
|
233
|
+
endif()
|
|
234
|
+
endforeach()
|
|
199
235
|
set_target_properties(epzero_web PROPERTIES OUTPUT_NAME epzero SUFFIX .js)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
# No eval / new Function in the generated bindings, so the site's Content Security Policy can
|
|
203
|
-
# forbid it (only WebAssembly compilation is allowed).
|
|
204
|
-
-sDYNAMIC_EXECUTION=0)
|
|
205
|
-
if(EPZERO_WASM_THREADS)
|
|
206
|
-
# Pre-spawn the workers: the module's own thread blocks while a build runs, so it cannot
|
|
207
|
-
# create new ones on demand.
|
|
208
|
-
target_link_options(epzero_web PRIVATE "-sPTHREAD_POOL_SIZE=Math.min(navigator.hardwareConcurrency,16)")
|
|
209
|
-
endif()
|
|
236
|
+
set_target_properties(epzero_lab PROPERTIES OUTPUT_NAME epzero-lab SUFFIX .js)
|
|
237
|
+
target_compile_definitions(epzero_lab PRIVATE EPZERO_WASM_LAB_AUTO=1)
|
|
210
238
|
endif()
|
|
211
239
|
|
|
212
240
|
if(EPZERO_BUILD_PYTHON)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: epzero
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A fast HNSW vector database in C++20 that configures itself: SIMD search, filtered search, recall monitoring, crash-safe storage
|
|
5
5
|
Keywords: vector database,hnsw,ann,nearest neighbor,embeddings,rag,langchain
|
|
6
6
|
Author: Lajat
|
|
@@ -12,6 +12,7 @@ Classifier: Programming Language :: C++
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Classifier: Operating System :: POSIX :: Linux
|
|
14
14
|
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
16
|
Classifier: Topic :: Database
|
|
16
17
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
18
|
Project-URL: Homepage, https://epzero.dev/
|
|
@@ -20,11 +21,17 @@ Requires-Python: >=3.10
|
|
|
20
21
|
Requires-Dist: numpy
|
|
21
22
|
Provides-Extra: langchain
|
|
22
23
|
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
24
|
+
Provides-Extra: llamaindex
|
|
25
|
+
Requires-Dist: llama-index-core>=0.12; extra == "llamaindex"
|
|
26
|
+
Provides-Extra: haystack
|
|
27
|
+
Requires-Dist: haystack-ai>=2.10; extra == "haystack"
|
|
23
28
|
Provides-Extra: test
|
|
24
29
|
Requires-Dist: pytest; extra == "test"
|
|
25
30
|
Requires-Dist: pytest-asyncio; extra == "test"
|
|
26
31
|
Requires-Dist: langchain-core>=0.3; extra == "test"
|
|
27
32
|
Requires-Dist: langchain-tests>=0.3; extra == "test"
|
|
33
|
+
Requires-Dist: llama-index-core>=0.12; extra == "test"
|
|
34
|
+
Requires-Dist: haystack-ai>=2.10; extra == "test"
|
|
28
35
|
Description-Content-Type: text/markdown
|
|
29
36
|
|
|
30
37
|
# epzero
|
|
@@ -45,16 +52,24 @@ An HNSW vector database written from scratch in C++20, with Python bindings.
|
|
|
45
52
|
confidence interval and moves the search effort to hold a target; `explain()` says why any given
|
|
46
53
|
item was not returned. With `per_query=True` each query also stops as soon as its answer settles,
|
|
47
54
|
so easy queries cost less and hard ones search further.
|
|
48
|
-
- **Hybrid search:** `TextIndex` (BM25 keyword search) and `hybrid_search()`,
|
|
49
|
-
with vector search by reciprocal rank fusion.
|
|
55
|
+
- **Hybrid search:** `TextIndex` (BM25 keyword search, with English stemming) and `hybrid_search()`,
|
|
56
|
+
which merges it with vector search by reciprocal rank fusion.
|
|
57
|
+
- **Retrieval evaluation:** `epzero.ir` keeps documents under string ids, runs dense, keyword and
|
|
58
|
+
hybrid retrieval, writes TREC run files and scores them against relevance judgements exactly as
|
|
59
|
+
trec_eval does. On BEIR with all-MiniLM-L6-v2, hybrid reaches nDCG@10 0.713 on SciFact and 0.354 on
|
|
60
|
+
NFCorpus, above either half alone.
|
|
50
61
|
- **Durable:** `Database` adds a write-ahead log with crash recovery and checkpoints.
|
|
51
62
|
- **8-bit quantization** with exact re-ranking, stopped early per query once the rest can't reach
|
|
52
63
|
the top k.
|
|
53
|
-
- **
|
|
64
|
+
- **Stays accurate as data changes:** `health()` says when an index needs recalibrating,
|
|
65
|
+
requantizing, compacting or rebuilding, and each fix runs in place.
|
|
66
|
+
- **LangChain, LlamaIndex and Haystack** stores, each passing its framework's own test suite.
|
|
54
67
|
|
|
55
68
|
```bash
|
|
56
69
|
pip install epzero # the engine
|
|
57
70
|
pip install "epzero[langchain]" # plus the LangChain vector store
|
|
71
|
+
pip install "epzero[llamaindex]" # plus the LlamaIndex vector store
|
|
72
|
+
pip install "epzero[haystack]" # plus the Haystack document store and retriever
|
|
58
73
|
```
|
|
59
74
|
|
|
60
75
|
## Quick start: let it configure itself
|
|
@@ -93,12 +108,36 @@ users get the same `build_auto()`.
|
|
|
93
108
|
If your queries differ from your data (text queries against image vectors, say), pass a few
|
|
94
109
|
hundred of them as `queries=`: recall calibrated on data rows is optimistic for those.
|
|
95
110
|
|
|
111
|
+
**Metadata.** Fields per label, filtered in the engine:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
meta = epzero.Metadata()
|
|
115
|
+
meta.set_many(labels, [{"source": "faq", "year": 2024}, ...])
|
|
116
|
+
allowed = meta.select({"source": ["faq", "docs"], "year": {"$gte": 2022}})
|
|
117
|
+
labels, distances = index.search(queries, k=10, filter=index.filter(allowed))
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**After the build.** An index keeps changing; its calibration doesn't, by itself:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
print(index.health()) # growth since calibration, deleted share, code error, fit of the directions, advice
|
|
124
|
+
print(index.check_queries(queries)) # do these queries look like the data? (text vs images: no)
|
|
125
|
+
index.recalibrate(queries) # re-measure the beam width on real queries; saved in the profile
|
|
126
|
+
index.requantize() # vectors drifted outside the 8-bit codes' ranges: refit and reconnect
|
|
127
|
+
guard = epzero.RecallGuard(index, sample_rate=0.01) # or measure live recall and adjust as you go
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
When later vectors drift outside the ranges the 8-bit codes were fitted on, they are clipped and
|
|
131
|
+
the graph links chosen for them suffer: in a synthetic test with a 4 sd drift, the beam width needed
|
|
132
|
+
for recall 0.95 went from 12 to 200. `requantize()` refits the codes and reconnects the graph in about half a rebuild's
|
|
133
|
+
time, back to 12.
|
|
134
|
+
|
|
96
135
|
## Configured by hand
|
|
97
136
|
|
|
98
137
|
```python
|
|
99
138
|
vectors = np.random.rand(100_000, 128).astype(np.float32)
|
|
100
139
|
index = epzero.Index(dim=128, metric="l2", max_elements=100_000, M=16, ef_construction=200)
|
|
101
|
-
index.add(vectors) # labels 0..n-1 (or pass labels=)
|
|
140
|
+
index.add(vectors) # labels 0..n-1 (or pass labels=); grows as needed
|
|
102
141
|
|
|
103
142
|
labels, distances = index.search(vectors[:5], k=10, ef=64)
|
|
104
143
|
|
|
@@ -145,9 +184,59 @@ metadata. They are compiled into an Epzero filter, so the engine chooses how to
|
|
|
145
184
|
documents instead of over-fetching and discarding. The store passes LangChain's standard
|
|
146
185
|
vector-store test suite.
|
|
147
186
|
|
|
187
|
+
## LlamaIndex
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from llama_index.core import StorageContext, VectorStoreIndex
|
|
191
|
+
from epzero.llamaindex import EpzeroVectorStore
|
|
192
|
+
|
|
193
|
+
store = EpzeroVectorStore(metric="cosine")
|
|
194
|
+
index = VectorStoreIndex.from_documents(documents, storage_context=StorageContext.from_defaults(vector_store=store))
|
|
195
|
+
index.as_query_engine().query("How do I reset my password?")
|
|
196
|
+
store.persist("./epzero-store") # EpzeroVectorStore.from_persist_path("./epzero-store")
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Metadata filters (`==`, `!=`, `>`, `>=`, `<`, `<=`, `in`, `nin`, combined with AND or OR) run in the
|
|
200
|
+
engine, as do `doc_ids` and `node_ids`; `delete_ref_doc` removes a document's nodes.
|
|
201
|
+
|
|
202
|
+
## Haystack
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from haystack import Pipeline
|
|
206
|
+
from epzero.haystack import EpzeroDocumentStore, EpzeroEmbeddingRetriever
|
|
207
|
+
|
|
208
|
+
store = EpzeroDocumentStore(metric="cosine")
|
|
209
|
+
store.write_documents(documents) # Documents with embeddings
|
|
210
|
+
pipeline = Pipeline()
|
|
211
|
+
pipeline.add_component("retriever", EpzeroEmbeddingRetriever(document_store=store, top_k=5))
|
|
212
|
+
pipeline.run({"retriever": {"query_embedding": embedding,
|
|
213
|
+
"filters": {"field": "meta.lang", "operator": "==", "value": "en"}}})
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Filters use Haystack's syntax and run in the engine wherever that gives exactly Haystack's answer;
|
|
217
|
+
the rest (ranges on dates, `None`) go to Haystack's reference implementation. The store passes
|
|
218
|
+
Haystack's document-store test suite.
|
|
219
|
+
|
|
220
|
+
## Retrieval evaluation
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from epzero import ir
|
|
224
|
+
|
|
225
|
+
corpus, queries, qrels = ir.load_beir("scifact") # a BEIR dataset folder
|
|
226
|
+
collection = ir.Collection.build(list(corpus), doc_vectors, texts=list(corpus.values()), metric="cosine")
|
|
227
|
+
run = collection.retrieve(list(queries), vectors=query_vectors, texts=list(queries.values()),
|
|
228
|
+
k=100, mode="hybrid") # or "dense", "keyword"
|
|
229
|
+
ir.write_run(run, "run.txt", tag="my-system") # TREC format, for CLEF or TREC
|
|
230
|
+
print(ir.evaluate(run, qrels)) # nDCG@10, R@100, MAP, MRR@10
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`evaluate` matches trec_eval (tested against it, ties included) and also takes `per_query=True` and
|
|
234
|
+
measures like `P@5` or `nDCG@20`. Documents can be added, replaced and removed by id, and a collection
|
|
235
|
+
saves to a folder.
|
|
236
|
+
|
|
148
237
|
## Platforms
|
|
149
238
|
|
|
150
|
-
Wheels for Linux (x86-64 and ARM64, glibc 2.28+)
|
|
151
|
-
to 3.13. Elsewhere, `pip install epzero` builds from source (CMake 3.20+, a C++20 compiler).
|
|
239
|
+
Wheels for Linux (x86-64 and ARM64, glibc 2.28+), macOS (Apple silicon and Intel) and, from 0.5,
|
|
240
|
+
Windows (x64), Python 3.10 to 3.13. Elsewhere, `pip install epzero` builds from source (CMake 3.20+, a C++20 compiler).
|
|
152
241
|
|
|
153
242
|
Live demo, benchmarks and design notes: https://epzero.dev/ · MIT license.
|
|
@@ -43,6 +43,7 @@ struct Options {
|
|
|
43
43
|
std::size_t nav_dim = 0;
|
|
44
44
|
bool fill_links = false;
|
|
45
45
|
bool fast_build = false;
|
|
46
|
+
bool half = false;
|
|
46
47
|
bool fill_after = false;
|
|
47
48
|
std::size_t refine = 0;
|
|
48
49
|
bool refine_exact = false;
|
|
@@ -98,6 +99,7 @@ Options parse(int argc, char** argv) {
|
|
|
98
99
|
else if (arg == "--nav-dim") o.nav_dim = std::stoul(value());
|
|
99
100
|
else if (arg == "--fill-links") o.fill_links = true;
|
|
100
101
|
else if (arg == "--fast-build") o.fast_build = true;
|
|
102
|
+
else if (arg == "--half") o.half = true;
|
|
101
103
|
else if (arg == "--fill-after") o.fill_after = true;
|
|
102
104
|
else if (arg == "--refine") o.refine = std::stoul(value());
|
|
103
105
|
else if (arg == "--refine-exact") o.refine_exact = true;
|
|
@@ -191,7 +193,7 @@ int main(int argc, char** argv) try {
|
|
|
191
193
|
HnswParams params{.dim = dim, .metric = opt.metric, .max_elements = base.n, .M = opt.M,
|
|
192
194
|
.ef_construction = opt.ef_construction, .seed = opt.seed, .quantization = opt.quantization,
|
|
193
195
|
.keep_vectors = opt.keep_vectors, .nav_dim = opt.nav_dim, .fill_links = opt.fill_links,
|
|
194
|
-
.fast_build = opt.fast_build};
|
|
196
|
+
.fast_build = opt.fast_build, .half_vectors = opt.half};
|
|
195
197
|
loaded = std::make_unique<HnswIndex>(params);
|
|
196
198
|
t0 = Clock::now();
|
|
197
199
|
loaded->train(base.data.data(), std::min<std::size_t>(base.n, 100'000)); // no-op unquantized
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"float-l2": {
|
|
3
|
+
"4": {
|
|
4
|
+
"recall": 0.9068,
|
|
5
|
+
"distances": 209.99
|
|
6
|
+
},
|
|
7
|
+
"8": {
|
|
8
|
+
"recall": 0.9068,
|
|
9
|
+
"distances": 209.99
|
|
10
|
+
},
|
|
11
|
+
"16": {
|
|
12
|
+
"recall": 0.9664,
|
|
13
|
+
"distances": 247.212
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"float-cosine": {
|
|
17
|
+
"4": {
|
|
18
|
+
"recall": 0.9042,
|
|
19
|
+
"distances": 222.262
|
|
20
|
+
},
|
|
21
|
+
"8": {
|
|
22
|
+
"recall": 0.9042,
|
|
23
|
+
"distances": 222.262
|
|
24
|
+
},
|
|
25
|
+
"16": {
|
|
26
|
+
"recall": 0.967,
|
|
27
|
+
"distances": 263.986
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"sq8-second-pass": {
|
|
31
|
+
"4": {
|
|
32
|
+
"recall": 0.9132,
|
|
33
|
+
"distances": 234.102
|
|
34
|
+
},
|
|
35
|
+
"8": {
|
|
36
|
+
"recall": 0.9132,
|
|
37
|
+
"distances": 234.102
|
|
38
|
+
},
|
|
39
|
+
"16": {
|
|
40
|
+
"recall": 0.981,
|
|
41
|
+
"distances": 275.388
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"sq8-directions": {
|
|
45
|
+
"32": {
|
|
46
|
+
"recall": 0.4988,
|
|
47
|
+
"distances": 323.592
|
|
48
|
+
},
|
|
49
|
+
"64": {
|
|
50
|
+
"recall": 0.7316,
|
|
51
|
+
"distances": 390.994
|
|
52
|
+
},
|
|
53
|
+
"128": {
|
|
54
|
+
"recall": 0.9518,
|
|
55
|
+
"distances": 508.41
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Fuzzes MetadataIndex::load: arbitrary bytes as a metadata file must either load into an index that is
|
|
2
|
+
// safe to query, change and save, or throw. Never crash, read out of bounds or hit undefined behavior.
|
|
3
|
+
#include <exception>
|
|
4
|
+
#include <string>
|
|
5
|
+
|
|
6
|
+
#include "common.h"
|
|
7
|
+
#include "epzero/metadata.h"
|
|
8
|
+
|
|
9
|
+
extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) {
|
|
10
|
+
static const auto path = epzero::fuzz::scratch("metadata") / "input.meta";
|
|
11
|
+
epzero::fuzz::write_bytes(path, data, size);
|
|
12
|
+
try {
|
|
13
|
+
epzero::MetadataIndex index = epzero::MetadataIndex::load(path.string());
|
|
14
|
+
(void)index.select({{.field = "source", .any_of = {std::string("faq")}}, {.field = "year", .min = 2000.0}});
|
|
15
|
+
(void)index.select({{.field = "year", .max = 1e9, .negate = true}});
|
|
16
|
+
index.set(1, {{"source", std::string("new")}});
|
|
17
|
+
index.remove(1);
|
|
18
|
+
index.save(path.string());
|
|
19
|
+
} catch (const std::exception&) {
|
|
20
|
+
// Rejected cleanly: the expected outcome for corrupt input.
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
#include "common.h"
|
|
11
11
|
#include "epzero/database.h"
|
|
12
|
+
#include "epzero/metadata.h"
|
|
12
13
|
#include "epzero/text.h"
|
|
13
14
|
|
|
14
15
|
namespace fs = std::filesystem;
|
|
@@ -20,7 +21,7 @@ int main(int argc, char** argv) {
|
|
|
20
21
|
return 2;
|
|
21
22
|
}
|
|
22
23
|
const fs::path out = argv[1];
|
|
23
|
-
for (const char* name : {"snapshot", "graph", "wal", "text"}) fs::create_directories(out / name);
|
|
24
|
+
for (const char* name : {"snapshot", "graph", "wal", "text", "metadata"}) fs::create_directories(out / name);
|
|
24
25
|
|
|
25
26
|
std::vector<float> vectors(40 * kDim);
|
|
26
27
|
for (std::size_t i = 0; i < vectors.size(); ++i) vectors[i] = static_cast<float>((i * 7919) % 101) / 101.0f;
|
|
@@ -68,7 +69,16 @@ int main(int argc, char** argv) {
|
|
|
68
69
|
text.add(1, "red car by the river");
|
|
69
70
|
text.add(2, "night city lights, city traffic");
|
|
70
71
|
text.add(7, "café über 42");
|
|
71
|
-
text.save((out / "text" / "valid").string());
|
|
72
|
+
text.save((out / "text" / "valid").string()); // version 2: stemmed
|
|
73
|
+
epzero::TextIndex plain({.stemmer = epzero::TextIndex::Stemmer::None});
|
|
74
|
+
plain.add(1, "red car by the river");
|
|
75
|
+
plain.add(7, "café über 42");
|
|
76
|
+
plain.save((out / "text" / "valid-v1").string());
|
|
77
|
+
|
|
78
|
+
epzero::MetadataIndex meta;
|
|
79
|
+
meta.set(1, {{"source", std::string("faq")}, {"year", std::int64_t{2021}}, {"public", true}});
|
|
80
|
+
meta.set(2, {{"source", std::string("blog")}, {"score", 0.5}});
|
|
81
|
+
meta.save((out / "metadata" / "valid").string());
|
|
72
82
|
std::printf("seeds written to %s\n", out.string().c_str());
|
|
73
83
|
return 0;
|
|
74
84
|
}
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
#include <cstdint>
|
|
19
19
|
#include <memory>
|
|
20
20
|
#include <optional>
|
|
21
|
+
#include <string>
|
|
21
22
|
#include <utility>
|
|
23
|
+
#include <vector>
|
|
22
24
|
|
|
23
25
|
#include "epzero/hnsw.h"
|
|
24
26
|
|
|
@@ -35,6 +37,7 @@ struct AutoFixed {
|
|
|
35
37
|
std::optional<float> rerank_z;
|
|
36
38
|
std::optional<std::size_t> ef; // skips calibration
|
|
37
39
|
std::optional<std::pair<std::size_t, std::size_t>> prefetch; // (depth, lines); skips timing
|
|
40
|
+
std::optional<bool> half_vectors; // 16-bit full vectors: half their memory (never chosen automatically)
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
struct AutoOptions {
|
|
@@ -67,4 +70,40 @@ AutoPlan plan_auto(const float* data, std::size_t n, std::size_t dim, const Auto
|
|
|
67
70
|
std::unique_ptr<HnswIndex> build_auto(const float* data, const std::uint64_t* labels, std::size_t n, std::size_t dim,
|
|
68
71
|
const AutoOptions& options = {});
|
|
69
72
|
|
|
73
|
+
// Re-measures the beam width that reaches `recall` (0: the profile's target, else 0.95) on `queries`
|
|
74
|
+
// (count rows of dim floats) and stores it in the index's profile, which search() then uses. Pass real
|
|
75
|
+
// queries: rows already in the graph are linked to their own neighbours, so they make the search look
|
|
76
|
+
// easier than it is. Not thread-safe against concurrent searches. Returns (ef, recall reached).
|
|
77
|
+
std::pair<std::size_t, float> recalibrate(HnswIndex& index, const float* queries, std::size_t count, float recall = 0.0f,
|
|
78
|
+
std::size_t threads = 0);
|
|
79
|
+
|
|
80
|
+
// What an index needs, if anything, since it was built: a measurement plus advice in plain words.
|
|
81
|
+
struct IndexHealth {
|
|
82
|
+
std::size_t live = 0;
|
|
83
|
+
std::size_t deleted = 0; // tombstones still in the graph
|
|
84
|
+
double growth = 0.0; // change in live vectors since calibration, as a share (0.3: 30% more)
|
|
85
|
+
double nav_variance_built = 0.0; // share of the variance the principal directions held when built
|
|
86
|
+
double nav_variance_now = 0.0; // and on the vectors in the index now (0 without principal directions)
|
|
87
|
+
double code_error = 0.0; // share of the variance the 8-bit codes miss (HnswIndex::code_error)
|
|
88
|
+
std::vector<std::string> advice; // empty when nothing needs doing
|
|
89
|
+
};
|
|
90
|
+
IndexHealth check_health(const HnswIndex& index, std::size_t threads = 0);
|
|
91
|
+
|
|
92
|
+
// Whether `queries` (count rows) look like the data the index was calibrated on. A query from the data's
|
|
93
|
+
// distribution sits about as far from its nearest neighbour as rows of the data do from theirs; text
|
|
94
|
+
// queries against image vectors, say, sit far farther (VIBE's text-to-image sets: 2.3x, against 0.99-1.09x
|
|
95
|
+
// for queries like the data). Then recall calibrated on rows of the data overestimates theirs (0.74-0.80
|
|
96
|
+
// against a 0.95 target on those sets), and `advice` says to recalibrate on them.
|
|
97
|
+
struct QueryCheck {
|
|
98
|
+
double query_distance = 0.0; // median distance from a query to its nearest neighbour (sample)
|
|
99
|
+
double row_distance = 0.0; // the same for rows of the data
|
|
100
|
+
double ratio = 0.0; // query_distance / row_distance
|
|
101
|
+
bool shifted = false; // ratio above 1.5
|
|
102
|
+
std::vector<std::string> advice;
|
|
103
|
+
};
|
|
104
|
+
QueryCheck check_queries(const HnswIndex& index, const float* queries, std::size_t count, std::size_t threads = 0);
|
|
105
|
+
|
|
106
|
+
// Share of the variance of the index's vectors (a sample) that its principal directions hold.
|
|
107
|
+
double nav_variance(const HnswIndex& index, std::size_t threads = 0);
|
|
108
|
+
|
|
70
109
|
} // namespace epzero
|
|
@@ -38,6 +38,12 @@ std::string machine_name();
|
|
|
38
38
|
DistanceFn get_distance_fn(Metric metric, SimdLevel level) noexcept;
|
|
39
39
|
DistanceFn get_distance_fn(Metric metric) noexcept;
|
|
40
40
|
|
|
41
|
+
// A float query against a vector stored as IEEE half floats (HnswParams::half_vectors): the metric's
|
|
42
|
+
// distance as get_distance_fn computes it. Always available (a scalar fallback); F16C or NEON where
|
|
43
|
+
// the CPU has them.
|
|
44
|
+
using HalfDistanceFn = float (*)(const float* query, const std::uint16_t* v, std::size_t dim) noexcept;
|
|
45
|
+
HalfDistanceFn get_half_distance_fn(Metric metric, SimdLevel level) noexcept;
|
|
46
|
+
|
|
41
47
|
// Asymmetric-distance kernel for 8-bit scalar quantization: returns sum_i w[i] * codes[i].
|
|
42
48
|
// The quantizer folds its per-dimension scales and offsets into `w` once per query, so scoring a
|
|
43
49
|
// node reads only `dim` bytes. Returns nullptr if `level` is not supported.
|