Skip to content

Commit 8a754c3

Browse files
authored
Merge pull request #28 from pavanjava/qql16
2 new concepts 1. Update and 2.Group By
2 parents 74ac147 + 967ab61 commit 8a754c3

13 files changed

Lines changed: 1366 additions & 16 deletions

README.md

Lines changed: 16 additions & 5 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-405%20passing-brightgreen)](tests/)
8+
[![Tests](https://img.shields.io/badge/tests-500%20passing-brightgreen)](tests/)
99

10-
Write `INSERT`, `SELECT`, `SEARCH`, `SCROLL`, `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.
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.
1111

1212
```
1313
qql> INSERT INTO COLLECTION notes VALUES {'text': 'Qdrant is a vector database', 'author': 'alice', 'year': 2024}
@@ -82,9 +82,9 @@ Full documentation lives in the [`docs/`](docs/) folder and at **[pavanjava.gith
8282
|---|---|
8383
| [Getting Started](docs/getting-started.md) | Installation, connecting, first queries |
8484
| [INSERT / INSERT BULK](docs/insert.md) | Adding documents, batch inserts, payload types |
85-
| [SEARCH / SELECT / SCROLL / RECOMMEND / Hybrid / RERANK](docs/search.md) | Semantic search, point retrieval, pagination, hybrid, reranking, recommendations |
85+
| [SEARCH / SELECT / SCROLL / RECOMMEND / Hybrid / GROUP BY / RERANK](docs/search.md) | Semantic search, grouped search, point retrieval, pagination, hybrid, reranking, recommendations |
8686
| [WHERE Filters](docs/filters.md) | Full SQL-style filter operators |
87-
| [Collections & Quantization](docs/collections.md) | SHOW, CREATE, DROP, QUANTIZE (scalar/turbo/binary/product), CREATE INDEX |
87+
| [Collections & Quantization](docs/collections.md) | SHOW, CREATE, DROP, QUANTIZE (scalar/turbo/binary/product), CREATE INDEX, UPDATE VECTOR, UPDATE PAYLOAD |
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 |
@@ -128,6 +128,17 @@ SHOW COLLECTIONS
128128
SHOW COLLECTION articles
129129
DROP COLLECTION articles
130130

131+
-- Search with grouping
132+
SEARCH articles SIMILAR TO 'query' LIMIT 5 GROUP BY category
133+
SEARCH articles SIMILAR TO 'query' LIMIT 5 GROUP BY category GROUP_SIZE 3
134+
SEARCH articles SIMILAR TO 'query' LIMIT 5 WHERE year >= 2020 GROUP BY category GROUP_SIZE 2
135+
SEARCH articles SIMILAR TO 'query' LIMIT 5 USING HYBRID GROUP BY category
136+
137+
-- Update
138+
UPDATE articles SET VECTOR WHERE id = '3f2e1a4b-...' [0.1, 0.2, 0.3, 0.4]
139+
UPDATE articles SET PAYLOAD WHERE id = '3f2e1a4b-...' {'year': 2025, 'status': 'active'}
140+
UPDATE articles SET PAYLOAD WHERE category = 'draft' {'status': 'published'}
141+
131142
-- Delete
132143
DELETE FROM articles WHERE id = '3f2e1a4b-...'
133144
DELETE FROM articles WHERE year < 2020
@@ -147,7 +158,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
147158
pytest tests/ -v
148159
```
149160

150-
Expected: **405 tests passing**.
161+
Expected: **500 tests passing**.
151162

152163
---
153164

docs/collections.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,68 @@ DELETE FROM articles WHERE year < 2020 AND status = 'draft'
310310
**Notes:**
311311
- If no points match the filter or ID, the operation succeeds silently with a count of 0.
312312
- The collection itself must exist; deleting from a non-existent collection raises an error.
313+
314+
---
315+
316+
## UPDATE SET VECTOR — replace a point's dense vector
317+
318+
Replaces the stored dense vector for a **single point** identified by its ID. The point must already exist in the collection. Use this when you want to refresh an embedding without changing the payload.
319+
320+
**Syntax:**
321+
```
322+
UPDATE <collection> SET VECTOR WHERE id = '<point_id>' [<vector>]
323+
UPDATE <collection> SET VECTOR WHERE id = <integer_id> [<vector>]
324+
```
325+
326+
The vector is provided as a JSON-style float array `[v1, v2, ..., vN]`. The array length must match the collection's configured vector dimensions.
327+
328+
**Examples:**
329+
330+
```sql
331+
-- Replace vector by UUID
332+
UPDATE articles SET VECTOR WHERE id = '3f2e1a4b-8c91-4d0e-b123-abc123def456' [0.1, 0.2, 0.3, 0.4]
333+
334+
-- Replace vector by integer ID
335+
UPDATE articles SET VECTOR WHERE id = 42 [0.1, 0.2, 0.3, 0.4]
336+
```
337+
338+
**Notes:**
339+
- Only single-point updates are supported (by ID). Bulk or filter-based vector updates are not supported.
340+
- The point must already exist; this operation does not create new points.
341+
- The collection must exist; updating from a non-existent collection raises an error.
342+
- For hybrid collections, the dense vector named `"dense"` is updated. Sparse vectors are managed separately.
343+
344+
---
345+
346+
## UPDATE SET PAYLOAD — merge fields into a point's payload
347+
348+
Merges new key/value pairs into the payload of one or more points. **Existing fields not mentioned in the update are preserved** (additive merge, not a full replace). Use a `WHERE` filter to update multiple points at once.
349+
350+
**Syntax:**
351+
```
352+
UPDATE <collection> SET PAYLOAD WHERE id = '<point_id>' {<payload>}
353+
UPDATE <collection> SET PAYLOAD WHERE id = <integer_id> {<payload>}
354+
UPDATE <collection> SET PAYLOAD WHERE <filter> {<payload>}
355+
```
356+
357+
**Examples:**
358+
359+
```sql
360+
-- Update a single point by UUID
361+
UPDATE articles SET PAYLOAD WHERE id = '3f2e1a4b-8c91-4d0e-b123-abc123def456' {'year': 2025, 'status': 'active'}
362+
363+
-- Update a single point by integer ID
364+
UPDATE articles SET PAYLOAD WHERE id = 42 {'category': 'tech'}
365+
366+
-- Update all points matching a filter
367+
UPDATE articles SET PAYLOAD WHERE category = 'draft' {'status': 'published'}
368+
369+
-- Compound filter update
370+
UPDATE articles SET PAYLOAD WHERE year < 2020 AND status = 'draft' {'archived': true}
371+
```
372+
373+
**Notes:**
374+
- **Merge semantics:** only the fields in `{…}` are written; all other existing payload fields are preserved.
375+
- If no points match the filter, the operation succeeds silently with no changes.
376+
- The collection must exist; updating from a non-existent collection raises an error.
377+
- All `WHERE` filter operators supported by `DELETE` are also supported here (see [WHERE Filters](filters.md)).

docs/reference.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
162162
pytest tests/ -v
163163
```
164164

165-
Expected output: **405 tests passing**.
165+
Expected output: **500 tests passing**.
166166

167167
---
168168

@@ -174,14 +174,23 @@ Expected output: **405 tests passing**.
174174
| `Connection failed: ...` | Qdrant unreachable at given URL | Check that Qdrant is running and the URL is correct |
175175
| `INSERT requires a 'text' field in VALUES` | `text` key missing from the VALUES dict | Add `'text': '...'` to your dict |
176176
| `Vector dimension mismatch: collection '...' expects X dims, but model produces Y dims` | Model used in INSERT differs from the one used to create the collection | Use `USING MODEL` to specify the same model as the collection was created with |
177-
| `Collection '...' does not exist` | SEARCH / SCROLL / SELECT / DROP / DELETE on a non-existent collection | Check name spelling or run `SHOW COLLECTIONS` |
177+
| `Collection '...' does not exist` | SEARCH / SCROLL / SELECT / DROP / DELETE / UPDATE on a non-existent collection | Check name spelling or run `SHOW COLLECTIONS` |
178178
| `Unexpected token '...'; expected a QQL statement keyword` | Unrecognized statement | Check the query syntax and supported statement list |
179179
| `SELECT requires a string or integer point id, got '...'` | `SELECT` used with a non-ID filter value | Use `SELECT * FROM <collection> WHERE id = '<id>'` or an integer ID |
180180
| `Unterminated string literal (at position N)` | A string is missing its closing quote | Close the string with a matching `'` or `"` |
181181
| `Unexpected character '@' (at position N)` | A character not part of QQL syntax | Remove or quote the offending character |
182182
| `Expected a filter operator after field '...'` | Unknown operator in WHERE clause | Use one of: `=`, `!=`, `>`, `>=`, `<`, `<=`, `IN`, `NOT IN`, `BETWEEN`, `IS NULL`, `IS NOT NULL`, `IS EMPTY`, `IS NOT EMPTY`, `MATCH` |
183183
| `Expected ')' ...` | Unclosed parenthesis in WHERE clause | Add the missing `)` to close the group |
184184
| `Qdrant error during SEARCH: ...` | Hybrid search on a non-hybrid collection, or wrong vector names | Ensure the collection was created with `HYBRID` before using `USING HYBRID` in INSERT/SEARCH |
185+
| `Qdrant error during GROUP BY SEARCH: ...` | GROUP BY on an unindexed field, or unsupported field type | Ensure the group-by field is indexed as `keyword` or `integer` via `CREATE INDEX` |
186+
| `GROUP BY and RERANK cannot be combined ...` | Both GROUP BY and RERANK specified in the same SEARCH | Remove one of the two clauses |
187+
| `Expected VECTOR or PAYLOAD after SET, got '...'` | Unknown keyword after SET in UPDATE | Use `UPDATE ... SET VECTOR ...` or `UPDATE ... SET PAYLOAD ...` |
188+
| `Expected a vector list [...] after point ID in UPDATE SET VECTOR` | UPDATE SET VECTOR missing the `[...]` float array | Add the vector array: `UPDATE ... SET VECTOR WHERE id = '...' [0.1, 0.2, ...]` |
189+
| `Qdrant error during UPDATE VECTOR: ...` | Point does not exist, or vector dimensions mismatch | Verify the point ID exists and the vector length matches the collection's dimensions |
190+
| `Qdrant error during UPDATE PAYLOAD: ...` | Qdrant rejected the payload update | Check field values and collection state |
191+
| `Vector elements must be numeric floats; boolean values are not allowed` | A boolean (`true` or `false`) was present in the vector array for `UPDATE SET VECTOR``float(True)` silently equals `1.0` in Python, so this is caught explicitly | Replace booleans with numeric floats: `UPDATE … [0.1, 0.2, …, 0.N]` |
192+
| `Vector elements must be numeric; got invalid value: ...` | A non-numeric value (string or null) was present in the vector array for `UPDATE SET VECTOR` | Ensure all vector elements are floats: `UPDATE … [0.1, 0.2, …, 0.N]` |
193+
| `GROUP_SIZE must be a positive integer, got N` | `GROUP_SIZE 0` or a negative value was specified | Use a positive integer: `GROUP_SIZE 3` |
185194
| `Qdrant error during SCROLL: ...` | Qdrant rejected scroll request | Verify collection state, filter, and cursor (`AFTER`) value |
186195
| `Unknown index type '...'` | Invalid schema type in CREATE INDEX | Use one of: `keyword`, `integer`, `float`, `bool`, `text`, `geo`, `datetime` |
187196
| `Qdrant error during CREATE INDEX: ...` | Qdrant rejected the index creation | Check field name and collection state |

docs/search.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,68 @@ SEARCH articles SIMILAR TO 'semantic search' LIMIT 5
343343
| Large collections with keyword-heavy queries | `USING HYBRID RERANK` |
344344

345345
> **Note on scores:** After reranking, the `score` column shows the cross-encoder's raw logit (can be any real number, unbounded). Do not compare reranked scores to non-reranked cosine similarity scores.
346+
347+
---
348+
349+
## SEARCH … GROUP BY — grouped results
350+
351+
Returns the top-scoring points **grouped by a payload field value**. Instead of a single flat ranked list, results are organised into groups — each group contains the top-scoring points that share the same value for the specified field.
352+
353+
Useful for **result diversification**: e.g. "return the 3 best articles from each category", or "show the top 2 papers per author".
354+
355+
**Syntax:**
356+
```
357+
SEARCH <collection> SIMILAR TO '<query>' LIMIT <n> GROUP BY <field>
358+
SEARCH <collection> SIMILAR TO '<query>' LIMIT <n> GROUP BY <field> GROUP_SIZE <m>
359+
SEARCH <collection> SIMILAR TO '<query>' LIMIT <n> [WHERE <filter>] GROUP BY <field> [GROUP_SIZE <m>]
360+
SEARCH <collection> SIMILAR TO '<query>' LIMIT <n> USING HYBRID GROUP BY <field> [GROUP_SIZE <m>]
361+
```
362+
363+
- **`LIMIT <n>`** — maximum number of **groups** to return.
364+
- **`GROUP_SIZE <m>`** — maximum number of points per group (default: **3**).
365+
- **`GROUP BY <field>`** — the payload field whose values define the groups. **Must be a string (keyword) or number (integer) field** — this is enforced by Qdrant. Dot-notation is supported (e.g. `meta.author`). Array-valued fields are allowed: a point with multiple values for the field can appear in multiple groups. The field should be indexed as `keyword` or `integer` for best performance (see [CREATE INDEX](collections.md)).
366+
- `WHERE` filters, `USING HYBRID`, and `USING MODEL` are all compatible with GROUP BY.
367+
- **`GROUP BY` and `RERANK` cannot be combined** in the same statement — this raises a syntax error.
368+
369+
**Examples:**
370+
371+
Top 5 categories, up to 3 articles each (default group_size):
372+
```sql
373+
SEARCH articles SIMILAR TO 'machine learning' LIMIT 5 GROUP BY category
374+
```
375+
376+
Top 3 authors, up to 2 papers each:
377+
```sql
378+
SEARCH papers SIMILAR TO 'neural networks' LIMIT 3 GROUP BY author GROUP_SIZE 2
379+
```
380+
381+
Grouped search with a payload filter:
382+
```sql
383+
SEARCH articles SIMILAR TO 'deep learning' LIMIT 5 WHERE year >= 2022 GROUP BY category GROUP_SIZE 4
384+
```
385+
386+
Grouped hybrid search:
387+
```sql
388+
SEARCH articles SIMILAR TO 'vector databases' LIMIT 4 USING HYBRID GROUP BY category GROUP_SIZE 3
389+
```
390+
391+
**Output:**
392+
393+
```
394+
✓ Found 3 group(s) by 'category' (grouped)
395+
Group: machine-learning
396+
Score │ ID │ Payload
397+
────────┼──────────────────────────────────────┼────────────────────────────────────────
398+
0.9312 │ 3f2e1a4b-8c91-4d0e-b123-abc123def456 │ {'text': '...', 'category': 'machine-learning'}
399+
0.8845 │ 9a1b2c3d-4e5f-6789-abcd-ef0123456789 │ {'text': '...', 'category': 'machine-learning'}
400+
401+
Group: nlp
402+
Score │ ID │ Payload
403+
────────┼──────────────────────────────────────┼────────────────────────────────────────
404+
0.9100 │ 1a2b3c4d-5e6f-7890-bcde-f01234567890 │ {'text': '...', 'category': 'nlp'}
405+
```
406+
407+
> **Tip:** For GROUP BY to work efficiently, create a payload index on the grouping field first:
408+
> ```sql
409+
> CREATE INDEX ON COLLECTION articles FOR category TYPE keyword
410+
> ```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "qql-cli"
3-
version = "2.2.0"
3+
version = "2.3.0"
44
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" }

src/qql/ast_nodes.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ class SearchStmt:
213213
rerank: bool = False # if True, apply cross-encoder reranking post-Qdrant
214214
rerank_model: str | None = None # cross-encoder model; None → CrossEncoderEmbedder.DEFAULT_MODEL
215215
with_clause: SearchWith | None = None
216+
group_by: str | None = None # GROUP BY field name; None → normal flat search
217+
group_size: int = 3 # max points per group (ignored when group_by is None)
216218

217219

218220
@dataclass(frozen=True)
@@ -237,6 +239,23 @@ class DeleteStmt:
237239
query_filter: FilterExpr | None = None
238240

239241

242+
@dataclass(frozen=True)
243+
class UpdateVectorStmt:
244+
"""UPDATE <collection> SET VECTOR WHERE id = <id> [vector...]"""
245+
collection: str
246+
point_id: str | int
247+
vector: tuple[float, ...] # dense vector as immutable tuple (frozen=True compatible)
248+
249+
250+
@dataclass(frozen=True)
251+
class UpdatePayloadStmt:
252+
"""UPDATE <collection> SET PAYLOAD WHERE <filter|id> {payload}"""
253+
collection: str
254+
payload: dict[str, Any]
255+
point_id: str | int | None = None # mutually exclusive with query_filter
256+
query_filter: FilterExpr | None = None
257+
258+
240259
# Union type for all top-level statement nodes
241260
ASTNode = (
242261
InsertStmt
@@ -251,4 +270,6 @@ class DeleteStmt:
251270
| SearchStmt
252271
| RecommendStmt
253272
| DeleteStmt
273+
| UpdateVectorStmt
274+
| UpdatePayloadStmt
254275
)

src/qql/cli.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
Optional: [yellow]RERANK[/yellow] [MODEL '<model>'] rerank results with a cross-encoder
7272
Optional: [yellow]EXACT[/yellow] bypass HNSW and perform exact search
7373
Optional: [yellow]WITH[/yellow] { hnsw_ef: <int>, exact: <bool>, acorn: <bool> } search parameters
74+
Optional: [yellow]GROUP BY[/yellow] <field> [[yellow]GROUP_SIZE[/yellow] <n>]
75+
Group results by a payload field value (default GROUP_SIZE: 3).
76+
Field must be keyword or integer type. RERANK and GROUP BY cannot be combined.
7477
7578
[yellow]RECOMMEND FROM[/yellow] <name> [yellow]POSITIVE IDS[/yellow] (<id>, ...)
7679
Find points similar to known examples.
@@ -82,6 +85,15 @@
8285
[yellow]DELETE FROM[/yellow] <name> [yellow]WHERE id =[/yellow] '<id>'
8386
Delete a point by its ID.
8487
88+
[yellow]UPDATE[/yellow] <name> [yellow]SET VECTOR WHERE id =[/yellow] '<id>'|<int> [<vector>]
89+
Replace the dense vector for a single point by ID.
90+
The point must already exist. Vector is a float array: [0.1, 0.2, ..., 0.N]
91+
92+
[yellow]UPDATE[/yellow] <name> [yellow]SET PAYLOAD WHERE id =[/yellow] '<id>'|<int> {<payload>}
93+
[yellow]UPDATE[/yellow] <name> [yellow]SET PAYLOAD WHERE[/yellow] <filter> {<payload>}
94+
Merge new key/value pairs into a point's payload (additive; existing fields preserved).
95+
Supports all WHERE filter operators. Filter-based updates affect all matching points.
96+
8597
Script files (in-shell):
8698
[yellow]EXECUTE[/yellow] <path> or [yellow]\\e[/yellow] <path>
8799
Run a .qql script file. Statements are executed in order.
@@ -458,6 +470,32 @@ def _run_and_print(executor: Executor, query: str) -> None:
458470
console.print(_format_collection_diagnostics(result.data))
459471
return
460472

473+
# Pretty-print grouped search results (GROUP BY)
474+
if (
475+
isinstance(result.data, list)
476+
and result.data
477+
and isinstance(result.data[0], dict)
478+
and "group_id" in result.data[0]
479+
):
480+
for group in result.data:
481+
console.print(f"\n[bold cyan]Group: {group['group_id']}[/bold cyan]")
482+
hits = group.get("hits", [])
483+
if hits:
484+
tbl = Table(show_header=True, header_style="bold")
485+
tbl.add_column("Score", style="green", no_wrap=True, justify="right")
486+
tbl.add_column("ID")
487+
tbl.add_column("Payload")
488+
for hit in hits:
489+
tbl.add_row(
490+
str(hit["score"]),
491+
str(hit["id"]),
492+
str(hit.get("payload", {})),
493+
)
494+
console.print(tbl)
495+
else:
496+
console.print(" (no hits)")
497+
return
498+
461499
# Pretty-print search results
462500
if isinstance(result.data, list) and result.data and isinstance(result.data[0], dict) and "score" in result.data[0]:
463501
table = Table(show_header=True, header_style="bold cyan")

0 commit comments

Comments
 (0)