Skip to content

Commit 4a1fa92

Browse files
pavanjavasrimon12
andauthored
Feat/refractor batch grpc async support (#48)
* feat: add async execution and batched programmatic APIs Add async QQL support with AsyncConnection and AsyncExecutor, plus sync and async batching helpers for running multiple statements through one programmatic API. Introduce BEGIN BATCH syntax, parameterized query helpers, and optional gRPC connection settings. Refactor shared parser and executor logic into qql.utils so sync and async paths can reuse filter conversion, vector shaping, topology parsing, batch grouping, and search parsing helpers. Update tests and docs for async usage, batching, parameterized queries, gRPC configuration, and batch block execution. * feat: enhance batch processing with strict result count checks and null handling * feat: enhance batch processing and query handling with improved error management and null support * feat: enhance InExpr and NotInExpr handling with null support in filter building --------- Co-authored-by: Srimon <srimon12mckv@gmail.com>
1 parent 4a67ba6 commit 4a1fa92

19 files changed

Lines changed: 3848 additions & 461 deletions

README.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
[![PyPI version](https://img.shields.io/pypi/v/qql-cli?color=blue&label=PyPI)](https://pypi.org/project/qql-cli/)
66
[![Python 3.12+](https://img.shields.io/pypi/pyversions/qql-cli)](https://pypi.org/project/qql-cli/)
77
[![MIT License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
8-
[![Tests](https://img.shields.io/badge/tests-549%20passing-brightgreen)](tests/)
8+
[![Tests](https://img.shields.io/badge/tests-635%20passing-brightgreen)](tests/)
99

10-
Write `INSERT`, `SELECT`, `SEARCH`, `SCROLL`, `RECOMMEND`, `UPDATE`, `DELETE`, and `CREATE COLLECTION` statements instead of Python SDK calls. Supports hybrid dense+sparse vector search, grouped search (GROUP BY), cross-encoder reranking, quantization (scalar, turbo, binary, product), SQL-style `WHERE` filters, script execution, and collection dump/restore.
10+
Write `INSERT`, `SELECT`, `SEARCH`, `SCROLL`, `RECOMMEND`, `UPDATE`, `DELETE`, and `CREATE COLLECTION` statements instead of Python SDK calls. Supports hybrid dense+sparse vector search, grouped search (GROUP BY), cross-encoder reranking, quantization (scalar, turbo, binary, product), SQL-style `WHERE` filters, script execution, collection dump/restore, async execution, gRPC transport, parameterized queries, and batched query execution.
1111

1212
```
1313
qql> INSERT INTO COLLECTION notes VALUES {'text': 'Qdrant is a vector database', 'author': 'alice', 'year': 2024}
@@ -50,16 +50,23 @@ Your query string
5050

5151
When you run `INSERT`, the `text` field is automatically converted into a dense vector using [Fastembed](https://github.com/qdrant/fastembed). In **hybrid mode** (`USING HYBRID`), a sparse BM25 vector is also generated alongside the dense vector, and searches use Qdrant's Reciprocal Rank Fusion (RRF) by default to merge the results of both retrieval methods. You can switch hybrid search to DBSF with `FUSION 'dbsf'`.
5252

53-
QQL also exposes a **programmatic API** for use inside Python applications — no CLI required:
53+
QQL also exposes a **programmatic API** for use inside Python applications — no CLI required. Use `Connection` for sync code, `AsyncConnection` for async apps, and batch helpers when you want QQL to combine compatible operations into fewer Qdrant requests:
5454

5555
```python
56-
from qql import Connection
56+
from qql import Connection, QQLBatch
5757

5858
with Connection("http://localhost:6333") as conn:
5959
conn.run_query("INSERT INTO COLLECTION notes VALUES {'text': 'Qdrant is fast'}")
60-
result = conn.run_query("SEARCH notes SIMILAR TO 'vector database' LIMIT 5")
61-
for hit in result.data:
62-
print(hit["score"], hit["payload"])
60+
result = conn.run_parameterized_query(
61+
"SEARCH notes SIMILAR TO :query LIMIT 5",
62+
{"query": "vector database"},
63+
)
64+
65+
with QQLBatch(conn) as batch:
66+
neurology = batch.add("SEARCH notes SIMILAR TO 'neurology' LIMIT 5")
67+
cardiology = batch.add("SEARCH notes SIMILAR TO 'cardiology' LIMIT 5")
68+
69+
print(neurology.result.data, cardiology.result.data)
6370
```
6471

6572
---
@@ -103,8 +110,8 @@ Full documentation lives in the [`docs/`](docs/) folder and at **[pavanjava.gith
103110
| [SEARCH / SELECT / SCROLL / RECOMMEND / Hybrid / GROUP BY / RERANK](docs/search.md) | Semantic search, grouped search, point retrieval, pagination, hybrid, reranking, recommendations |
104111
| [WHERE Filters](docs/filters.md) | Full SQL-style filter operators |
105112
| [Collections & Quantization](docs/collections.md) | SHOW, CREATE, DROP, QUANTIZE (scalar/turbo/binary/product), CREATE INDEX, UPDATE VECTOR, UPDATE PAYLOAD |
106-
| [Scripts: EXECUTE / DUMP](docs/scripts.md) | Script files, collection backup/restore |
107-
| [Programmatic Usage](docs/programmatic.md) | Use QQL as a Python library via `Connection` or `run_query()` |
113+
| [Scripts: EXECUTE / DUMP](docs/scripts.md) | Script files, `BEGIN BATCH` blocks, collection backup/restore |
114+
| [Programmatic Usage](docs/programmatic.md) | Sync/async Python APIs, parameterized queries, batching, gRPC |
108115
| [Reference: Models / Config / Errors](docs/reference.md) | Embedding models, config file, error reference |
109116

110117
---
@@ -176,6 +183,12 @@ DELETE FROM articles WHERE year < 2020
176183
-- Scripts
177184
EXECUTE /path/to/script.qql
178185
DUMP articles /path/to/backup.qql
186+
187+
-- Batch block
188+
BEGIN BATCH;
189+
SEARCH articles SIMILAR TO 'query one' LIMIT 5;
190+
SEARCH articles SIMILAR TO 'query two' LIMIT 5;
191+
END BATCH
179192
```
180193

181194
---
@@ -188,7 +201,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
188201
pytest tests/ -v
189202
```
190203

191-
Expected: **549 tests passing**.
204+
Expected: **635 tests passing**.
192205

193206
---
194207

docs/getting-started.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ title: "Getting Started"
55

66
# Getting Started with QQL
77

8-
QQL is a SQL-like query language and CLI for [Qdrant](https://qdrant.tech). Instead of writing Python SDK calls you write natural query statements to insert, search, manage, and delete vector data.
8+
QQL is a SQL-like query language and CLI for [Qdrant](https://qdrant.tech). Instead of writing Python SDK calls you write natural query statements to insert, search, manage, and delete vector data. It can also be used as a sync or async Python library with batching, parameterized queries, and optional gRPC transport.
99

1010
---
1111

@@ -172,6 +172,12 @@ SHOW COLLECTION notes
172172

173173
-- Retrieve a point by ID
174174
SELECT * FROM notes WHERE id = 1
175+
176+
-- Run compatible queries as one batch
177+
BEGIN BATCH;
178+
SEARCH notes SIMILAR TO 'vector databases' LIMIT 5;
179+
SEARCH notes SIMILAR TO 'semantic search' LIMIT 5;
180+
END BATCH
175181
```
176182

177183
---
@@ -182,5 +188,6 @@ SELECT * FROM notes WHERE id = 1
182188
- [SEARCH / SELECT / SCROLL / RECOMMEND / Hybrid / RERANK](search.md) — querying
183189
- [WHERE Filters](filters.md) — payload filtering
184190
- [Collections & Quantization](collections.md) — managing collections
185-
- [Scripts: EXECUTE / DUMP](scripts.md) — automating with script files
191+
- [Scripts: EXECUTE / DUMP](scripts.md) — automating with script files and batch blocks
192+
- [Programmatic Usage](programmatic.md) — sync/async APIs, batching, parameterized queries, gRPC
186193
- [Embedding Models](reference.md#embedding-models) — model reference

docs/programmatic.md

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ single connection to Qdrant once and reuses it for every `run_query()` call —
1616
more efficient than the legacy `run_query()` function, which creates a new
1717
client on every invocation.
1818

19+
Use `AsyncConnection` when your application already runs on `asyncio`.
20+
1921
### Basic usage
2022

2123
```python
@@ -70,6 +72,22 @@ with Connection("https://<your-cluster>.qdrant.io", secret="<your-api-key>") as
7072
print(result.data)
7173
```
7274

75+
### gRPC transport
76+
77+
QQL can ask the Qdrant client to prefer gRPC for lower request overhead:
78+
79+
```python
80+
from qql import Connection
81+
82+
with Connection(
83+
"http://localhost:6333",
84+
prefer_grpc=True,
85+
grpc_port=6334,
86+
) as conn:
87+
result = conn.run_query("SHOW COLLECTIONS")
88+
print(result.data)
89+
```
90+
7391
### Internal or self-signed certificates
7492

7593
Prefer a custom CA bundle when your Qdrant endpoint uses an internal or
@@ -179,10 +197,118 @@ with Connection("http://localhost:6333") as conn:
179197
| `url` | `str` | `"http://localhost:6333"` | Qdrant instance URL |
180198
| `secret` | `str \| None` | `None` | API key; `None` for unauthenticated |
181199
| `default_model` | `str \| None` | `None``sentence-transformers/all-MiniLM-L6-v2` | Dense embedding model used when no `USING MODEL` clause is given |
200+
| `prefer_grpc` | `bool` | `False` | Passes `prefer_grpc=True` to the Qdrant client |
201+
| `grpc_port` | `int` | `6334` | gRPC port used when `prefer_grpc=True` |
182202
| `verify` | `bool \| str` | `True` | TLS verification setting; use `False` to skip verification or a CA bundle path for internal/self-signed certificates |
183203
| `default_dense_vector_name` | `str` | `"dense"` | Dense vector name used when QQL creates a collection and no explicit `USING VECTOR` name is given |
184204
| `default_sparse_vector_name` | `str` | `"sparse"` | Sparse vector name used when QQL creates a hybrid collection and no explicit sparse vector name is given |
185205

206+
---
207+
208+
## Parameterized Queries
209+
210+
Parameterized helpers render `:name` placeholders before parsing the QQL statement. String values are quoted and escaped; booleans are rendered as `true` / `false`.
211+
212+
```python
213+
from qql import Connection
214+
215+
with Connection("http://localhost:6333") as conn:
216+
result = conn.run_parameterized_query(
217+
"SEARCH notes SIMILAR TO :query LIMIT 5 WHERE author = :author",
218+
{"query": "vector database", "author": "alice"},
219+
)
220+
221+
results = conn.run_parameterized_batch(
222+
"SEARCH notes SIMILAR TO :query LIMIT 5 WHERE category = :category",
223+
[
224+
{"query": "brain stroke", "category": "Neurology"},
225+
{"query": "heart attack", "category": "Cardiology"},
226+
],
227+
)
228+
```
229+
230+
Parameterized queries are a convenience for building QQL strings safely in application code; they are not sent to Qdrant as server-side prepared statements.
231+
232+
---
233+
234+
## Batch Execution
235+
236+
`run_queries_batch()` parses multiple QQL strings into a `BatchBlockStmt`. The executor groups compatible statements:
237+
238+
- compatible `SEARCH` / `RECOMMEND` statements use Qdrant `query_batch_points`
239+
- compatible `INSERT` statements become one `INSERT BULK`
240+
- mixed or incompatible statements still execute in order
241+
242+
```python
243+
from qql import Connection
244+
245+
with Connection("http://localhost:6333") as conn:
246+
results = conn.run_queries_batch([
247+
"SEARCH docs SIMILAR TO 'neurology' LIMIT 5",
248+
"SEARCH docs SIMILAR TO 'cardiology' LIMIT 5",
249+
])
250+
251+
for result in results:
252+
print(result.message)
253+
```
254+
255+
For ergonomic batching in application code, use `QQLBatch`:
256+
257+
```python
258+
from qql import Connection, QQLBatch
259+
260+
with Connection("http://localhost:6333") as conn:
261+
with QQLBatch(conn) as batch:
262+
neuro = batch.add("SEARCH docs SIMILAR TO 'neurology' LIMIT 5")
263+
cardio = batch.add("SEARCH docs SIMILAR TO 'cardiology' LIMIT 5")
264+
265+
print(neuro.result.data)
266+
print(cardio.result.data)
267+
```
268+
269+
Each proxy's `.result` becomes available after the context manager exits.
270+
271+
---
272+
273+
## Async API
274+
275+
`AsyncConnection` mirrors the sync API for `asyncio` applications and uses `AsyncQdrantClient` under the hood.
276+
277+
```python
278+
from qql import AsyncConnection
279+
280+
async with AsyncConnection("http://localhost:6333") as conn:
281+
await conn.run_query(
282+
"INSERT INTO COLLECTION notes VALUES {'text': 'async QQL'}"
283+
)
284+
result = await conn.run_query(
285+
"SEARCH notes SIMILAR TO 'async vector search' LIMIT 5"
286+
)
287+
print(result.data)
288+
```
289+
290+
Async batching and parameterized helpers are also available:
291+
292+
```python
293+
from qql import AsyncConnection, QQLAsyncBatch
294+
295+
async with AsyncConnection("http://localhost:6333", prefer_grpc=True) as conn:
296+
result = await conn.run_parameterized_query(
297+
"SEARCH docs SIMILAR TO :query LIMIT 5",
298+
{"query": "clinical notes"},
299+
)
300+
301+
async with QQLAsyncBatch(conn) as batch:
302+
first = batch.add("SEARCH docs SIMILAR TO 'neurology' LIMIT 5")
303+
second = batch.add("SEARCH docs SIMILAR TO 'cardiology' LIMIT 5")
304+
305+
print(first.result.data, second.result.data)
306+
```
307+
308+
The async executor preserves the same `ExecutionResult` shape as the sync executor.
309+
310+
---
311+
186312
### Power-user: `executor` property
187313

188314
For low-level access to the pipeline, use `conn.executor` directly:
@@ -275,7 +401,8 @@ class ExecutionResult:
275401
|---|---|
276402
| INSERT (dense) | `{"id": int \| "<uuid>", "collection": "<name>"}` |
277403
| INSERT (hybrid) | `{"id": int \| "<uuid>", "collection": "<name>"}` |
278-
| INSERT BULK | `None` (count in `result.message`) |
404+
| INSERT BULK | `{"ids": [int \| "<uuid>", ...]}` |
405+
| BEGIN BATCH / programmatic batch | `[ExecutionResult, ...]` |
279406
| SELECT | `{"id": str, "payload": dict}` or `None` when not found |
280407
| SEARCH | `[{"id": str, "score": float, "payload": dict}, ...]` |
281408
| SCROLL | `{"points": [{"id": str, "payload": dict}, ...], "next_offset": str \| int \| None}` |

docs/reference.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ title: "Reference"
55

66
# Reference — Models, Config, Project Structure, Errors
77

8-
Default embedding models, configuration parameters, project layout, and common error codes for troubleshooting.
8+
Default embedding models, configuration parameters, public APIs, project layout, and common error codes for troubleshooting.
99

1010
---
1111

@@ -149,30 +149,56 @@ You can edit this file directly to change the default model without reconnecting
149149

150150
---
151151

152+
## Public Python API
153+
154+
| API | Description |
155+
|---|---|
156+
| `Connection` | Stateful sync QQL client backed by `QdrantClient` |
157+
| `AsyncConnection` | Stateful async QQL client backed by `AsyncQdrantClient` |
158+
| `QQLBatch` | Sync context manager for collecting statements and resolving per-statement results after execution |
159+
| `QQLAsyncBatch` | Async context manager equivalent of `QQLBatch` |
160+
| `Executor` | Low-level sync AST executor |
161+
| `AsyncExecutor` | Low-level async AST executor |
162+
| `ExecutionResult` | Standard result object returned by all operations |
163+
164+
Both sync and async connections support:
165+
166+
- `run_query(query)`
167+
- `run_queries_batch([query, ...])`
168+
- `run_parameterized_query(template, params)`
169+
- `run_parameterized_batch(template, [params, ...])`
170+
- `prefer_grpc=True` and `grpc_port=<port>` connection options
171+
172+
---
173+
152174
## Project Structure
153175

154-
```
176+
```text
155177
qql/
156178
├── pyproject.toml # Package config; installs the `qql` CLI command
157179
├── src/
158180
│ └── qql/
159-
│ ├── __init__.py # Public API: Connection, run_query()
181+
│ ├── __init__.py # Public API exports: sync, async, batching, parser/executor
160182
│ ├── cli.py # CLI entry point: connect, disconnect, execute, dump, REPL
161183
│ ├── config.py # QQLConfig dataclass + ~/.qql/config.json I/O
162-
│ ├── connection.py # Connection class — stateful programmatic API
184+
│ ├── connection.py # Sync Connection, QQLBatch, parameterized query helpers
185+
│ ├── async_connection.py # AsyncConnection and QQLAsyncBatch
163186
│ ├── exceptions.py # QQLError, QQLSyntaxError, QQLRuntimeError
164187
│ ├── lexer.py # Tokenizer: string → List[Token]
165188
│ ├── ast_nodes.py # Frozen dataclasses for each statement and filter type
166189
│ ├── parser.py # Recursive descent parser: tokens → AST node
167190
│ ├── embedder.py # Embedder (dense) + SparseEmbedder (BM25) + CrossEncoderEmbedder (rerank)
168-
│ ├── executor.py # AST node → Qdrant client call + filter + hybrid search
191+
│ ├── executor.py # Sync AST node → Qdrant client call
192+
│ ├── async_executor.py # Async AST node → AsyncQdrantClient call
193+
│ ├── utils.py # Shared pure helpers for parsing, filters, batching, vectors
169194
│ ├── script.py # Script runner: parse and execute .qql files statement by statement
170195
│ └── dumper.py # Collection exporter: scroll all points → .qql INSERT BULK script
171196
└── tests/
172197
├── test_lexer.py # Tokenizer unit tests
173198
├── test_parser.py # Parser unit tests
174199
├── test_executor.py # Executor unit tests (mocked Qdrant client)
175200
├── test_connection.py # Connection class unit tests (mocked Qdrant client)
201+
├── test_async_connection.py # AsyncConnection / AsyncExecutor tests
176202
├── test_script.py # Script runner unit tests
177203
└── test_dumper.py # Dumper unit tests
178204
```
@@ -187,7 +213,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
187213
pytest tests/ -v
188214
```
189215

190-
Expected output: **604 tests passing**.
216+
Expected output: **635 tests passing**.
191217

192218
---
193219

@@ -220,3 +246,6 @@ Expected output: **604 tests passing**.
220246
| `Unknown index type '...'` | Invalid schema type in CREATE INDEX | Use one of: `keyword`, `integer`, `float`, `bool`, `text`, `geo`, `datetime`, `uuid` |
221247
| `Unknown CREATE INDEX option '...'` | Unsupported advanced option for the chosen payload index type | Check which `WITH { ... }` keys are supported for `keyword`, `uuid`, or `text` |
222248
| `Qdrant error during CREATE INDEX: ...` | Qdrant rejected the index creation | Check field name and collection state |
249+
| `Unterminated batch block; expected END BATCH` | A `BEGIN BATCH` block was not closed | Add `END BATCH` at the end of the block |
250+
| `Batch has not been executed yet.` | Read a `QQLBatch` proxy result before leaving the context manager | Access `.result` only after the `with QQLBatch(...)` block exits |
251+
| `AsyncBatch has not been executed yet.` | Read a `QQLAsyncBatch` proxy result before leaving the async context manager | Access `.result` only after the `async with QQLAsyncBatch(...)` block exits |

0 commit comments

Comments
 (0)