Skip to content

Commit 0d12724

Browse files
committed
feat: add SCROLL statement for pagination support in QQL
1 parent c56254b commit 0d12724

16 files changed

Lines changed: 270 additions & 19 deletions

README.md

Lines changed: 7 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, turbo, binary, product), SQL-style `WHERE` filters, script execution, and collection dump/restore.
10+
Write `INSERT`, `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.
1111

1212
```
1313
qql> INSERT INTO COLLECTION notes VALUES {'text': 'Qdrant is a vector database', 'author': 'alice', 'year': 2024}
@@ -82,7 +82,7 @@ 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 / RECOMMEND / Hybrid / RERANK](docs/search.md) | Semantic search, hybrid, reranking, recommendations |
85+
| [SEARCH / SCROLL / RECOMMEND / Hybrid / RERANK](docs/search.md) | Semantic search, pagination, hybrid, reranking, recommendations |
8686
| [WHERE Filters](docs/filters.md) | Full SQL-style filter operators |
8787
| [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 |
@@ -104,6 +104,11 @@ SEARCH articles SIMILAR TO 'query' LIMIT 10 WHERE year >= 2020
104104
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID
105105
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID RERANK
106106

107+
-- Scroll
108+
SCROLL FROM articles LIMIT 50
109+
SCROLL FROM articles WHERE year >= 2024 LIMIT 50
110+
SCROLL FROM articles AFTER 'cursor-id' LIMIT 50
111+
107112
-- Recommend
108113
RECOMMEND FROM articles POSITIVE IDS (1001, 1002) LIMIT 5
109114

docs/getting-started.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ SEARCH notes SIMILAR TO 'vector storage engines' LIMIT 3
138138
-- Filter results
139139
SEARCH notes SIMILAR TO 'vector databases' LIMIT 5 WHERE year >= 2023
140140

141+
-- Browse with pagination
142+
SCROLL FROM notes LIMIT 10
143+
141144
-- List all collections
142145
SHOW COLLECTIONS
143146
```
@@ -147,7 +150,7 @@ SHOW COLLECTIONS
147150
## Next Steps
148151

149152
- [INSERT / INSERT BULK](insert.md) — adding documents
150-
- [SEARCH / RECOMMEND / Hybrid / RERANK](search.md) — querying
153+
- [SEARCH / SCROLL / RECOMMEND / Hybrid / RERANK](search.md) — querying
151154
- [WHERE Filters](filters.md) — payload filtering
152155
- [Collections & Quantization](collections.md) — managing collections
153156
- [Scripts: EXECUTE / DUMP](scripts.md) — automating with script files

docs/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ <h3>INSERT / INSERT BULK</h3>
148148
<p>Adding documents, batch inserts, payload types</p>
149149
</a>
150150
<a class="card" href="search">
151-
<h3>SEARCH / RECOMMEND</h3>
152-
<p>Semantic search, hybrid search, reranking, recommendations</p>
151+
<h3>SEARCH / SCROLL / RECOMMEND</h3>
152+
<p>Semantic search, pagination, hybrid search, reranking, recommendations</p>
153153
</a>
154154
<a class="card" href="filters">
155155
<h3>WHERE Filters</h3>

docs/programmatic.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ result = run_query(
4040
for hit in result.data:
4141
print(hit["score"], hit["payload"])
4242

43+
# Scroll / pagination
44+
result = run_query(
45+
"SCROLL FROM notes LIMIT 2",
46+
url="http://localhost:6333",
47+
)
48+
for point in result.data["points"]:
49+
print(point["id"], point["payload"])
50+
print(result.data["next_offset"])
51+
4352
# Bulk insert (all records embedded and upserted in one call)
4453
result = run_query(
4554
"""INSERT BULK INTO COLLECTION notes VALUES [
@@ -112,6 +121,7 @@ class ExecutionResult:
112121
| INSERT (hybrid) | `{"id": int \| "<uuid>", "collection": "<name>"}` |
113122
| INSERT BULK | `None` (count in `result.message`) |
114123
| SEARCH | `[{"id": str, "score": float, "payload": dict}, ...]` |
124+
| SCROLL | `{"points": [{"id": str, "payload": dict}, ...], "next_offset": str \| None}` |
115125
| RECOMMEND | `[{"id": str, "score": float, "payload": dict}, ...]` |
116126
| SHOW COLLECTIONS | `["name1", "name2", ...]` |
117127
| CREATE COLLECTION | `None` |

docs/reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,13 @@ Expected output: **375 tests passing**.
171171
| `Connection failed: ...` | Qdrant unreachable at given URL | Check that Qdrant is running and the URL is correct |
172172
| `INSERT requires a 'text' field in VALUES` | `text` key missing from the VALUES dict | Add `'text': '...'` to your dict |
173173
| `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 |
174-
| `Collection '...' does not exist` | SEARCH / DROP / DELETE on a non-existent collection | Check name spelling or run `SHOW COLLECTIONS` |
174+
| `Collection '...' does not exist` | SEARCH / SCROLL / DROP / DELETE on a non-existent collection | Check name spelling or run `SHOW COLLECTIONS` |
175175
| `Unexpected token '...'; expected a QQL statement keyword` | Unrecognized statement | Check the query syntax; QQL does not support SQL SELECT |
176176
| `Unterminated string literal (at position N)` | A string is missing its closing quote | Close the string with a matching `'` or `"` |
177177
| `Unexpected character '@' (at position N)` | A character not part of QQL syntax | Remove or quote the offending character |
178178
| `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` |
179179
| `Expected ')' ...` | Unclosed parenthesis in WHERE clause | Add the missing `)` to close the group |
180180
| `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 |
181+
| `Qdrant error during SCROLL: ...` | Qdrant rejected scroll request | Verify collection state, filter, and cursor (`AFTER`) value |
181182
| `Unknown index type '...'` | Invalid schema type in CREATE INDEX | Use one of: `keyword`, `integer`, `float`, `bool`, `text`, `geo`, `datetime` |
182183
| `Qdrant error during CREATE INDEX: ...` | Qdrant rejected the index creation | Check field name and collection state |

docs/search.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# SEARCH, RECOMMEND, Hybrid Search & Reranking
1+
# SEARCH, SCROLL, RECOMMEND, Hybrid Search & Reranking
22

33
---
44

@@ -98,6 +98,32 @@ SEARCH articles SIMILAR TO 'RAG' LIMIT 10 WHERE tag = 'li' WITH { acorn: true }
9898

9999
---
100100

101+
## SCROLL — pagination / browsing
102+
103+
Use `SCROLL` to iterate through points in a collection page by page.
104+
105+
**Syntax:**
106+
```sql
107+
SCROLL FROM <collection_name> LIMIT <n>
108+
SCROLL FROM <collection_name> WHERE <filter> LIMIT <n>
109+
SCROLL FROM <collection_name> AFTER '<point_id>' LIMIT <n>
110+
SCROLL FROM <collection_name> WHERE <filter> AFTER <point_id> LIMIT <n>
111+
```
112+
113+
**Examples:**
114+
```sql
115+
SCROLL FROM articles LIMIT 50
116+
SCROLL FROM articles WHERE year >= 2024 LIMIT 50
117+
SCROLL FROM articles AFTER 'cursor-id' LIMIT 50
118+
```
119+
120+
**Behavior:**
121+
- Returns points in ID order with payloads.
122+
- Returns a `next_offset` cursor when more points are available.
123+
- Use `AFTER <next_offset>` to fetch the next page.
124+
125+
---
126+
101127
## Hybrid Search (USING HYBRID)
102128

103129
Hybrid search combines **dense semantic vectors** and **sparse BM25 keyword vectors** in a single query and merges the results with Qdrant's **Reciprocal Rank Fusion (RRF)** algorithm. This typically outperforms either method alone.

src/qql/ast_nodes.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,14 @@ class ShowCollectionsStmt:
180180
pass
181181

182182

183+
@dataclass(frozen=True)
184+
class ScrollStmt:
185+
collection: str
186+
limit: int
187+
query_filter: FilterExpr | None = None
188+
after: str | int | None = None
189+
190+
183191
@dataclass(frozen=True)
184192
class SearchStmt:
185193
collection: str
@@ -225,6 +233,7 @@ class DeleteStmt:
225233
| CreateIndexStmt
226234
| DropCollectionStmt
227235
| ShowCollectionsStmt
236+
| ScrollStmt
228237
| SearchStmt
229238
| RecommendStmt
230239
| DeleteStmt

src/qql/cli.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
[yellow]SHOW COLLECTIONS[/yellow]
5050
List all collections in the connected Qdrant instance.
5151
52+
[yellow]SCROLL FROM[/yellow] <name> [yellow]LIMIT[/yellow] <n>
53+
Paginate points by ID order.
54+
Optional: [yellow]WHERE[/yellow] <filter>
55+
Optional: [yellow]AFTER[/yellow] '<id>'|<int>
56+
5257
[yellow]SEARCH[/yellow] <name> [yellow]SIMILAR TO[/yellow] '<text>' [yellow]LIMIT[/yellow] <n>
5358
Semantic search by vector similarity.
5459
Optional: [yellow]USING MODEL[/yellow] '<model>'
@@ -400,5 +405,19 @@ def _run_and_print(executor: Executor, query: str) -> None:
400405
console.print(table)
401406
return
402407

408+
# Pretty-print scroll results
409+
if isinstance(result.data, dict) and "points" in result.data and "next_offset" in result.data:
410+
points = result.data["points"]
411+
if points:
412+
table = Table(show_header=True, header_style="bold cyan")
413+
table.add_column("ID")
414+
table.add_column("Payload")
415+
for point in points:
416+
table.add_row(point["id"], str(point["payload"]))
417+
console.print(table)
418+
if result.data["next_offset"] is not None:
419+
console.print(f"[dim]next_offset: {result.data['next_offset']}[/dim]")
420+
return
421+
403422
# Fallback: print data as-is
404423
console.print(result.data)

src/qql/executor.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
QuantizationConfig,
7777
QuantizationType,
7878
RecommendStmt,
79+
ScrollStmt,
7980
SearchStmt,
8081
SearchWith,
8182
ShowCollectionsStmt,
@@ -115,6 +116,8 @@ def execute(self, node: ASTNode) -> ExecutionResult:
115116
return self._execute_drop(node)
116117
if isinstance(node, ShowCollectionsStmt):
117118
return self._execute_show(node)
119+
if isinstance(node, ScrollStmt):
120+
return self._execute_scroll(node)
118121
if isinstance(node, SearchStmt):
119122
return self._execute_search(node)
120123
if isinstance(node, RecommendStmt):
@@ -412,6 +415,38 @@ def _execute_show(self, node: ShowCollectionsStmt) -> ExecutionResult:
412415
data=names,
413416
)
414417

418+
def _execute_scroll(self, node: ScrollStmt) -> ExecutionResult:
419+
if not self._client.collection_exists(node.collection):
420+
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
421+
422+
scroll_filter: Filter | None = None
423+
if node.query_filter is not None:
424+
scroll_filter = self._wrap_as_filter(
425+
self._build_qdrant_filter(node.query_filter)
426+
)
427+
428+
try:
429+
records, next_offset = self._client.scroll(
430+
collection_name=node.collection,
431+
scroll_filter=scroll_filter,
432+
limit=node.limit,
433+
offset=node.after,
434+
with_payload=True,
435+
with_vectors=False,
436+
)
437+
except UnexpectedResponse as e:
438+
raise QQLRuntimeError(f"Qdrant error during SCROLL: {e}") from e
439+
440+
points = [
441+
{"id": str(rec.id), "payload": rec.payload or {}}
442+
for rec in records
443+
]
444+
return ExecutionResult(
445+
success=True,
446+
message=f"Scrolled {len(points)} point(s) from '{node.collection}'",
447+
data={"points": points, "next_offset": None if next_offset is None else str(next_offset)},
448+
)
449+
415450
def _execute_search(self, node: SearchStmt) -> ExecutionResult:
416451
if not self._client.collection_exists(node.collection):
417452
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")

src/qql/lexer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class TokenKind(Enum):
3535
DROP = auto()
3636
SHOW = auto()
3737
COLLECTIONS = auto()
38+
SCROLL = auto()
3839
SEARCH = auto()
3940
RECOMMEND = auto()
4041
POSITIVE = auto()
@@ -47,6 +48,7 @@ class TokenKind(Enum):
4748
OFFSET = auto()
4849
SCORE = auto()
4950
THRESHOLD = auto()
51+
AFTER = auto()
5052
LOOKUP = auto()
5153
VECTOR = auto()
5254
DELETE = auto()
@@ -123,6 +125,7 @@ class TokenKind(Enum):
123125
"DROP": TokenKind.DROP,
124126
"SHOW": TokenKind.SHOW,
125127
"COLLECTIONS": TokenKind.COLLECTIONS,
128+
"SCROLL": TokenKind.SCROLL,
126129
"SEARCH": TokenKind.SEARCH,
127130
"RECOMMEND": TokenKind.RECOMMEND,
128131
"POSITIVE": TokenKind.POSITIVE,
@@ -135,6 +138,7 @@ class TokenKind(Enum):
135138
"OFFSET": TokenKind.OFFSET,
136139
"SCORE": TokenKind.SCORE,
137140
"THRESHOLD": TokenKind.THRESHOLD,
141+
"AFTER": TokenKind.AFTER,
138142
"LOOKUP": TokenKind.LOOKUP,
139143
"VECTOR": TokenKind.VECTOR,
140144
"DELETE": TokenKind.DELETE,

0 commit comments

Comments
 (0)