Skip to content

Commit 8bc85fb

Browse files
authored
Merge pull request #23 from pavanjava/qql14
new quantization implementation
2 parents aacdaa5 + 1921755 commit 8bc85fb

9 files changed

Lines changed: 306 additions & 23 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![MIT License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
88
[![Tests](https://img.shields.io/badge/tests-375%20passing-brightgreen)](tests/)
99

10-
Write `INSERT`, `SEARCH`, `RECOMMEND`, `DELETE`, and `CREATE COLLECTION` statements instead of Python SDK calls. Supports hybrid dense+sparse vector search, cross-encoder reranking, quantization (scalar, binary, product), SQL-style `WHERE` filters, script execution, and collection dump/restore.
10+
Write `INSERT`, `SEARCH`, `RECOMMEND`, `DELETE`, and `CREATE COLLECTION` statements instead of Python SDK calls. Supports hybrid dense+sparse vector search, cross-encoder reranking, quantization (scalar, turbo, binary, product), SQL-style `WHERE` filters, script execution, and collection dump/restore.
1111

1212
```
1313
qql> INSERT INTO COLLECTION notes VALUES {'text': 'Qdrant is a vector database', 'author': 'alice', 'year': 2024}
@@ -84,7 +84,7 @@ Full documentation lives in the [`docs/`](docs/) folder and at **[pavanjava.gith
8484
| [INSERT / INSERT BULK](docs/insert.md) | Adding documents, batch inserts, payload types |
8585
| [SEARCH / RECOMMEND / Hybrid / RERANK](docs/search.md) | Semantic search, hybrid, reranking, recommendations |
8686
| [WHERE Filters](docs/filters.md) | Full SQL-style filter operators |
87-
| [Collections & Quantization](docs/collections.md) | CREATE, DROP, QUANTIZE (scalar/binary/product), CREATE INDEX |
87+
| [Collections & Quantization](docs/collections.md) | CREATE, DROP, QUANTIZE (scalar/turbo/binary/product), CREATE INDEX |
8888
| [Scripts: EXECUTE / DUMP](docs/scripts.md) | Script files, collection backup/restore |
8989
| [Programmatic Usage](docs/programmatic.md) | Use QQL as a Python library |
9090
| [Reference: Models / Config / Errors](docs/reference.md) | Embedding models, config file, error reference |
@@ -111,6 +111,9 @@ RECOMMEND FROM articles POSITIVE IDS (1001, 1002) LIMIT 5
111111
CREATE COLLECTION articles
112112
CREATE COLLECTION articles HYBRID
113113
CREATE COLLECTION articles QUANTIZE SCALAR
114+
CREATE COLLECTION articles QUANTIZE TURBO
115+
CREATE COLLECTION articles QUANTIZE TURBO BITS 2
116+
CREATE COLLECTION articles QUANTIZE TURBO BITS 1.5 ALWAYS RAM
114117
CREATE INDEX ON COLLECTION articles FOR year TYPE integer
115118
SHOW COLLECTIONS
116119
DROP COLLECTION articles

docs/collections.md

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,27 +67,38 @@ When `USING MODEL` is omitted, the collection uses the **default embedding model
6767

6868
## Quantization — QUANTIZE clause
6969

70-
Quantization reduces the memory footprint of vector collections and speeds up search at the cost of a small, controllable accuracy loss. QQL supports all three Qdrant quantization strategies via an optional `QUANTIZE` clause appended to `CREATE COLLECTION`.
70+
Quantization reduces the memory footprint of vector collections and speeds up search at the cost of a small, controllable accuracy loss. QQL supports all four Qdrant quantization strategies via an optional `QUANTIZE` clause appended to `CREATE COLLECTION`.
7171

72-
**Three strategies:**
72+
**Four strategies:**
7373

74-
| Type | Compression | Accuracy Loss | Best For |
74+
| Type | Compression | Accuracy | Best For |
7575
|---|---|---|---|
76-
| `SCALAR` | 4× (float32 → int8) | < 1% | Most collections — best balance |
77-
| `BINARY` | 32× (float32 → 1-bit) | Higher | High-dimensional vectors (768+), speed priority |
76+
| `SCALAR` | 4× (float32 → int8) | < 1% loss | Most collections — best balance |
77+
| `TURBO` | 8–32× (4-bit to 1-bit) | Low–medium | Better recall than BINARY at same storage budget |
78+
| `BINARY` | 32× (float32 → 1-bit) | Higher loss | Speed priority; centered distributions only |
7879
| `PRODUCT` | 4× (configurable) | Variable | Memory-constrained deployments |
7980

8081
**Full syntax:**
8182
```
8283
CREATE COLLECTION <name> ... QUANTIZE SCALAR [QUANTILE <0.0–1.0>] [ALWAYS RAM]
84+
CREATE COLLECTION <name> ... QUANTIZE TURBO [BITS <1|1.5|2|4>] [ALWAYS RAM]
8385
CREATE COLLECTION <name> ... QUANTIZE BINARY [ALWAYS RAM]
8486
CREATE COLLECTION <name> ... QUANTIZE PRODUCT [ALWAYS RAM]
8587
```
8688

87-
- **`QUANTILE <float>`** — (scalar only) calibration quantile for the INT8 conversion; defaults to Qdrant's built-in default (0.99) when omitted.
88-
- **`ALWAYS RAM`** — keep the **quantized** vectors in RAM at all times, regardless of the collection's `on_disk` setting. Improves search throughput at the cost of higher RAM usage for the compressed index. The original full-precision vectors are stored and managed independently of this flag. Supported by all three quantization types.
89+
- **`QUANTILE <float>`** — (SCALAR only) calibration quantile for the INT8 conversion; defaults to Qdrant's built-in default (0.99) when omitted.
90+
- **`BITS <depth>`** — (TURBO only) bit depth passed to the Qdrant SDK:
91+
- `4` — 4-bit (default when `BITS` is omitted; server applies its own default)
92+
- `2` — 2-bit
93+
- `1.5` — 1.5-bit
94+
- `1` — 1-bit
95+
> Compression ratios (8×, 16×, 24×, 32×) and recall characteristics are
96+
> Qdrant server-side behaviors. QQL maps the `BITS` value to the SDK model and
97+
> passes it to Qdrant; actual results depend on your Qdrant server version.
98+
- **`ALWAYS RAM`** — keep the **quantized** vectors in RAM at all times, regardless of the collection's `on_disk` setting. Improves search throughput at the cost of higher RAM usage for the compressed index. The original full-precision vectors are stored and managed independently of this flag. Supported by all four quantization types.
8999
- **`QUANTIZE`** always appears **after** all other clauses (`HYBRID`, `USING MODEL`, etc.).
90100
- For `PRODUCT`, the compression ratio is fixed at **** in this version.
101+
- For `TURBO`, Cosine, Dot, and Euclidean distance are supported by the Qdrant server when TurboQuant is enabled.
91102
- When used with `HYBRID` collections, quantization applies only to the **dense** vector.
92103

93104
**Examples:**
@@ -102,6 +113,26 @@ Scalar with explicit calibration and quantized vectors pinned to RAM:
102113
CREATE COLLECTION research_papers QUANTIZE SCALAR QUANTILE 0.95 ALWAYS RAM
103114
```
104115

116+
TurboQuant — default 4-bit (8× compression, good recall):
117+
```sql
118+
CREATE COLLECTION research_papers QUANTIZE TURBO
119+
```
120+
121+
TurboQuant — 2-bit (16× compression):
122+
```sql
123+
CREATE COLLECTION research_papers QUANTIZE TURBO BITS 2
124+
```
125+
126+
TurboQuant — 1.5-bit (24× compression) with quantized vectors pinned to RAM:
127+
```sql
128+
CREATE COLLECTION research_papers QUANTIZE TURBO BITS 1.5 ALWAYS RAM
129+
```
130+
131+
TurboQuant — 1-bit (32× compression, same ratio as BINARY but better recall):
132+
```sql
133+
CREATE COLLECTION research_papers QUANTIZE TURBO BITS 1
134+
```
135+
105136
Binary quantization for large high-dimensional embeddings:
106137
```sql
107138
CREATE COLLECTION research_papers QUANTIZE BINARY
@@ -115,22 +146,29 @@ CREATE COLLECTION research_papers QUANTIZE PRODUCT ALWAYS RAM
115146
Combined with hybrid collection:
116147
```sql
117148
CREATE COLLECTION research_papers HYBRID QUANTIZE SCALAR
149+
CREATE COLLECTION research_papers HYBRID QUANTIZE TURBO BITS 2
118150
```
119151

120152
Combined with a pinned model:
121153
```sql
122154
CREATE COLLECTION research_papers USING MODEL 'BAAI/bge-base-en-v1.5' QUANTIZE SCALAR QUANTILE 0.99
155+
CREATE COLLECTION research_papers USING MODEL 'BAAI/bge-base-en-v1.5' QUANTIZE TURBO BITS 2
156+
```
157+
158+
Combined with hybrid + dense model:
159+
```sql
160+
CREATE COLLECTION research_papers USING HYBRID DENSE MODEL 'BAAI/bge-base-en-v1.5' QUANTIZE TURBO
123161
```
124162

125163
**Valid combinations:**
126164

127-
| Base form | + QUANTIZE SCALAR | + QUANTIZE BINARY | + QUANTIZE PRODUCT |
128-
|---|---|---|---|
129-
| `CREATE COLLECTION name` ||||
130-
| `... HYBRID` ||||
131-
| `... USING MODEL 'x'` ||||
132-
| `... USING HYBRID` ||||
133-
| `... USING HYBRID DENSE MODEL 'x'` ||||
165+
| Base form | + SCALAR | + TURBO | + BINARY | + PRODUCT |
166+
|---|---|---|---|---|
167+
| `CREATE COLLECTION name` |||||
168+
| `... HYBRID` |||||
169+
| `... USING MODEL 'x'` |||||
170+
| `... USING HYBRID` |||||
171+
| `... USING HYBRID DENSE MODEL 'x'` |||||
134172

135173
> INSERT and SEARCH on quantized collections work exactly the same as on non-quantized ones — no changes to INSERT or SEARCH syntax are needed.
136174

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "qql-cli"
3-
version = "2.0.0"
4-
description = "QQL is a SQL-like query language and CLI for Qdrant vector database. Write INSERT, SEARCH, RECOMMEND, DELETE, and CREATE COLLECTION statements instead of Python SDK calls. Supports hybrid dense+sparse vector search, cross-encoder reranking, quantization (scalar, binary, product), WHERE clause filters, script execution, and collection dump/restore."
3+
version = "2.1.0"
4+
description = "QQL is a SQL-like query language and CLI for Qdrant vector database. Write INSERT, SEARCH, RECOMMEND, DELETE, and CREATE COLLECTION statements instead of Python SDK calls. Supports hybrid dense+sparse vector search, cross-encoder reranking, quantization (scalar, turbo, binary, product), WHERE clause filters, script execution, and collection dump/restore."
55
readme = "README.md"
66
license = { file = "LICENSE" }
77
requires-python = ">=3.12"
@@ -37,7 +37,7 @@ classifiers = [
3737
"Topic :: Text Processing :: Indexing",
3838
]
3939
dependencies = [
40-
"qdrant-client[fastembed]>=1.13.0",
40+
"qdrant-client[fastembed]>=1.18.0",
4141
"click>=8.1.0",
4242
"rich>=13.0.0",
4343
"prompt_toolkit>=3.0.0",

src/qql/ast_nodes.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ class QuantizationType(Enum):
99
SCALAR = "scalar"
1010
BINARY = "binary"
1111
PRODUCT = "product"
12+
TURBO = "turbo"
1213

1314

1415
@dataclass(frozen=True)
1516
class QuantizationConfig:
1617
"""Quantization settings parsed from a QUANTIZE clause."""
1718
type: QuantizationType
18-
quantile: float | None = None # SCALAR only; None → Qdrant default (0.99)
19-
always_ram: bool = False # all types; default False
19+
quantile: float | None = None # SCALAR only; None → Qdrant default (0.99)
20+
always_ram: bool = False # all types; default False
21+
turbo_bits: float | None = None # TURBO only; None → bits4 (Qdrant default 4-bit, 8×)
2022

2123

2224
@dataclass(frozen=True)

src/qql/executor.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
ScalarQuantization,
4242
ScalarQuantizationConfig,
4343
ScalarType,
44+
TurboQuantBitSize,
45+
TurboQuantization,
46+
TurboQuantQuantizationConfig,
4447
SearchParams,
4548
SparseVector,
4649
SparseVectorParams,
@@ -846,7 +849,7 @@ def _wrap_as_filter(self, qdrant_expr: Any) -> Filter:
846849

847850
def _build_quantization_config(
848851
self, qc: QuantizationConfig
849-
) -> ScalarQuantization | BinaryQuantization | ProductQuantization:
852+
) -> ScalarQuantization | BinaryQuantization | ProductQuantization | TurboQuantization:
850853
"""Convert a parsed QuantizationConfig to a Qdrant SDK quantization object."""
851854
if qc.type == QuantizationType.SCALAR:
852855
return ScalarQuantization(
@@ -867,6 +870,28 @@ def _build_quantization_config(
867870
always_ram=qc.always_ram,
868871
)
869872
)
873+
if qc.type == QuantizationType.TURBO:
874+
_BITS_MAP: dict[float, TurboQuantBitSize] = {
875+
4.0: TurboQuantBitSize.BITS4,
876+
2.0: TurboQuantBitSize.BITS2,
877+
1.5: TurboQuantBitSize.BITS1_5,
878+
1.0: TurboQuantBitSize.BITS1,
879+
}
880+
if qc.turbo_bits is None:
881+
bits_enum = None # user omitted BITS → preserve None, server applies default
882+
elif qc.turbo_bits in _BITS_MAP:
883+
bits_enum = _BITS_MAP[qc.turbo_bits]
884+
else:
885+
raise QQLRuntimeError(
886+
f"Unsupported TURBO bit depth: {qc.turbo_bits}. "
887+
f"Valid values: 1, 1.5, 2, 4"
888+
)
889+
return TurboQuantization(
890+
turbo=TurboQuantQuantizationConfig(
891+
bits=bits_enum,
892+
always_ram=qc.always_ram,
893+
)
894+
)
870895
raise QQLRuntimeError(f"Unknown quantization type: {qc.type}")
871896

872897
def _collection_is_hybrid(self, name: str) -> bool:

src/qql/lexer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ class TokenKind(Enum):
2727
QUANTILE = auto()
2828
ALWAYS = auto()
2929
RAM = auto()
30+
TURBO = auto()
31+
BITS = auto()
3032
CREATE = auto()
3133
INDEX = auto()
3234
ON = auto()
@@ -113,6 +115,8 @@ class TokenKind(Enum):
113115
"QUANTILE": TokenKind.QUANTILE,
114116
"ALWAYS": TokenKind.ALWAYS,
115117
"RAM": TokenKind.RAM,
118+
"TURBO": TokenKind.TURBO,
119+
"BITS": TokenKind.BITS,
116120
"CREATE": TokenKind.CREATE,
117121
"INDEX": TokenKind.INDEX,
118122
"ON": TokenKind.ON,

src/qql/parser.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,32 @@ def _parse_quantize_clause(self) -> QuantizationConfig:
248248
always_ram = True
249249
return QuantizationConfig(type=QuantizationType.PRODUCT, always_ram=always_ram)
250250

251+
if tok.kind == TokenKind.TURBO:
252+
self._advance()
253+
turbo_bits: float | None = None
254+
always_ram = False
255+
if self._peek().kind == TokenKind.BITS:
256+
self._advance()
257+
bits_tok = self._peek()
258+
raw = float(self._parse_number())
259+
if raw not in (1.0, 1.5, 2.0, 4.0):
260+
raise QQLSyntaxError(
261+
f"BITS must be one of 1, 1.5, 2, or 4 for TURBO quantization, got {raw}",
262+
bits_tok.pos,
263+
)
264+
turbo_bits = raw
265+
if self._peek().kind == TokenKind.ALWAYS:
266+
self._advance()
267+
self._expect(TokenKind.RAM)
268+
always_ram = True
269+
return QuantizationConfig(
270+
type=QuantizationType.TURBO,
271+
turbo_bits=turbo_bits,
272+
always_ram=always_ram,
273+
)
274+
251275
raise QQLSyntaxError(
252-
f"Expected SCALAR, BINARY, or PRODUCT after QUANTIZE, got '{tok.value}'",
276+
f"Expected SCALAR, BINARY, PRODUCT, or TURBO after QUANTIZE, got '{tok.value}'",
253277
tok.pos,
254278
)
255279

tests/test_executor.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,3 +1640,110 @@ def test_result_message_no_quantization_suffix_when_absent(self, executor, mock_
16401640
node = CreateCollectionStmt(collection="articles")
16411641
result = executor.execute(node)
16421642
assert "quantization" not in result.message
1643+
1644+
1645+
class TestTurboQuantCreate:
1646+
"""Executor tests for QUANTIZE TURBO — verifies correct SDK objects are built."""
1647+
1648+
@pytest.fixture
1649+
def executor(self, cfg, mock_client):
1650+
return Executor(mock_client, cfg)
1651+
1652+
# ── TurboQuantization object is produced ──────────────────────────────
1653+
1654+
def test_turbo_passes_turbo_quantization(self, executor, mock_client):
1655+
from qdrant_client.models import TurboQuantization
1656+
node = CreateCollectionStmt(
1657+
collection="articles",
1658+
quantization=QuantizationConfig(type=QuantizationType.TURBO),
1659+
)
1660+
executor.execute(node)
1661+
kw = mock_client.create_collection.call_args.kwargs
1662+
assert isinstance(kw.get("quantization_config"), TurboQuantization)
1663+
1664+
def test_turbo_default_bits_is_none(self, executor, mock_client):
1665+
"""When BITS is omitted, bits must be None — preserving omission so the
1666+
SDK/server applies its own default rather than QQL forcing BITS4."""
1667+
node = CreateCollectionStmt(
1668+
collection="articles",
1669+
quantization=QuantizationConfig(type=QuantizationType.TURBO),
1670+
)
1671+
executor.execute(node)
1672+
kw = mock_client.create_collection.call_args.kwargs
1673+
assert kw["quantization_config"].turbo.bits is None
1674+
1675+
def test_turbo_bits2(self, executor, mock_client):
1676+
from qdrant_client.models import TurboQuantBitSize
1677+
node = CreateCollectionStmt(
1678+
collection="articles",
1679+
quantization=QuantizationConfig(type=QuantizationType.TURBO, turbo_bits=2.0),
1680+
)
1681+
executor.execute(node)
1682+
kw = mock_client.create_collection.call_args.kwargs
1683+
assert kw["quantization_config"].turbo.bits == TurboQuantBitSize.BITS2
1684+
1685+
def test_turbo_bits1_5(self, executor, mock_client):
1686+
from qdrant_client.models import TurboQuantBitSize
1687+
node = CreateCollectionStmt(
1688+
collection="articles",
1689+
quantization=QuantizationConfig(type=QuantizationType.TURBO, turbo_bits=1.5),
1690+
)
1691+
executor.execute(node)
1692+
kw = mock_client.create_collection.call_args.kwargs
1693+
assert kw["quantization_config"].turbo.bits == TurboQuantBitSize.BITS1_5
1694+
1695+
def test_turbo_bits1(self, executor, mock_client):
1696+
from qdrant_client.models import TurboQuantBitSize
1697+
node = CreateCollectionStmt(
1698+
collection="articles",
1699+
quantization=QuantizationConfig(type=QuantizationType.TURBO, turbo_bits=1.0),
1700+
)
1701+
executor.execute(node)
1702+
kw = mock_client.create_collection.call_args.kwargs
1703+
assert kw["quantization_config"].turbo.bits == TurboQuantBitSize.BITS1
1704+
1705+
def test_turbo_always_ram_true(self, executor, mock_client):
1706+
node = CreateCollectionStmt(
1707+
collection="articles",
1708+
quantization=QuantizationConfig(type=QuantizationType.TURBO, always_ram=True),
1709+
)
1710+
executor.execute(node)
1711+
kw = mock_client.create_collection.call_args.kwargs
1712+
assert kw["quantization_config"].turbo.always_ram is True
1713+
1714+
def test_turbo_always_ram_false_by_default(self, executor, mock_client):
1715+
node = CreateCollectionStmt(
1716+
collection="articles",
1717+
quantization=QuantizationConfig(type=QuantizationType.TURBO),
1718+
)
1719+
executor.execute(node)
1720+
kw = mock_client.create_collection.call_args.kwargs
1721+
assert kw["quantization_config"].turbo.always_ram is False
1722+
1723+
def test_turbo_hybrid_collection_has_both_configs(self, executor, mock_client):
1724+
from qdrant_client.models import TurboQuantization
1725+
node = CreateCollectionStmt(
1726+
collection="articles",
1727+
hybrid=True,
1728+
quantization=QuantizationConfig(type=QuantizationType.TURBO),
1729+
)
1730+
executor.execute(node)
1731+
kw = mock_client.create_collection.call_args.kwargs
1732+
assert isinstance(kw.get("quantization_config"), TurboQuantization)
1733+
assert "sparse_vectors_config" in kw
1734+
1735+
def test_turbo_result_message_includes_turbo(self, executor, mock_client):
1736+
node = CreateCollectionStmt(
1737+
collection="articles",
1738+
quantization=QuantizationConfig(type=QuantizationType.TURBO),
1739+
)
1740+
result = executor.execute(node)
1741+
assert "turbo" in result.message
1742+
1743+
def test_turbo_invalid_bits_at_executor_raises(self, executor, mock_client):
1744+
"""An unexpected turbo_bits value that bypasses parser validation must
1745+
raise QQLRuntimeError explicitly instead of silently coercing to BITS4."""
1746+
from qql.exceptions import QQLRuntimeError as QQLErr
1747+
qc = QuantizationConfig(type=QuantizationType.TURBO, turbo_bits=3.0)
1748+
with pytest.raises(QQLErr, match="Unsupported TURBO bit depth"):
1749+
executor._build_quantization_config(qc)

0 commit comments

Comments
 (0)