Skip to content

Commit e95465d

Browse files
committed
feat: add RECOMMEND statement for point recommendations
- Introduced the RECOMMEND statement to allow users to find similar points based on known examples. - Implemented parsing, execution, and filtering logic for the RECOMMEND statement in the QQL parser and executor. - Updated the CLI to include documentation for the new RECOMMEND statement. - Enhanced the executor to handle positive and negative IDs, as well as optional query filters and strategies. - Added tests for the RECOMMEND statement to ensure correct functionality and error handling. - Updated the sample QQL script to demonstrate the usage of the RECOMMEND statement.
1 parent a866342 commit e95465d

13 files changed

Lines changed: 671 additions & 43 deletions

File tree

README.md

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ qql> SEARCH notes SIMILAR TO 'vector databases' LIMIT 5 USING HYBRID RERANK
3737
- [INSERT — add a point](#insert--add-a-point)
3838
- [INSERT BULK — batch insert](#insert-bulk--batch-insert-multiple-points)
3939
- [SEARCH — find similar points](#search--find-similar-points)
40+
- [RECOMMEND — retrieve by example IDs](#recommend--retrieve-by-example-ids)
4041
- [Query-Time Search Params (`EXACT`, `WITH`)](#query-time-search-params-exact-with)
4142
- [WHERE Clause Filters](#where-clause-filters)
4243
- [Hybrid Search (USING HYBRID)](#hybrid-search-using-hybrid)
@@ -186,6 +187,8 @@ Inserts a new document into a collection. The `text` field is **mandatory** —
186187

187188
If the collection does not exist yet, it is **created automatically** with the correct vector dimensions.
188189

190+
If you include an `id` field in `VALUES`, QQL uses it as the Qdrant point ID. Supported explicit IDs are unsigned integers or UUID strings. If you omit `id`, QQL generates a UUID automatically.
191+
189192
**Syntax:**
190193
```
191194
INSERT INTO COLLECTION <collection_name> VALUES {<dict>}
@@ -204,6 +207,7 @@ INSERT INTO COLLECTION articles VALUES {'text': 'Qdrant supports cosine similari
204207
Insert with metadata:
205208
```sql
206209
INSERT INTO COLLECTION articles VALUES {
210+
'id': 1001,
207211
'text': 'Neural networks learn representations from data',
208212
'author': 'alice',
209213
'category': 'ml',
@@ -231,13 +235,13 @@ INSERT INTO COLLECTION articles VALUES {'text': 'hello world'}
231235
**What happens internally:**
232236
1. The `text` value is embedded into a dense vector using the configured model.
233237
2. In hybrid mode, a sparse BM25 vector is also generated.
234-
3. A UUID is auto-generated as the point ID.
235-
4. All fields (including `text`) are stored in the payload.
238+
3. If `id` is provided, it is used as the point ID; otherwise a UUID is auto-generated.
239+
4. All fields except `id` are stored in the payload.
236240
5. The point is upserted into Qdrant.
237241

238242
**Rules:**
239243
- `text` is always required. Omitting it raises an error.
240-
- A point ID (UUID) is generated automatically — you do not provide one.
244+
- `id`, when provided, must be an unsigned integer or UUID string.
241245
- If the collection already exists with a different vector size (from a different model), an error is raised with a clear message.
242246
- Hybrid inserts require a hybrid collection (created with `CREATE COLLECTION ... HYBRID` or auto-created on first `USING HYBRID` insert).
243247

@@ -249,6 +253,8 @@ Inserts multiple documents in a single statement. Each item in the array must co
249253

250254
If the collection does not exist yet, it is **created automatically** on the first bulk insert.
251255

256+
Each record may optionally include an `id` field. This is the preferred way to keep seed data deterministic and to make follow-up operations like `RECOMMEND` or `DELETE` reproducible.
257+
252258
**Syntax:**
253259
```
254260
INSERT BULK INTO COLLECTION <collection_name> VALUES [<dict>, <dict>, ...]
@@ -271,9 +277,9 @@ INSERT BULK INTO COLLECTION articles VALUES [
271277
Bulk insert with metadata:
272278
```sql
273279
INSERT BULK INTO COLLECTION articles VALUES [
274-
{'text': 'Attention is all you need', 'author': 'vaswani', 'year': 2017},
275-
{'text': 'BERT: Pre-training of deep bidirectional transformers', 'author': 'devlin', 'year': 2018},
276-
{'text': 'Language models are few-shot learners', 'author': 'brown', 'year': 2020}
280+
{'id': 1001, 'text': 'Attention is all you need', 'author': 'vaswani', 'year': 2017},
281+
{'id': 1002, 'text': 'BERT: Pre-training of deep bidirectional transformers', 'author': 'devlin', 'year': 2018},
282+
{'id': 1003, 'text': 'Language models are few-shot learners', 'author': 'brown', 'year': 2020}
277283
]
278284
```
279285

@@ -288,7 +294,7 @@ INSERT BULK INTO COLLECTION articles VALUES [
288294
**Rules:**
289295
- Every dict in the array must contain a `"text"` key. Missing `text` on any item raises an error with the offending index.
290296
- An empty array `[]` raises an error.
291-
- A UUID is auto-generated for each point — you do not provide IDs.
297+
- `id`, when provided, must be an unsigned integer or UUID string.
292298
- Supports all the same `USING` clauses as single `INSERT`.
293299

294300
---
@@ -371,13 +377,57 @@ Results are displayed as a table with three columns:
371377
```
372378

373379
- **Score** — similarity score. Higher is more relevant.
374-
- **ID** — the UUID of the matching point.
380+
- **ID** — the point ID returned by Qdrant. This may be an integer or a UUID string.
375381
- **Payload** — all fields stored alongside the vector.
376382

377383
**Important:** Use the same model for SEARCH as you used for INSERT. Mixing models produces meaningless scores because the vectors live in different spaces.
378384

379385
---
380386

387+
### RECOMMEND — retrieve by example IDs
388+
389+
Performs a Qdrant recommendation query using existing point IDs as positive and optional negative examples.
390+
391+
This is useful when you already know which stored points represent the kind of result you want. Qdrant uses those examples to retrieve nearby points, and QQL automatically excludes the seed IDs from the results.
392+
393+
**Syntax:**
394+
```sql
395+
RECOMMEND FROM <collection_name> POSITIVE IDS (1001, 1002) LIMIT <n>
396+
RECOMMEND FROM <collection_name> POSITIVE IDS (1001, 1002) NEGATIVE IDS (1003) LIMIT <n>
397+
RECOMMEND FROM <collection_name> POSITIVE IDS (1001) STRATEGY 'best_score' LIMIT <n>
398+
RECOMMEND FROM <collection_name> POSITIVE IDS (1001) LIMIT <n> WHERE <filter>
399+
```
400+
401+
**Examples:**
402+
403+
Recommend more results like two known articles:
404+
```sql
405+
RECOMMEND FROM articles POSITIVE IDS (1001, 1002) LIMIT 5
406+
```
407+
408+
Recommend similar results while steering away from one bad example:
409+
```sql
410+
RECOMMEND FROM articles POSITIVE IDS (1001, 1002) NEGATIVE IDS (1009) LIMIT 5
411+
```
412+
413+
Use Qdrant's `best_score` recommendation strategy:
414+
```sql
415+
RECOMMEND FROM articles POSITIVE IDS (1001) STRATEGY 'best_score' LIMIT 10
416+
```
417+
418+
Recommend only within a filtered subset:
419+
```sql
420+
RECOMMEND FROM articles POSITIVE IDS (1001) LIMIT 5 WHERE year >= 2020 AND status = 'published'
421+
```
422+
423+
**Supported strategies:**
424+
425+
- `average_vector`
426+
- `best_score`
427+
- `sum_scores`
428+
429+
---
430+
381431
### Query-Time Search Params (`EXACT`, `WITH`)
382432

383433
QQL supports a small set of Qdrant query-time search parameters on `SEARCH` statements.
@@ -818,7 +868,7 @@ Raises an error if the collection does not exist.
818868

819869
### DELETE — remove a point
820870

821-
Deletes a single point from a collection by its ID. The point ID is the UUID returned by INSERT.
871+
Deletes a single point from a collection by its ID. The ID may be an integer or a UUID string, either generated by QQL or supplied explicitly on INSERT.
822872

823873
**Syntax:**
824874
```
@@ -890,9 +940,14 @@ SHOW COLLECTIONS
890940
**Rules:**
891941
- `--` to end-of-line is a comment and is ignored (inline or full-line)
892942
- Statements can span multiple lines (e.g. `INSERT BULK ... VALUES [...]`)
943+
- `RECOMMEND` statements work in `.qql` files the same way they do in the REPL
893944
- Blank lines between statements are ignored
894945
- By default all statements run even if one fails; use `--stop-on-error` to halt early
895946

947+
**Included examples:**
948+
- [`resources/sample.qql`](resources/sample.qql) seeds the demo medical dataset
949+
- [`resources/sample_v2.qql`](resources/sample_v2.qql) is a compact end-to-end example with explicit IDs and runnable `RECOMMEND` statements
950+
896951
**Example output:**
897952
```
898953
Executing: /path/to/script.qql
@@ -1165,15 +1220,15 @@ result = run_query(
11651220
"INSERT INTO COLLECTION notes VALUES {'text': 'hello world', 'author': 'alice', 'year': 2024}",
11661221
url="http://localhost:6333",
11671222
)
1168-
print(result.message) # "Inserted 1 point [<uuid>]"
1169-
print(result.data) # {"id": "...", "collection": "notes"}
1223+
print(result.message) # "Inserted 1 point [<id>]"
1224+
print(result.data) # {"id": 1001 or "<uuid>", "collection": "notes"}
11701225

11711226
# Insert with hybrid vectors
11721227
result = run_query(
11731228
"INSERT INTO COLLECTION notes VALUES {'text': 'hello world'} USING HYBRID",
11741229
url="http://localhost:6333",
11751230
)
1176-
print(result.message) # "Inserted 1 point [<uuid>] (hybrid)"
1231+
print(result.message) # "Inserted 1 point [<id>] (hybrid)"
11771232

11781233
# Dense search with WHERE filter
11791234
result = run_query(
@@ -1228,9 +1283,10 @@ class ExecutionResult:
12281283

12291284
| Operation | `result.data` type |
12301285
|---|---|
1231-
| INSERT (dense) | `{"id": "<uuid>", "collection": "<name>"}` |
1232-
| INSERT (hybrid) | `{"id": "<uuid>", "collection": "<name>"}` |
1286+
| INSERT (dense) | `{"id": int | "<uuid>", "collection": "<name>"}` |
1287+
| INSERT (hybrid) | `{"id": int | "<uuid>", "collection": "<name>"}` |
12331288
| SEARCH | `[{"id": str, "score": float, "payload": dict}, ...]` |
1289+
| RECOMMEND | `[{"id": str, "score": float, "payload": dict}, ...]` |
12341290
| SHOW COLLECTIONS | `["name1", "name2", ...]` |
12351291
| CREATE COLLECTION | `None` |
12361292
| DROP COLLECTION | `None` |

resources/sample_v2.qql

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
-- QQL sample v2
2+
-- Compact end-to-end showcase for deterministic inserts, search, query-time
3+
-- params, recommendation, and hybrid retrieval.
4+
5+
SHOW COLLECTIONS
6+
7+
-- Dense collection
8+
CREATE COLLECTION qql_sample_v2
9+
10+
INSERT BULK INTO COLLECTION qql_sample_v2 VALUES [
11+
{
12+
'id': 1001,
13+
'text': 'STEMI requires emergent revascularization with primary PCI and dual antiplatelet therapy.',
14+
'department': 'cardiology',
15+
'topic': 'acute_coronary_syndrome',
16+
'year': 2024
17+
},
18+
{
19+
'id': 1002,
20+
'text': 'Heart failure with reduced ejection fraction is treated with ARNI, beta-blocker, MRA, and SGLT2 inhibitor therapy.',
21+
'department': 'cardiology',
22+
'topic': 'heart_failure',
23+
'year': 2024
24+
},
25+
{
26+
'id': 2001,
27+
'text': 'Acute ischemic stroke management includes rapid imaging, alteplase within the treatment window, and thrombectomy in selected patients.',
28+
'department': 'neurology',
29+
'topic': 'stroke',
30+
'year': 2024
31+
},
32+
{
33+
'id': 2002,
34+
'text': 'Transient ischemic attack requires urgent secondary prevention and vascular risk stratification.',
35+
'department': 'neurology',
36+
'topic': 'stroke',
37+
'year': 2023
38+
},
39+
{
40+
'id': 2003,
41+
'text': 'Secondary stroke prevention includes antiplatelet therapy, statins, blood pressure control, and carotid evaluation when indicated.',
42+
'department': 'neurology',
43+
'topic': 'stroke_prevention',
44+
'year': 2024
45+
},
46+
{
47+
'id': 3001,
48+
'text': 'COPD exacerbations are managed with bronchodilators, corticosteroids, and antibiotics when indicated.',
49+
'department': 'pulmonology',
50+
'topic': 'copd',
51+
'year': 2024
52+
}
53+
]
54+
55+
-- Basic dense search
56+
SEARCH qql_sample_v2 SIMILAR TO 'stroke thrombolysis and thrombectomy' LIMIT 3
57+
58+
-- Dense search with filter
59+
SEARCH qql_sample_v2 SIMILAR TO 'secondary stroke prevention' LIMIT 3 WHERE department = 'neurology'
60+
61+
-- Query-time search params
62+
SEARCH qql_sample_v2 SIMILAR TO 'acute coronary syndrome' LIMIT 3 EXACT
63+
SEARCH qql_sample_v2 SIMILAR TO 'stroke prevention' LIMIT 3 WITH { hnsw_ef: 128 }
64+
65+
-- Recommendation from known example IDs
66+
RECOMMEND FROM qql_sample_v2
67+
POSITIVE IDS (2001)
68+
LIMIT 3
69+
70+
RECOMMEND FROM qql_sample_v2
71+
POSITIVE IDS (2001, 2002)
72+
NEGATIVE IDS (1001)
73+
STRATEGY 'best_score'
74+
LIMIT 3
75+
WHERE department = 'neurology'
76+
77+
-- Hybrid collection
78+
CREATE COLLECTION qql_sample_v2_hybrid HYBRID
79+
80+
INSERT BULK INTO COLLECTION qql_sample_v2_hybrid VALUES [
81+
{
82+
'id': 4001,
83+
'text': 'Transformer attention mechanisms improve long-context sequence modeling and retrieval quality.',
84+
'domain': 'ml',
85+
'year': 2024
86+
},
87+
{
88+
'id': 4002,
89+
'text': 'Sparse retrieval with BM25 remains strong for exact terminology and keyword-heavy document search.',
90+
'domain': 'ir',
91+
'year': 2023
92+
},
93+
{
94+
'id': 4003,
95+
'text': 'Hybrid retrieval combines dense semantic matching with sparse keyword search using reciprocal rank fusion.',
96+
'domain': 'ir',
97+
'year': 2024
98+
}
99+
] USING HYBRID
100+
101+
-- Hybrid and sparse-only search
102+
SEARCH qql_sample_v2_hybrid SIMILAR TO 'keyword retrieval and bm25' LIMIT 3 USING HYBRID
103+
SEARCH qql_sample_v2_hybrid SIMILAR TO 'keyword retrieval and bm25' LIMIT 3 USING SPARSE
104+
105+
SHOW COLLECTIONS

src/qql/ast_nodes.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,17 @@ class SearchStmt:
169169
rerank_model: str | None = None # cross-encoder model; None → CrossEncoderEmbedder.DEFAULT_MODEL
170170
with_clause: SearchWith | None = None
171171

172+
173+
@dataclass(frozen=True)
174+
class RecommendStmt:
175+
collection: str
176+
positive_ids: tuple[str | int, ...]
177+
negative_ids: tuple[str | int, ...] = ()
178+
limit: int = 10
179+
strategy: str | None = None
180+
query_filter: FilterExpr | None = None
181+
182+
172183
@dataclass(frozen=True)
173184
class DeleteStmt:
174185
collection: str
@@ -183,5 +194,6 @@ class DeleteStmt:
183194
| DropCollectionStmt
184195
| ShowCollectionsStmt
185196
| SearchStmt
197+
| RecommendStmt
186198
| DeleteStmt
187199
)

src/qql/cli.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
2626
[yellow]INSERT INTO COLLECTION[/yellow] <name> [yellow]VALUES[/yellow] {[yellow]'text'[/yellow]: '...', ...}
2727
Insert a point. 'text' is required and auto-vectorized.
28+
Optional: include [yellow]'id'[/yellow] in VALUES as an integer or UUID
2829
Optional: [yellow]USING MODEL[/yellow] '<model>'
2930
Optional: [yellow]USING HYBRID[/yellow] [DENSE MODEL '<model>'] [SPARSE MODEL '<model>']
3031
3132
[yellow]INSERT BULK INTO COLLECTION[/yellow] <name> [yellow]VALUES[/yellow] [{[yellow]'text'[/yellow]: '...', ...}, ...]
3233
Batch insert multiple points in a single call. Each dict must contain 'text'.
34+
Optional: each dict may include [yellow]'id'[/yellow] as an integer or UUID.
3335
Supports the same [yellow]USING[/yellow] clauses as INSERT.
3436
3537
[yellow]CREATE COLLECTION[/yellow] <name> [[yellow]HYBRID[/yellow]]
@@ -53,6 +55,13 @@
5355
Optional: [yellow]EXACT[/yellow] bypass HNSW and perform exact search
5456
Optional: [yellow]WITH[/yellow] { hnsw_ef: <int>, exact: <bool>, acorn: <bool> } search parameters
5557
58+
[yellow]RECOMMEND FROM[/yellow] <name> [yellow]POSITIVE IDS[/yellow] (<id>, ...)
59+
Find points similar to known examples.
60+
Optional: [yellow]NEGATIVE IDS[/yellow] (<id>, ...)
61+
Optional: [yellow]STRATEGY[/yellow] 'average_vector|best_score|sum_scores'
62+
Optional: [yellow]WHERE[/yellow] <filter>
63+
Requires: [yellow]LIMIT[/yellow] <n>
64+
5665
[yellow]DELETE FROM[/yellow] <name> [yellow]WHERE id =[/yellow] '<id>'
5766
Delete a point by its ID.
5867
@@ -66,8 +75,8 @@
6675
The file can be re-imported with EXECUTE.
6776
6877
Keyboard shortcuts:
69-
← → arrows move cursor within the current line
70-
↑ ↓ arrows scroll through command history
78+
Left/Right arrows move cursor within the current line
79+
Up/Down arrows scroll through command history
7180
Ctrl-A / Ctrl-E jump to beginning / end of line
7281
Ctrl-C cancel current input
7382
Ctrl-D exit shell
@@ -215,7 +224,7 @@ def dump(collection: str, output: str) -> None:
215224
from .dumper import dump_collection
216225

217226
console.print(
218-
f"[bold cyan]Dumping:[/bold cyan] '{collection}' {output}\n"
227+
f"[bold cyan]Dumping:[/bold cyan] '{collection}' -> {output}\n"
219228
)
220229
written, skipped = dump_collection(collection, output, client, console, err_console)
221230

@@ -315,7 +324,7 @@ def _launch_repl(cfg: QQLConfig) -> None:
315324
coll_name, out_path = parts[1], parts[2]
316325
from .dumper import dump_collection
317326
console.print(
318-
f"[bold cyan]Dumping:[/bold cyan] '{coll_name}' {out_path}\n"
327+
f"[bold cyan]Dumping:[/bold cyan] '{coll_name}' -> {out_path}\n"
319328
)
320329
written, skipped = dump_collection(
321330
coll_name, out_path, client, console, err_console
@@ -348,7 +357,7 @@ def _run_and_print(executor: Executor, query: str) -> None:
348357
err_console.print(f"[bold red]Failed:[/bold red] {result.message}")
349358
return
350359

351-
console.print(f"[bold green][/bold green] {result.message}")
360+
console.print(f"[bold green]OK[/bold green] {result.message}")
352361

353362
if result.data is None:
354363
return

src/qql/dumper.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ def dump_collection(
160160
if "text" not in payload:
161161
skipped += 1
162162
continue
163-
valid.append(payload)
163+
dump_payload = dict(payload)
164+
dump_payload["id"] = rec.id
165+
valid.append(dump_payload)
164166

165167
if valid:
166168
f.write(

0 commit comments

Comments
 (0)