Skip to content

Commit 8a066e1

Browse files
committed
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.
1 parent dbb6713 commit 8a066e1

17 files changed

Lines changed: 3350 additions & 447 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
---
@@ -97,8 +104,8 @@ Full documentation lives in the [`docs/`](docs/) folder and at **[pavanjava.gith
97104
| [SEARCH / SELECT / SCROLL / RECOMMEND / Hybrid / GROUP BY / RERANK](docs/search.md) | Semantic search, grouped search, point retrieval, pagination, hybrid, reranking, recommendations |
98105
| [WHERE Filters](docs/filters.md) | Full SQL-style filter operators |
99106
| [Collections & Quantization](docs/collections.md) | SHOW, CREATE, DROP, QUANTIZE (scalar/turbo/binary/product), CREATE INDEX, UPDATE VECTOR, UPDATE PAYLOAD |
100-
| [Scripts: EXECUTE / DUMP](docs/scripts.md) | Script files, collection backup/restore |
101-
| [Programmatic Usage](docs/programmatic.md) | Use QQL as a Python library via `Connection` or `run_query()` |
107+
| [Scripts: EXECUTE / DUMP](docs/scripts.md) | Script files, `BEGIN BATCH` blocks, collection backup/restore |
108+
| [Programmatic Usage](docs/programmatic.md) | Sync/async Python APIs, parameterized queries, batching, gRPC |
102109
| [Reference: Models / Config / Errors](docs/reference.md) | Embedding models, config file, error reference |
103110

104111
---
@@ -170,6 +177,12 @@ DELETE FROM articles WHERE year < 2020
170177
-- Scripts
171178
EXECUTE /path/to/script.qql
172179
DUMP articles /path/to/backup.qql
180+
181+
-- Batch block
182+
BEGIN BATCH;
183+
SEARCH articles SIMILAR TO 'query one' LIMIT 5;
184+
SEARCH articles SIMILAR TO 'query two' LIMIT 5;
185+
END BATCH
173186
```
174187

175188
---
@@ -182,7 +195,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
182195
pytest tests/ -v
183196
```
184197

185-
Expected: **549 tests passing**.
198+
Expected: **635 tests passing**.
186199

187200
---
188201

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

@@ -154,6 +154,12 @@ SHOW COLLECTION notes
154154

155155
-- Retrieve a point by ID
156156
SELECT * FROM notes WHERE id = 1
157+
158+
-- Run compatible queries as one batch
159+
BEGIN BATCH;
160+
SEARCH notes SIMILAR TO 'vector databases' LIMIT 5;
161+
SEARCH notes SIMILAR TO 'semantic search' LIMIT 5;
162+
END BATCH
157163
```
158164

159165
---
@@ -164,5 +170,6 @@ SELECT * FROM notes WHERE id = 1
164170
- [SEARCH / SELECT / SCROLL / RECOMMEND / Hybrid / RERANK](search.md) — querying
165171
- [WHERE Filters](filters.md) — payload filtering
166172
- [Collections & Quantization](collections.md) — managing collections
167-
- [Scripts: EXECUTE / DUMP](scripts.md) — automating with script files
173+
- [Scripts: EXECUTE / DUMP](scripts.md) — automating with script files and batch blocks
174+
- [Programmatic Usage](programmatic.md) — sync/async APIs, batching, parameterized queries, gRPC
168175
- [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
### Custom embedding model
7492

7593
```python
@@ -155,9 +173,117 @@ with Connection("http://localhost:6333") as conn:
155173
| `url` | `str` | `"http://localhost:6333"` | Qdrant instance URL |
156174
| `secret` | `str \| None` | `None` | API key; `None` for unauthenticated |
157175
| `default_model` | `str \| None` | `None``sentence-transformers/all-MiniLM-L6-v2` | Dense embedding model used when no `USING MODEL` clause is given |
176+
| `prefer_grpc` | `bool` | `False` | Passes `prefer_grpc=True` to the Qdrant client |
177+
| `grpc_port` | `int` | `6334` | gRPC port used when `prefer_grpc=True` |
158178
| `default_dense_vector_name` | `str` | `"dense"` | Dense vector name used when QQL creates a collection and no explicit `USING VECTOR` name is given |
159179
| `default_sparse_vector_name` | `str` | `"sparse"` | Sparse vector name used when QQL creates a hybrid collection and no explicit sparse vector name is given |
160180

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

163289
For low-level access to the pipeline, use `conn.executor` directly:
@@ -250,7 +376,8 @@ class ExecutionResult:
250376
|---|---|
251377
| INSERT (dense) | `{"id": int \| "<uuid>", "collection": "<name>"}` |
252378
| INSERT (hybrid) | `{"id": int \| "<uuid>", "collection": "<name>"}` |
253-
| INSERT BULK | `None` (count in `result.message`) |
379+
| INSERT BULK | `{"ids": [int \| "<uuid>", ...]}` |
380+
| BEGIN BATCH / programmatic batch | `[ExecutionResult, ...]` |
254381
| SELECT | `{"id": str, "payload": dict}` or `None` when not found |
255382
| SEARCH | `[{"id": str, "score": float, "payload": dict}, ...]` |
256383
| SCROLL | `{"points": [{"id": str, "payload": dict}, ...], "next_offset": str \| int \| None}` |

docs/reference.md

Lines changed: 34 additions & 5 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

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

148148
---
149149

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

152174
```
153175
qql/
154176
├── pyproject.toml # Package config; installs the `qql` CLI command
155177
├── src/
156178
│ └── qql/
157-
│ ├── __init__.py # Public API: Connection, run_query()
179+
│ ├── __init__.py # Public API exports: sync, async, batching, parser/executor
158180
│ ├── cli.py # CLI entry point: connect, disconnect, execute, dump, REPL
159181
│ ├── config.py # QQLConfig dataclass + ~/.qql/config.json I/O
160-
│ ├── connection.py # Connection class — stateful programmatic API
182+
│ ├── connection.py # Sync Connection, QQLBatch, parameterized query helpers
183+
│ ├── async_connection.py # AsyncConnection and QQLAsyncBatch
161184
│ ├── exceptions.py # QQLError, QQLSyntaxError, QQLRuntimeError
162185
│ ├── lexer.py # Tokenizer: string → List[Token]
163186
│ ├── ast_nodes.py # Frozen dataclasses for each statement and filter type
164187
│ ├── parser.py # Recursive descent parser: tokens → AST node
165188
│ ├── embedder.py # Embedder (dense) + SparseEmbedder (BM25) + CrossEncoderEmbedder (rerank)
166-
│ ├── executor.py # AST node → Qdrant client call + filter + hybrid search
189+
│ ├── executor.py # Sync AST node → Qdrant client call
190+
│ ├── async_executor.py # Async AST node → AsyncQdrantClient call
191+
│ ├── utils.py # Shared pure helpers for parsing, filters, batching, vectors
167192
│ ├── script.py # Script runner: parse and execute .qql files statement by statement
168193
│ └── dumper.py # Collection exporter: scroll all points → .qql INSERT BULK script
169194
└── tests/
170195
├── test_lexer.py # Tokenizer unit tests
171196
├── test_parser.py # Parser unit tests
172197
├── test_executor.py # Executor unit tests (mocked Qdrant client)
173198
├── test_connection.py # Connection class unit tests (mocked Qdrant client)
199+
├── test_async_connection.py # AsyncConnection / AsyncExecutor tests
174200
├── test_script.py # Script runner unit tests
175201
└── test_dumper.py # Dumper unit tests
176202
```
@@ -185,7 +211,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
185211
pytest tests/ -v
186212
```
187213

188-
Expected output: **604 tests passing**.
214+
Expected output: **635 tests passing**.
189215

190216
---
191217

@@ -218,3 +244,6 @@ Expected output: **604 tests passing**.
218244
| `Unknown index type '...'` | Invalid schema type in CREATE INDEX | Use one of: `keyword`, `integer`, `float`, `bool`, `text`, `geo`, `datetime`, `uuid` |
219245
| `Unknown CREATE INDEX option '...'` | Unsupported advanced option for the chosen payload index type | Check which `WITH { ... }` keys are supported for `keyword`, `uuid`, or `text` |
220246
| `Qdrant error during CREATE INDEX: ...` | Qdrant rejected the index creation | Check field name and collection state |
247+
| `Unterminated batch block; expected END BATCH` | A `BEGIN BATCH` block was not closed | Add `END BATCH` at the end of the block |
248+
| `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 |
249+
| `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)