pyvectorhound 1.4.0__tar.gz → 1.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.
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/CHANGELOG.md +12 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/Cargo.lock +1 -1
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/Cargo.toml +1 -2
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/PKG-INFO +81 -36
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/README.md +53 -9
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyproject.toml +2 -1
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/scripts/setup_shortcuts.sh +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/scripts/setup_shortcuts.sh +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/scripts/test_dashboards_crossplatform.sh +0 -0
- pyvectorhound-1.5.0/src/lib.rs +196 -0
- pyvectorhound-1.5.0/src/quantization.rs +300 -0
- pyvectorhound-1.4.0/src/lib.rs +0 -91
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.github/dependabot.yml +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.github/workflows/ci.yml +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.gitignore +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/.pre-commit-config.yaml +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/ARCHITECTURE.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/CODE_OF_CONDUCT.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/CONTRIBUTING.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/DASHBOARD_SHORTCUTS.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/DEPLOYMENT.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/LICENSE +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/Makefile +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/OKF_INTEGRATION.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/OTEL_SETUP_GUIDE.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/PRODUCTION_DEPLOYMENT.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/SECURITY.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/USER_GUIDE.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/ARCHITECTURE.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/GUIDE.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/PRODUCT_VISION.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/ROADMAP.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PYHOUND_FINAL_STATUS.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_COMPETITIVE_ROADMAP.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_FINAL_PRODUCT.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_GITHUB_LAUNCH.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_LAUNCH_CHECKLIST.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_UNIFICATION_STRATEGY.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/docs/strategy/PyHound_vs_Observability.md +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/examples/benchmarking_and_trends.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/examples/mcp_pyvectorhound.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/examples/retrieval_debug.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/install.sh +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/otel_exporter.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/__init__.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/_mcp_connector.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/_mcp_tools.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/_retry.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/_safe_exec.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/advanced_analytics.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/backends.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/benchmarking.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/cli.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/comparison.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/database.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/db_adapters.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/diagnosis.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/error_messages.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/errors.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/hound.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/langchain_integration.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/llamaindex_integration.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/logging_config.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/okf_diagnostics.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/otel_integration.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/py.typed +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/recommendations.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/retrieval_replay.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/retrieval_tracing.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/scorer.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/scripts/__init__.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/scripts/post_install.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/server.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/trend_analysis.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/validation.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound/web_dashboard.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/pyvectorhound.toml +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/rust-toolchain.toml +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/src/metrics.rs +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/src/retrieval_ranking.rs +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_benchmarking.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_database_adapters.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_diagnosis.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_hound.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_okf_diagnostics.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_recommendations.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_retrieval_replay.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_scorer.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_trend_analysis.py +0 -0
- {pyvectorhound-1.4.0 → pyvectorhound-1.5.0}/tests/test_v05_integrations.py +0 -0
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to PyVectorHound are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.5.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **int8 scalar quantization** (`src/quantization.rs`) — per-vector min/max
|
|
10
|
+
linear quantization to `i8`, with `quantize`/`dequantize` (and batch
|
|
11
|
+
variants) exposed via PyO3. 4x memory reduction vs `f32` for large local
|
|
12
|
+
vector indices, with measured reconstruction error (0.0039 max on a
|
|
13
|
+
384-dim `[-1,1]` test vector) within the theoretical 8-bit bound
|
|
14
|
+
(`range/255`). SIMD/GPU acceleration is a separate, larger effort and
|
|
15
|
+
intentionally out of scope here.
|
|
16
|
+
|
|
5
17
|
## [1.4.0]
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "pyvectorhound"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.5.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Georgi Mammen Mullassery <mullassery@gmail.com>"]
|
|
6
6
|
license = "Proprietary"
|
|
7
7
|
description = "Vector search diagnostics engine for RAG/LLM systems"
|
|
8
|
-
readme = "README.md"
|
|
9
8
|
|
|
10
9
|
[dependencies]
|
|
11
10
|
pyo3 = { version = "0.23", features = ["extension-module"] }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: pyvectorhound
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: Intended Audience :: Science/Research
|
|
@@ -18,46 +18,47 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
18
18
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
19
|
Classifier: Topic :: Text Processing
|
|
20
20
|
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
21
|
-
Requires-Dist: numpy>=1.21
|
|
22
|
-
Requires-Dist: requests>=2.28
|
|
23
|
-
Requires-Dist: python-frontmatter>=1.0.0
|
|
24
|
-
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Requires-Dist: numpy >=1.21, <3
|
|
22
|
+
Requires-Dist: requests >=2.28, <3
|
|
23
|
+
Requires-Dist: python-frontmatter >=1.0.0, <1.2.0
|
|
24
|
+
Requires-Dist: pydantic >=2.0, <3
|
|
25
|
+
Requires-Dist: qdrant-client >=1.7, <3 ; extra == 'qdrant'
|
|
26
|
+
Requires-Dist: chromadb >=0.4, <1 ; extra == 'chroma'
|
|
27
|
+
Requires-Dist: pymilvus >=2.3, <3 ; extra == 'milvus'
|
|
28
|
+
Requires-Dist: weaviate-client >=3.24, <4 ; extra == 'weaviate'
|
|
29
|
+
Requires-Dist: psycopg2-binary >=2.9, <3 ; extra == 'postgres'
|
|
30
|
+
Requires-Dist: psycopg2-binary >=2.9, <3 ; extra == 'pgvector'
|
|
31
|
+
Requires-Dist: pytest >=7.0, <9 ; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov >=4.0, <7 ; extra == 'dev'
|
|
33
|
+
Requires-Dist: mypy >=1.0, <2 ; extra == 'dev'
|
|
34
|
+
Requires-Dist: black >=23.0, <26 ; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff >=0.1, <1 ; extra == 'dev'
|
|
36
|
+
Requires-Dist: maturin >=1.7, <2 ; extra == 'dev'
|
|
25
37
|
Requires-Dist: pyvectorhound[qdrant,chroma,milvus,weaviate,postgres,pgvector,dev] ; extra == 'all'
|
|
26
|
-
|
|
27
|
-
Requires-Dist: pytest>=7.0,<9 ; extra == 'dev'
|
|
28
|
-
Requires-Dist: pytest-cov>=4.0,<7 ; extra == 'dev'
|
|
29
|
-
Requires-Dist: mypy>=1.0,<2 ; extra == 'dev'
|
|
30
|
-
Requires-Dist: black>=23.0,<26 ; extra == 'dev'
|
|
31
|
-
Requires-Dist: ruff>=0.1,<1 ; extra == 'dev'
|
|
32
|
-
Requires-Dist: maturin>=1.7,<2 ; extra == 'dev'
|
|
33
|
-
Requires-Dist: pymilvus>=2.3,<3 ; extra == 'milvus'
|
|
34
|
-
Requires-Dist: psycopg2-binary>=2.9,<3 ; extra == 'pgvector'
|
|
35
|
-
Requires-Dist: psycopg2-binary>=2.9,<3 ; extra == 'postgres'
|
|
36
|
-
Requires-Dist: qdrant-client>=1.7,<3 ; extra == 'qdrant'
|
|
37
|
-
Requires-Dist: weaviate-client>=3.24,<4 ; extra == 'weaviate'
|
|
38
|
-
Provides-Extra: all
|
|
38
|
+
Provides-Extra: qdrant
|
|
39
39
|
Provides-Extra: chroma
|
|
40
|
-
Provides-Extra: dev
|
|
41
40
|
Provides-Extra: milvus
|
|
42
|
-
Provides-Extra: pgvector
|
|
43
|
-
Provides-Extra: postgres
|
|
44
|
-
Provides-Extra: qdrant
|
|
45
41
|
Provides-Extra: weaviate
|
|
42
|
+
Provides-Extra: postgres
|
|
43
|
+
Provides-Extra: pgvector
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Provides-Extra: all
|
|
46
46
|
License-File: LICENSE
|
|
47
47
|
Summary: Diagnostic tool for vector search failures in RAG and LLM systems. Evaluate embedding quality, analyze vector search rank correlation, benchmark BM25 vs semantic search, profile reranker performance. Identify root cause of retrieval failures.
|
|
48
48
|
Keywords: rag,retrieval-augmented-generation,vector-search,embedding,semantic-search,llm,large-language-models,information-retrieval,search-ranking,evaluation-metrics,bm25,reranking,debugging,diagnostics,nlp,natural-language-processing,similarity-search,faiss,elasticsearch,pinecone,milvus,ai-debugging
|
|
49
|
+
Author: Georgi Mammen Mullassery <mullassery@gmail.com>
|
|
49
50
|
Author-email: Georgi Mammen Mullassery <mullassery@gmail.com>
|
|
50
51
|
Maintainer-email: Georgi Mammen Mullassery <mullassery@gmail.com>
|
|
51
52
|
License: Proprietary
|
|
52
53
|
Requires-Python: >=3.8
|
|
53
54
|
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
54
|
-
Project-URL: Bug Tracker, https://github.com/Mullassery/Pyvectorhound/issues
|
|
55
|
-
Project-URL: Changelog, https://github.com/Mullassery/Pyvectorhound/releases
|
|
56
|
-
Project-URL: Discussions, https://github.com/Mullassery/Pyvectorhound/discussions
|
|
57
|
-
Project-URL: Documentation, https://github.com/Mullassery/Pyvectorhound#readme
|
|
58
55
|
Project-URL: Homepage, https://github.com/Mullassery/Pyvectorhound
|
|
59
56
|
Project-URL: Repository, https://github.com/Mullassery/Pyvectorhound
|
|
57
|
+
Project-URL: Documentation, https://github.com/Mullassery/Pyvectorhound#readme
|
|
58
|
+
Project-URL: Bug Tracker, https://github.com/Mullassery/Pyvectorhound/issues
|
|
59
|
+
Project-URL: Changelog, https://github.com/Mullassery/Pyvectorhound/releases
|
|
60
60
|
Project-URL: Source Code, https://github.com/Mullassery/Pyvectorhound/tree/main
|
|
61
|
+
Project-URL: Discussions, https://github.com/Mullassery/Pyvectorhound/discussions
|
|
61
62
|
|
|
62
63
|
# PyVectorHound
|
|
63
64
|
|
|
@@ -131,6 +132,50 @@ native `_core` extension but not yet exposed as a Python-callable function —
|
|
|
131
132
|
if you need it from Python today, treat it as in-progress internal
|
|
132
133
|
infrastructure rather than a public API.
|
|
133
134
|
|
|
135
|
+
### New: int8 scalar quantization (Rust core)
|
|
136
|
+
|
|
137
|
+
`src/quantization.rs` adds per-vector int8 scalar quantization for shrinking
|
|
138
|
+
the memory footprint of large local vector indices. Each `f32` vector is
|
|
139
|
+
mapped linearly to the `i8` range (-128..=127) using that vector's own
|
|
140
|
+
min/max, alongside a `(scale, offset)` pair needed to dequantize it back to
|
|
141
|
+
an approximate `f32` vector. This is scalar quantization, not product
|
|
142
|
+
quantization, and it isn't SIMD/GPU accelerated — both are out of scope for
|
|
143
|
+
this feature.
|
|
144
|
+
|
|
145
|
+
**Tradeoff:** storing `i8` instead of `f32` is a **4x memory reduction**
|
|
146
|
+
for the vector data (1 byte/dim vs 4 bytes/dim), at the cost of bounded
|
|
147
|
+
reconstruction error — max absolute error per element is at most
|
|
148
|
+
`(max - min) / 255` for the vector being quantized (half a quantization
|
|
149
|
+
step in practice). For a typical normalized embedding in `[-1, 1]`, that's
|
|
150
|
+
a worst-case error bound of about `0.0078` per dimension; measured max
|
|
151
|
+
error on a random 384-dim `[-1, 1]` vector in the test suite was `~0.0039`.
|
|
152
|
+
Constant vectors (including all-zero) round-trip exactly.
|
|
153
|
+
|
|
154
|
+
Exposed to Python via `pyvectorhound._core`:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from pyvectorhound import _core
|
|
158
|
+
|
|
159
|
+
# Single vector
|
|
160
|
+
result = _core.py_quantize_vector([0.12, -0.87, 0.5, 0.0])
|
|
161
|
+
# {"values": [i8, ...], "scale": float, "offset": float, "min": float, "max": float}
|
|
162
|
+
restored = _core.py_dequantize_vector(result["values"], result["scale"], result["offset"])
|
|
163
|
+
|
|
164
|
+
# Batch (one scale/offset per vector)
|
|
165
|
+
batch = _core.py_quantize_batch([[0.1, 0.2], [-1.0, 1.0]])
|
|
166
|
+
# {"values": [[i8, ...], ...], "params": [{"scale":..., "offset":..., "min":..., "max":...}, ...]}
|
|
167
|
+
restored_batch = _core.py_dequantize_batch(
|
|
168
|
+
batch["values"],
|
|
169
|
+
[p["scale"] for p in batch["params"]],
|
|
170
|
+
[p["offset"] for p in batch["params"]],
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This is currently a low-level building block (quantize/dequantize
|
|
175
|
+
primitives) rather than integrated into `Hound`'s storage/retrieval path —
|
|
176
|
+
if you want quantized on-disk storage for your own index today, call these
|
|
177
|
+
functions directly.
|
|
178
|
+
|
|
134
179
|
### Not yet real (known limitations)
|
|
135
180
|
|
|
136
181
|
Being upfront about what's still a stub, rather than leaving it to look
|
|
@@ -146,16 +191,16 @@ finished:
|
|
|
146
191
|
has a historical data store to compute a real trend from. Use
|
|
147
192
|
`Hound.track_metric()` + `Hound.get_trend_report()` (backed by the real,
|
|
148
193
|
tested `TrendAnalyzer`) instead.
|
|
149
|
-
- As of v1.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
install straight from the repo instead
|
|
156
|
-
correctly (needs a Rust toolchain — `maturin`/`pip` handle the build, but
|
|
157
|
-
`cargo` must be available):
|
|
194
|
+
- As of v1.4.0, PyPI carries a macOS arm64 / CPython 3.9 wheel plus a source
|
|
195
|
+
distribution (`sdist`). The wheel is still single-platform/single-ABI (no
|
|
196
|
+
`abi3` build yet — see below), but the sdist means `pip install
|
|
197
|
+
pyvectorhound` on any other interpreter or OS now builds the current
|
|
198
|
+
version from source instead of silently falling back to an old release,
|
|
199
|
+
as long as a Rust toolchain is available locally. If the sdist build
|
|
200
|
+
fails for you, install straight from the repo instead:
|
|
158
201
|
`pip install git+https://github.com/Mullassery/PyVectorHound.git`.
|
|
202
|
+
A proper multi-platform wheel matrix (built in CI, `abi3` so one wheel
|
|
203
|
+
covers multiple CPython versions) is still open work.
|
|
159
204
|
- GitHub Actions CI (the badge above) is currently red on every job across
|
|
160
205
|
recent pushes to `main` — not because of failing tests, but because
|
|
161
206
|
`.github/workflows/ci.yml`'s `dtolnay/rust-toolchain@v1` step is missing
|
|
@@ -70,6 +70,50 @@ native `_core` extension but not yet exposed as a Python-callable function —
|
|
|
70
70
|
if you need it from Python today, treat it as in-progress internal
|
|
71
71
|
infrastructure rather than a public API.
|
|
72
72
|
|
|
73
|
+
### New: int8 scalar quantization (Rust core)
|
|
74
|
+
|
|
75
|
+
`src/quantization.rs` adds per-vector int8 scalar quantization for shrinking
|
|
76
|
+
the memory footprint of large local vector indices. Each `f32` vector is
|
|
77
|
+
mapped linearly to the `i8` range (-128..=127) using that vector's own
|
|
78
|
+
min/max, alongside a `(scale, offset)` pair needed to dequantize it back to
|
|
79
|
+
an approximate `f32` vector. This is scalar quantization, not product
|
|
80
|
+
quantization, and it isn't SIMD/GPU accelerated — both are out of scope for
|
|
81
|
+
this feature.
|
|
82
|
+
|
|
83
|
+
**Tradeoff:** storing `i8` instead of `f32` is a **4x memory reduction**
|
|
84
|
+
for the vector data (1 byte/dim vs 4 bytes/dim), at the cost of bounded
|
|
85
|
+
reconstruction error — max absolute error per element is at most
|
|
86
|
+
`(max - min) / 255` for the vector being quantized (half a quantization
|
|
87
|
+
step in practice). For a typical normalized embedding in `[-1, 1]`, that's
|
|
88
|
+
a worst-case error bound of about `0.0078` per dimension; measured max
|
|
89
|
+
error on a random 384-dim `[-1, 1]` vector in the test suite was `~0.0039`.
|
|
90
|
+
Constant vectors (including all-zero) round-trip exactly.
|
|
91
|
+
|
|
92
|
+
Exposed to Python via `pyvectorhound._core`:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pyvectorhound import _core
|
|
96
|
+
|
|
97
|
+
# Single vector
|
|
98
|
+
result = _core.py_quantize_vector([0.12, -0.87, 0.5, 0.0])
|
|
99
|
+
# {"values": [i8, ...], "scale": float, "offset": float, "min": float, "max": float}
|
|
100
|
+
restored = _core.py_dequantize_vector(result["values"], result["scale"], result["offset"])
|
|
101
|
+
|
|
102
|
+
# Batch (one scale/offset per vector)
|
|
103
|
+
batch = _core.py_quantize_batch([[0.1, 0.2], [-1.0, 1.0]])
|
|
104
|
+
# {"values": [[i8, ...], ...], "params": [{"scale":..., "offset":..., "min":..., "max":...}, ...]}
|
|
105
|
+
restored_batch = _core.py_dequantize_batch(
|
|
106
|
+
batch["values"],
|
|
107
|
+
[p["scale"] for p in batch["params"]],
|
|
108
|
+
[p["offset"] for p in batch["params"]],
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This is currently a low-level building block (quantize/dequantize
|
|
113
|
+
primitives) rather than integrated into `Hound`'s storage/retrieval path —
|
|
114
|
+
if you want quantized on-disk storage for your own index today, call these
|
|
115
|
+
functions directly.
|
|
116
|
+
|
|
73
117
|
### Not yet real (known limitations)
|
|
74
118
|
|
|
75
119
|
Being upfront about what's still a stub, rather than leaving it to look
|
|
@@ -85,16 +129,16 @@ finished:
|
|
|
85
129
|
has a historical data store to compute a real trend from. Use
|
|
86
130
|
`Hound.track_metric()` + `Hound.get_trend_report()` (backed by the real,
|
|
87
131
|
tested `TrendAnalyzer`) instead.
|
|
88
|
-
- As of v1.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
install straight from the repo instead
|
|
95
|
-
correctly (needs a Rust toolchain — `maturin`/`pip` handle the build, but
|
|
96
|
-
`cargo` must be available):
|
|
132
|
+
- As of v1.4.0, PyPI carries a macOS arm64 / CPython 3.9 wheel plus a source
|
|
133
|
+
distribution (`sdist`). The wheel is still single-platform/single-ABI (no
|
|
134
|
+
`abi3` build yet — see below), but the sdist means `pip install
|
|
135
|
+
pyvectorhound` on any other interpreter or OS now builds the current
|
|
136
|
+
version from source instead of silently falling back to an old release,
|
|
137
|
+
as long as a Rust toolchain is available locally. If the sdist build
|
|
138
|
+
fails for you, install straight from the repo instead:
|
|
97
139
|
`pip install git+https://github.com/Mullassery/PyVectorHound.git`.
|
|
140
|
+
A proper multi-platform wheel matrix (built in CI, `abi3` so one wheel
|
|
141
|
+
covers multiple CPython versions) is still open work.
|
|
98
142
|
- GitHub Actions CI (the badge above) is currently red on every job across
|
|
99
143
|
recent pushes to `main` — not because of failing tests, but because
|
|
100
144
|
`.github/workflows/ci.yml`'s `dtolnay/rust-toolchain@v1` step is missing
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyvectorhound"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.0"
|
|
8
8
|
description = "Diagnostic tool for vector search failures in RAG and LLM systems. Evaluate embedding quality, analyze vector search rank correlation, benchmark BM25 vs semantic search, profile reranker performance. Identify root cause of retrieval failures."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -93,6 +93,7 @@ Discussions = "https://github.com/Mullassery/Pyvectorhound/discussions"
|
|
|
93
93
|
[tool.maturin]
|
|
94
94
|
module-name = "pyvectorhound._core"
|
|
95
95
|
features = ["pyo3/extension-module"]
|
|
96
|
+
include = ["LICENSE"]
|
|
96
97
|
|
|
97
98
|
[tool.pytest.ini_options]
|
|
98
99
|
testpaths = ["tests"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// PyHound Core - Rust implementation of retrieval diagnostics
|
|
2
|
+
//
|
|
3
|
+
// High-performance embedding quality metrics, pipeline analysis, and diagnostics.
|
|
4
|
+
|
|
5
|
+
mod metrics;
|
|
6
|
+
mod quantization;
|
|
7
|
+
mod retrieval_ranking;
|
|
8
|
+
|
|
9
|
+
use metrics::{
|
|
10
|
+
compute_coverage, compute_distinctiveness, compute_isotropy, compute_retrieval_metrics,
|
|
11
|
+
detect_drift,
|
|
12
|
+
};
|
|
13
|
+
use pyo3::prelude::*;
|
|
14
|
+
use pyo3::types::{PyDict, PyList};
|
|
15
|
+
use pyo3::Bound;
|
|
16
|
+
use quantization::{dequantize, dequantize_batch, quantize, quantize_batch, QuantizationParams};
|
|
17
|
+
|
|
18
|
+
/// Build a Python dict from quantization params: {"scale", "offset", "min", "max"}.
|
|
19
|
+
fn params_to_dict<'py>(
|
|
20
|
+
py: Python<'py>,
|
|
21
|
+
params: &QuantizationParams,
|
|
22
|
+
) -> PyResult<Bound<'py, PyDict>> {
|
|
23
|
+
let dict = PyDict::new(py);
|
|
24
|
+
dict.set_item("scale", params.scale)?;
|
|
25
|
+
dict.set_item("offset", params.offset)?;
|
|
26
|
+
dict.set_item("min", params.min)?;
|
|
27
|
+
dict.set_item("max", params.max)?;
|
|
28
|
+
Ok(dict)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Compute isotropy of embeddings (vector space utilization)
|
|
32
|
+
#[pyfunction]
|
|
33
|
+
fn py_compute_isotropy(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
|
|
34
|
+
Ok(compute_isotropy(&embeddings))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Compute coverage of embeddings (diversity of semantic space)
|
|
38
|
+
#[pyfunction]
|
|
39
|
+
fn py_compute_coverage(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
|
|
40
|
+
Ok(compute_coverage(&embeddings))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Compute distinctiveness of embeddings (semantic separation)
|
|
44
|
+
#[pyfunction]
|
|
45
|
+
fn py_compute_distinctiveness(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
|
|
46
|
+
Ok(compute_distinctiveness(&embeddings))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Detect drift in embedding quality
|
|
50
|
+
#[pyfunction]
|
|
51
|
+
fn py_detect_drift(
|
|
52
|
+
baseline_embeddings: Vec<Vec<f32>>,
|
|
53
|
+
current_embeddings: Vec<Vec<f32>>,
|
|
54
|
+
) -> PyResult<f32> {
|
|
55
|
+
Ok(detect_drift(&baseline_embeddings, ¤t_embeddings))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Compute retrieval metrics (precision, recall, F1, MRR, NDCG)
|
|
59
|
+
#[pyfunction]
|
|
60
|
+
fn py_compute_retrieval_metrics(
|
|
61
|
+
py: Python,
|
|
62
|
+
retrieved: Vec<usize>,
|
|
63
|
+
relevant: Vec<usize>,
|
|
64
|
+
top_k: usize,
|
|
65
|
+
) -> PyResult<PyObject> {
|
|
66
|
+
let metrics = compute_retrieval_metrics(&retrieved, &relevant, top_k);
|
|
67
|
+
|
|
68
|
+
let dict = PyDict::new(py);
|
|
69
|
+
dict.set_item("precision", metrics.precision)?;
|
|
70
|
+
dict.set_item("recall", metrics.recall)?;
|
|
71
|
+
dict.set_item("f1_score", metrics.f1_score)?;
|
|
72
|
+
dict.set_item("mrr", metrics.mrr)?;
|
|
73
|
+
dict.set_item("ndcg", metrics.ndcg)?;
|
|
74
|
+
|
|
75
|
+
Ok(dict.into())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Compute embedding quality score (composite metric)
|
|
79
|
+
#[pyfunction]
|
|
80
|
+
fn py_compute_quality_score(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
|
|
81
|
+
if embeddings.is_empty() {
|
|
82
|
+
return Ok(0.0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let isotropy = compute_isotropy(&embeddings);
|
|
86
|
+
let coverage = compute_coverage(&embeddings);
|
|
87
|
+
let distinctiveness = compute_distinctiveness(&embeddings);
|
|
88
|
+
|
|
89
|
+
// Composite score: weighted average
|
|
90
|
+
let quality = (isotropy * 0.4 + coverage * 0.3 + distinctiveness * 0.3).clamp(0.0, 1.0);
|
|
91
|
+
|
|
92
|
+
Ok(quality)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Quantize a single embedding vector to int8 for storage.
|
|
96
|
+
///
|
|
97
|
+
/// Returns a dict: `{"values": List[int], "scale": float, "offset": float,
|
|
98
|
+
/// "min": float, "max": float}`. `values` holds signed 8-bit integers
|
|
99
|
+
/// (-128..=127); `scale`/`offset` are required to dequantize.
|
|
100
|
+
#[pyfunction]
|
|
101
|
+
fn py_quantize_vector(py: Python, values: Vec<f32>) -> PyResult<PyObject> {
|
|
102
|
+
let (quantized, params) = quantize(&values);
|
|
103
|
+
|
|
104
|
+
let dict = PyDict::new(py);
|
|
105
|
+
dict.set_item("values", quantized)?;
|
|
106
|
+
dict.set_item("scale", params.scale)?;
|
|
107
|
+
dict.set_item("offset", params.offset)?;
|
|
108
|
+
dict.set_item("min", params.min)?;
|
|
109
|
+
dict.set_item("max", params.max)?;
|
|
110
|
+
|
|
111
|
+
Ok(dict.into())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Dequantize an int8 vector (produced by `py_quantize_vector`) back to f32.
|
|
115
|
+
#[pyfunction]
|
|
116
|
+
fn py_dequantize_vector(values: Vec<i8>, scale: f32, offset: f32) -> PyResult<Vec<f32>> {
|
|
117
|
+
let params = QuantizationParams {
|
|
118
|
+
scale,
|
|
119
|
+
offset,
|
|
120
|
+
min: 0.0,
|
|
121
|
+
max: 0.0,
|
|
122
|
+
};
|
|
123
|
+
Ok(dequantize(&values, ¶ms))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Quantize a batch of embedding vectors to int8, one set of quantization
|
|
127
|
+
/// parameters per vector (per-vector min/max scalar quantization).
|
|
128
|
+
///
|
|
129
|
+
/// Returns a dict: `{"values": List[List[int]], "params": List[dict]}`
|
|
130
|
+
/// where each entry of `params` has the same shape as
|
|
131
|
+
/// `py_quantize_vector`'s return dict.
|
|
132
|
+
#[pyfunction]
|
|
133
|
+
fn py_quantize_batch(py: Python, vectors: Vec<Vec<f32>>) -> PyResult<PyObject> {
|
|
134
|
+
let (values, params) = quantize_batch(&vectors);
|
|
135
|
+
|
|
136
|
+
let values_list = PyList::empty(py);
|
|
137
|
+
for v in &values {
|
|
138
|
+
values_list.append(v)?;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let params_list = PyList::empty(py);
|
|
142
|
+
for p in ¶ms {
|
|
143
|
+
params_list.append(params_to_dict(py, p)?)?;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let dict = PyDict::new(py);
|
|
147
|
+
dict.set_item("values", values_list)?;
|
|
148
|
+
dict.set_item("params", params_list)?;
|
|
149
|
+
|
|
150
|
+
Ok(dict.into())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Dequantize a batch of int8 vectors (produced by `py_quantize_batch`)
|
|
154
|
+
/// back to f32, given parallel `scales` and `offsets` lists.
|
|
155
|
+
#[pyfunction]
|
|
156
|
+
fn py_dequantize_batch(
|
|
157
|
+
values: Vec<Vec<i8>>,
|
|
158
|
+
scales: Vec<f32>,
|
|
159
|
+
offsets: Vec<f32>,
|
|
160
|
+
) -> PyResult<Vec<Vec<f32>>> {
|
|
161
|
+
if values.len() != scales.len() || values.len() != offsets.len() {
|
|
162
|
+
return Err(pyo3::exceptions::PyValueError::new_err(
|
|
163
|
+
"values, scales, and offsets must have the same length",
|
|
164
|
+
));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let params: Vec<QuantizationParams> = scales
|
|
168
|
+
.into_iter()
|
|
169
|
+
.zip(offsets)
|
|
170
|
+
.map(|(scale, offset)| QuantizationParams {
|
|
171
|
+
scale,
|
|
172
|
+
offset,
|
|
173
|
+
min: 0.0,
|
|
174
|
+
max: 0.0,
|
|
175
|
+
})
|
|
176
|
+
.collect();
|
|
177
|
+
|
|
178
|
+
Ok(dequantize_batch(&values, ¶ms))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// PyHound Python module
|
|
182
|
+
#[pymodule]
|
|
183
|
+
fn _core(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
184
|
+
m.add_function(wrap_pyfunction!(py_compute_isotropy, m)?)?;
|
|
185
|
+
m.add_function(wrap_pyfunction!(py_compute_coverage, m)?)?;
|
|
186
|
+
m.add_function(wrap_pyfunction!(py_compute_distinctiveness, m)?)?;
|
|
187
|
+
m.add_function(wrap_pyfunction!(py_detect_drift, m)?)?;
|
|
188
|
+
m.add_function(wrap_pyfunction!(py_compute_retrieval_metrics, m)?)?;
|
|
189
|
+
m.add_function(wrap_pyfunction!(py_compute_quality_score, m)?)?;
|
|
190
|
+
m.add_function(wrap_pyfunction!(py_quantize_vector, m)?)?;
|
|
191
|
+
m.add_function(wrap_pyfunction!(py_dequantize_vector, m)?)?;
|
|
192
|
+
m.add_function(wrap_pyfunction!(py_quantize_batch, m)?)?;
|
|
193
|
+
m.add_function(wrap_pyfunction!(py_dequantize_batch, m)?)?;
|
|
194
|
+
|
|
195
|
+
Ok(())
|
|
196
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
//! Int8 scalar quantization for vector storage.
|
|
2
|
+
//!
|
|
3
|
+
//! Large local vector indices stored as `Vec<f32>` cost 4 bytes per
|
|
4
|
+
//! dimension. Scalar quantization maps each vector's float components
|
|
5
|
+
//! linearly into the `i8` range (-128..=127), storing them as 1 byte per
|
|
6
|
+
//! dimension plus a small per-vector `(scale, offset)` pair needed to
|
|
7
|
+
//! reconstruct approximate float values. This gives a ~4x memory reduction
|
|
8
|
+
//! for the vector data itself, at the cost of bounded reconstruction error.
|
|
9
|
+
//!
|
|
10
|
+
//! This is *scalar* quantization (per-vector min/max), not product
|
|
11
|
+
//! quantization, and it is not SIMD/GPU accelerated — both are explicitly
|
|
12
|
+
//! out of scope here.
|
|
13
|
+
|
|
14
|
+
use serde::{Deserialize, Serialize};
|
|
15
|
+
|
|
16
|
+
/// Parameters needed to dequantize an `i8` vector back to `f32`.
|
|
17
|
+
///
|
|
18
|
+
/// Reconstruction: `f32_value = i8_value as f32 * scale + offset`.
|
|
19
|
+
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
20
|
+
pub struct QuantizationParams {
|
|
21
|
+
/// Multiplier applied to the raw i8 value.
|
|
22
|
+
pub scale: f32,
|
|
23
|
+
/// Additive offset applied after scaling.
|
|
24
|
+
pub offset: f32,
|
|
25
|
+
/// Original minimum value of the vector (kept for introspection).
|
|
26
|
+
pub min: f32,
|
|
27
|
+
/// Original maximum value of the vector (kept for introspection).
|
|
28
|
+
pub max: f32,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Quantize a single `f32` vector into `i8` values plus the parameters
|
|
32
|
+
/// needed to dequantize it.
|
|
33
|
+
///
|
|
34
|
+
/// Uses per-vector min/max scalar quantization: the vector's min and max
|
|
35
|
+
/// are mapped to the full `i8` range (-128..=127), and every other value is
|
|
36
|
+
/// linearly interpolated between them. This is standard for embedding
|
|
37
|
+
/// vectors, where per-vector dynamic range is usually similar across a
|
|
38
|
+
/// batch but not identical.
|
|
39
|
+
///
|
|
40
|
+
/// An empty input returns an empty output with a degenerate
|
|
41
|
+
/// (scale = 0, offset = 0) parameter set.
|
|
42
|
+
///
|
|
43
|
+
/// A constant vector (min == max, including all-zero) maps every value to
|
|
44
|
+
/// `0i8` and reconstructs exactly via `offset` (scale = 0), so round-trip
|
|
45
|
+
/// error is zero in that case.
|
|
46
|
+
pub fn quantize(values: &[f32]) -> (Vec<i8>, QuantizationParams) {
|
|
47
|
+
if values.is_empty() {
|
|
48
|
+
return (
|
|
49
|
+
Vec::new(),
|
|
50
|
+
QuantizationParams {
|
|
51
|
+
scale: 0.0,
|
|
52
|
+
offset: 0.0,
|
|
53
|
+
min: 0.0,
|
|
54
|
+
max: 0.0,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let mut min = values[0];
|
|
60
|
+
let mut max = values[0];
|
|
61
|
+
for &v in values.iter() {
|
|
62
|
+
if v < min {
|
|
63
|
+
min = v;
|
|
64
|
+
}
|
|
65
|
+
if v > max {
|
|
66
|
+
max = v;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Degenerate case: every value identical. Store the value itself as
|
|
71
|
+
// `offset`, use a zero scale, and quantize everything to 0 so
|
|
72
|
+
// dequantization is exact.
|
|
73
|
+
if min == max {
|
|
74
|
+
let params = QuantizationParams {
|
|
75
|
+
scale: 0.0,
|
|
76
|
+
offset: min,
|
|
77
|
+
min,
|
|
78
|
+
max,
|
|
79
|
+
};
|
|
80
|
+
return (vec![0i8; values.len()], params);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Map [min, max] -> [-128, 127] (255 steps).
|
|
84
|
+
let scale = (max - min) / 255.0;
|
|
85
|
+
let offset = min + scale * 128.0; // value that maps to raw i8 = 0
|
|
86
|
+
|
|
87
|
+
let quantized: Vec<i8> = values
|
|
88
|
+
.iter()
|
|
89
|
+
.map(|&v| {
|
|
90
|
+
let raw = ((v - min) / scale).round() - 128.0;
|
|
91
|
+
raw.clamp(-128.0, 127.0) as i8
|
|
92
|
+
})
|
|
93
|
+
.collect();
|
|
94
|
+
|
|
95
|
+
let params = QuantizationParams {
|
|
96
|
+
scale,
|
|
97
|
+
offset,
|
|
98
|
+
min,
|
|
99
|
+
max,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
(quantized, params)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Dequantize `i8` values back into approximate `f32` values using the
|
|
106
|
+
/// scale/offset produced by [`quantize`].
|
|
107
|
+
pub fn dequantize(values: &[i8], params: &QuantizationParams) -> Vec<f32> {
|
|
108
|
+
values
|
|
109
|
+
.iter()
|
|
110
|
+
.map(|&q| q as f32 * params.scale + params.offset)
|
|
111
|
+
.collect()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Quantize a batch of vectors independently (one `QuantizationParams` per
|
|
115
|
+
/// vector). This is the typical entry point for shrinking a whole local
|
|
116
|
+
/// vector index.
|
|
117
|
+
pub fn quantize_batch(vectors: &[Vec<f32>]) -> (Vec<Vec<i8>>, Vec<QuantizationParams>) {
|
|
118
|
+
let mut out_values = Vec::with_capacity(vectors.len());
|
|
119
|
+
let mut out_params = Vec::with_capacity(vectors.len());
|
|
120
|
+
for v in vectors {
|
|
121
|
+
let (q, p) = quantize(v);
|
|
122
|
+
out_values.push(q);
|
|
123
|
+
out_params.push(p);
|
|
124
|
+
}
|
|
125
|
+
(out_values, out_params)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Dequantize a batch of vectors produced by [`quantize_batch`].
|
|
129
|
+
pub fn dequantize_batch(values: &[Vec<i8>], params: &[QuantizationParams]) -> Vec<Vec<f32>> {
|
|
130
|
+
values
|
|
131
|
+
.iter()
|
|
132
|
+
.zip(params.iter())
|
|
133
|
+
.map(|(v, p)| dequantize(v, p))
|
|
134
|
+
.collect()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[cfg(test)]
|
|
138
|
+
mod tests {
|
|
139
|
+
use super::*;
|
|
140
|
+
|
|
141
|
+
/// Deterministic pseudo-random f32 generator (xorshift) so tests don't
|
|
142
|
+
/// need an external `rand` dependency.
|
|
143
|
+
fn pseudo_random_floats(seed: u64, n: usize, lo: f32, hi: f32) -> Vec<f32> {
|
|
144
|
+
let mut state = seed.wrapping_add(0x9E3779B97F4A7C15);
|
|
145
|
+
(0..n)
|
|
146
|
+
.map(|_| {
|
|
147
|
+
// xorshift64
|
|
148
|
+
state ^= state << 13;
|
|
149
|
+
state ^= state >> 7;
|
|
150
|
+
state ^= state << 17;
|
|
151
|
+
let unit = (state % 1_000_000) as f32 / 1_000_000.0; // [0, 1)
|
|
152
|
+
lo + unit * (hi - lo)
|
|
153
|
+
})
|
|
154
|
+
.collect()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn max_abs_error(original: &[f32], reconstructed: &[f32]) -> f32 {
|
|
158
|
+
original
|
|
159
|
+
.iter()
|
|
160
|
+
.zip(reconstructed.iter())
|
|
161
|
+
.map(|(a, b)| (a - b).abs())
|
|
162
|
+
.fold(0.0f32, f32::max)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#[test]
|
|
166
|
+
fn test_round_trip_unit_range() {
|
|
167
|
+
// Typical normalized-embedding range [-1, 1].
|
|
168
|
+
let values = pseudo_random_floats(42, 384, -1.0, 1.0);
|
|
169
|
+
let (quantized, params) = quantize(&values);
|
|
170
|
+
assert_eq!(quantized.len(), values.len());
|
|
171
|
+
|
|
172
|
+
let reconstructed = dequantize(&quantized, ¶ms);
|
|
173
|
+
let err = max_abs_error(&values, &reconstructed);
|
|
174
|
+
|
|
175
|
+
// Expected bound for 8-bit quantization: half a quantization step,
|
|
176
|
+
// i.e. range / 255 (using <= with a small epsilon for float
|
|
177
|
+
// rounding slack).
|
|
178
|
+
let expected_bound = (params.max - params.min) / 255.0;
|
|
179
|
+
assert!(
|
|
180
|
+
err <= expected_bound + 1e-4,
|
|
181
|
+
"max abs error {err} exceeded expected bound {expected_bound}"
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#[test]
|
|
186
|
+
fn test_round_trip_zero_to_one_range() {
|
|
187
|
+
// Typical unnormalized embedding range [0, 1].
|
|
188
|
+
let values = pseudo_random_floats(7, 768, 0.0, 1.0);
|
|
189
|
+
let (quantized, params) = quantize(&values);
|
|
190
|
+
let reconstructed = dequantize(&quantized, ¶ms);
|
|
191
|
+
let err = max_abs_error(&values, &reconstructed);
|
|
192
|
+
|
|
193
|
+
let expected_bound = (params.max - params.min) / 255.0;
|
|
194
|
+
assert!(
|
|
195
|
+
err <= expected_bound + 1e-4,
|
|
196
|
+
"max abs error {err} exceeded expected bound {expected_bound}"
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#[test]
|
|
201
|
+
fn test_round_trip_wide_range() {
|
|
202
|
+
// Wider range to make sure scale/offset math generalizes beyond
|
|
203
|
+
// [-1, 1] / [0, 1].
|
|
204
|
+
let values = pseudo_random_floats(1234, 512, -50.0, 75.0);
|
|
205
|
+
let (quantized, params) = quantize(&values);
|
|
206
|
+
let reconstructed = dequantize(&quantized, ¶ms);
|
|
207
|
+
let err = max_abs_error(&values, &reconstructed);
|
|
208
|
+
|
|
209
|
+
let expected_bound = (params.max - params.min) / 255.0;
|
|
210
|
+
assert!(
|
|
211
|
+
err <= expected_bound + 1e-4,
|
|
212
|
+
"max abs error {err} exceeded expected bound {expected_bound}"
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[test]
|
|
217
|
+
fn test_all_zero_vector_is_exact() {
|
|
218
|
+
let values = vec![0.0f32; 128];
|
|
219
|
+
let (quantized, params) = quantize(&values);
|
|
220
|
+
assert!(quantized.iter().all(|&q| q == 0));
|
|
221
|
+
|
|
222
|
+
let reconstructed = dequantize(&quantized, ¶ms);
|
|
223
|
+
let err = max_abs_error(&values, &reconstructed);
|
|
224
|
+
assert_eq!(err, 0.0, "all-zero vector should round-trip exactly");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[test]
|
|
228
|
+
fn test_single_value_constant_vector_is_exact() {
|
|
229
|
+
let values = vec![3.5f32; 64];
|
|
230
|
+
let (quantized, params) = quantize(&values);
|
|
231
|
+
let reconstructed = dequantize(&quantized, ¶ms);
|
|
232
|
+
let err = max_abs_error(&values, &reconstructed);
|
|
233
|
+
assert_eq!(err, 0.0, "constant vector should round-trip exactly");
|
|
234
|
+
assert!(reconstructed.iter().all(|&v| v == 3.5));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[test]
|
|
238
|
+
fn test_single_element_vector() {
|
|
239
|
+
let values = vec![-0.234f32];
|
|
240
|
+
let (quantized, params) = quantize(&values);
|
|
241
|
+
assert_eq!(quantized.len(), 1);
|
|
242
|
+
let reconstructed = dequantize(&quantized, ¶ms);
|
|
243
|
+
// A single element is trivially min == max -> exact round trip.
|
|
244
|
+
assert_eq!(reconstructed[0], values[0]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#[test]
|
|
248
|
+
fn test_empty_vector() {
|
|
249
|
+
let values: Vec<f32> = Vec::new();
|
|
250
|
+
let (quantized, params) = quantize(&values);
|
|
251
|
+
assert!(quantized.is_empty());
|
|
252
|
+
let reconstructed = dequantize(&quantized, ¶ms);
|
|
253
|
+
assert!(reconstructed.is_empty());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn test_extremes_map_to_i8_bounds() {
|
|
258
|
+
let values = vec![-1.0f32, 0.0, 0.5, 1.0];
|
|
259
|
+
let (quantized, _params) = quantize(&values);
|
|
260
|
+
assert_eq!(*quantized.first().unwrap(), -128);
|
|
261
|
+
assert_eq!(*quantized.last().unwrap(), 127);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn test_memory_footprint_is_4x_smaller() {
|
|
266
|
+
// i8 storage is 1 byte per dim vs 4 bytes for f32 -- this is the
|
|
267
|
+
// whole point of scalar quantization for large local indices.
|
|
268
|
+
let values = pseudo_random_floats(99, 1536, -1.0, 1.0);
|
|
269
|
+
let (quantized, _params) = quantize(&values);
|
|
270
|
+
|
|
271
|
+
let f32_bytes = values.len() * std::mem::size_of::<f32>();
|
|
272
|
+
let i8_bytes = quantized.len() * std::mem::size_of::<i8>();
|
|
273
|
+
|
|
274
|
+
assert_eq!(f32_bytes, i8_bytes * 4);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#[test]
|
|
278
|
+
fn test_quantize_batch_round_trip() {
|
|
279
|
+
let vectors = vec![
|
|
280
|
+
pseudo_random_floats(1, 256, -1.0, 1.0),
|
|
281
|
+
pseudo_random_floats(2, 256, 0.0, 1.0),
|
|
282
|
+
vec![0.0; 256],
|
|
283
|
+
];
|
|
284
|
+
let (quantized, params) = quantize_batch(&vectors);
|
|
285
|
+
let reconstructed = dequantize_batch(&quantized, ¶ms);
|
|
286
|
+
|
|
287
|
+
for (orig, recon) in vectors.iter().zip(reconstructed.iter()) {
|
|
288
|
+
let err = max_abs_error(orig, recon);
|
|
289
|
+
let bound = {
|
|
290
|
+
let min = orig.iter().cloned().fold(f32::INFINITY, f32::min);
|
|
291
|
+
let max = orig.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
|
292
|
+
(max - min) / 255.0
|
|
293
|
+
};
|
|
294
|
+
assert!(
|
|
295
|
+
err <= bound + 1e-4,
|
|
296
|
+
"batch round-trip error {err} exceeded bound {bound}"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
pyvectorhound-1.4.0/src/lib.rs
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
// PyHound Core - Rust implementation of retrieval diagnostics
|
|
2
|
-
//
|
|
3
|
-
// High-performance embedding quality metrics, pipeline analysis, and diagnostics.
|
|
4
|
-
|
|
5
|
-
mod metrics;
|
|
6
|
-
mod retrieval_ranking;
|
|
7
|
-
|
|
8
|
-
use metrics::{
|
|
9
|
-
compute_coverage, compute_distinctiveness, compute_isotropy, compute_retrieval_metrics,
|
|
10
|
-
detect_drift,
|
|
11
|
-
};
|
|
12
|
-
use pyo3::prelude::*;
|
|
13
|
-
use pyo3::types::PyDict;
|
|
14
|
-
use pyo3::Bound;
|
|
15
|
-
|
|
16
|
-
/// Compute isotropy of embeddings (vector space utilization)
|
|
17
|
-
#[pyfunction]
|
|
18
|
-
fn py_compute_isotropy(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
|
|
19
|
-
Ok(compute_isotropy(&embeddings))
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/// Compute coverage of embeddings (diversity of semantic space)
|
|
23
|
-
#[pyfunction]
|
|
24
|
-
fn py_compute_coverage(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
|
|
25
|
-
Ok(compute_coverage(&embeddings))
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/// Compute distinctiveness of embeddings (semantic separation)
|
|
29
|
-
#[pyfunction]
|
|
30
|
-
fn py_compute_distinctiveness(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
|
|
31
|
-
Ok(compute_distinctiveness(&embeddings))
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/// Detect drift in embedding quality
|
|
35
|
-
#[pyfunction]
|
|
36
|
-
fn py_detect_drift(
|
|
37
|
-
baseline_embeddings: Vec<Vec<f32>>,
|
|
38
|
-
current_embeddings: Vec<Vec<f32>>,
|
|
39
|
-
) -> PyResult<f32> {
|
|
40
|
-
Ok(detect_drift(&baseline_embeddings, ¤t_embeddings))
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/// Compute retrieval metrics (precision, recall, F1, MRR, NDCG)
|
|
44
|
-
#[pyfunction]
|
|
45
|
-
fn py_compute_retrieval_metrics(
|
|
46
|
-
py: Python,
|
|
47
|
-
retrieved: Vec<usize>,
|
|
48
|
-
relevant: Vec<usize>,
|
|
49
|
-
top_k: usize,
|
|
50
|
-
) -> PyResult<PyObject> {
|
|
51
|
-
let metrics = compute_retrieval_metrics(&retrieved, &relevant, top_k);
|
|
52
|
-
|
|
53
|
-
let dict = PyDict::new(py);
|
|
54
|
-
dict.set_item("precision", metrics.precision)?;
|
|
55
|
-
dict.set_item("recall", metrics.recall)?;
|
|
56
|
-
dict.set_item("f1_score", metrics.f1_score)?;
|
|
57
|
-
dict.set_item("mrr", metrics.mrr)?;
|
|
58
|
-
dict.set_item("ndcg", metrics.ndcg)?;
|
|
59
|
-
|
|
60
|
-
Ok(dict.into())
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/// Compute embedding quality score (composite metric)
|
|
64
|
-
#[pyfunction]
|
|
65
|
-
fn py_compute_quality_score(embeddings: Vec<Vec<f32>>) -> PyResult<f32> {
|
|
66
|
-
if embeddings.is_empty() {
|
|
67
|
-
return Ok(0.0);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
let isotropy = compute_isotropy(&embeddings);
|
|
71
|
-
let coverage = compute_coverage(&embeddings);
|
|
72
|
-
let distinctiveness = compute_distinctiveness(&embeddings);
|
|
73
|
-
|
|
74
|
-
// Composite score: weighted average
|
|
75
|
-
let quality = (isotropy * 0.4 + coverage * 0.3 + distinctiveness * 0.3).clamp(0.0, 1.0);
|
|
76
|
-
|
|
77
|
-
Ok(quality)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/// PyHound Python module
|
|
81
|
-
#[pymodule]
|
|
82
|
-
fn _core(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
83
|
-
m.add_function(wrap_pyfunction!(py_compute_isotropy, m)?)?;
|
|
84
|
-
m.add_function(wrap_pyfunction!(py_compute_coverage, m)?)?;
|
|
85
|
-
m.add_function(wrap_pyfunction!(py_compute_distinctiveness, m)?)?;
|
|
86
|
-
m.add_function(wrap_pyfunction!(py_detect_drift, m)?)?;
|
|
87
|
-
m.add_function(wrap_pyfunction!(py_compute_retrieval_metrics, m)?)?;
|
|
88
|
-
m.add_function(wrap_pyfunction!(py_compute_quality_score, m)?)?;
|
|
89
|
-
|
|
90
|
-
Ok(())
|
|
91
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|