Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ qql> SEARCH notes SIMILAR TO 'vector databases' LIMIT 5 USING HYBRID RERANK
- [Cross-Encoder Reranking (RERANK)](#cross-encoder-reranking-rerank)
- [SHOW COLLECTIONS — list collections](#show-collections--list-collections)
- [CREATE COLLECTION — create a collection](#create-collection--create-a-collection)
- [CREATE INDEX — create a payload index](#create-index--create-a-payload-index)
- [DROP COLLECTION — delete a collection](#drop-collection--delete-a-collection)
- [DELETE — remove a point](#delete--remove-a-point)
- [DELETE — remove points](#delete--remove-points)
- [Script Files](#script-files)
- [EXECUTE — run a script file](#execute--run-a-qql-script-file)
- [DUMP COLLECTION — export to script](#dump-collection--export-collection-to-a-qql-script-file)
Expand Down Expand Up @@ -902,6 +903,56 @@ If the collection already exists, the command succeeds with a message and does n

---

### CREATE INDEX — create a payload index

Creates a payload index on a collection field. Payload indexes speed up `WHERE` clause filtering by allowing Qdrant to efficiently match on indexed fields.

**Syntax:**
```
CREATE INDEX ON COLLECTION <collection_name> FOR <field_name> TYPE <schema_type>
```

**Supported schema types:**

| Type | Description |
|---|---|
| `keyword` | Exact string match (e.g. status, category) |
| `integer` | Whole numbers |
| `float` | Decimal numbers |
| `bool` | Boolean values |
| `text` | Full-text search (enables `MATCH` operators) |
| `geo` | Geospatial coordinates |
| `datetime` | Date/time values |

**Examples:**

Create a keyword index on a string field:
```sql
CREATE INDEX ON COLLECTION articles FOR category TYPE keyword
```

Create an integer index on a numeric field:
```sql
CREATE INDEX ON COLLECTION articles FOR year TYPE integer
```

Create a text index for full-text search:
```sql
CREATE INDEX ON COLLECTION articles FOR title TYPE text
```

Nested field (dot notation):
```sql
CREATE INDEX ON COLLECTION articles FOR meta.author TYPE keyword
```

**Rules:**
- The collection must already exist. Raises an error otherwise.
- The schema type must be one of: `keyword`, `integer`, `float`, `bool`, `text`, `geo`, `datetime`.
- Indexes are idempotent — creating the same index twice succeeds silently.

---

### DROP COLLECTION — delete a collection

Permanently deletes a collection and **all points inside it**. This operation is irreversible.
Expand All @@ -920,14 +971,15 @@ Raises an error if the collection does not exist.

---

### DELETE — remove a point
### DELETE — remove points

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.
Deletes one or more points from a collection. You can delete by specific ID or by a `WHERE` filter that matches multiple points.

**Syntax:**
```
DELETE FROM <collection_name> WHERE id = '<point_id>'
DELETE FROM <collection_name> WHERE id = <integer_id>
DELETE FROM <collection_name> WHERE <filter>
```

**Examples:**
Expand All @@ -942,6 +994,16 @@ Delete by integer ID:
DELETE FROM articles WHERE id = 42
```

Delete all points matching a filter:
```sql
DELETE FROM articles WHERE category = 'archived'
```

Delete with a compound filter:
```sql
DELETE FROM articles WHERE year < 2020 AND status = 'draft'
```

To find a point's ID, run a SEARCH first and copy the ID from the results table.

---
Expand Down Expand Up @@ -1399,3 +1461,5 @@ Expected output: **212 tests passing**.
| `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` |
| `Expected ')' ...` | Unclosed parenthesis in WHERE clause | Add the missing `)` to close the group |
| `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 |
| `Unknown index type '...'` | Invalid schema type in CREATE INDEX | Use one of: `keyword`, `integer`, `float`, `bool`, `text`, `geo`, `datetime` |
| `Qdrant error during CREATE INDEX: ...` | Qdrant rejected the index creation | Check field name and collection state |
11 changes: 10 additions & 1 deletion src/qql/ast_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ class CreateCollectionStmt:
model: str | None = None # dense model; None → use config default


@dataclass(frozen=True)
class CreateIndexStmt:
collection: str
field_name: str
schema: str


@dataclass(frozen=True)
class DropCollectionStmt:
collection: str
Expand Down Expand Up @@ -188,14 +195,16 @@ class RecommendStmt:
@dataclass(frozen=True)
class DeleteStmt:
collection: str
point_id: str | int
point_id: str | int | None = None
query_filter: FilterExpr | None = None


# Union type for all top-level statement nodes
ASTNode = (
InsertStmt
| InsertBulkStmt
| CreateCollectionStmt
| CreateIndexStmt
| DropCollectionStmt
| ShowCollectionsStmt
| SearchStmt
Expand Down
61 changes: 59 additions & 2 deletions src/qql/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
MatchValue,
Modifier,
PayloadField,
PayloadSchemaType,
PointStruct,
Prefetch,
Range,
Expand All @@ -44,6 +45,7 @@
BetweenExpr,
CompareExpr,
CreateCollectionStmt,
CreateIndexStmt,
DeleteStmt,
DropCollectionStmt,
FilterExpr,
Expand Down Expand Up @@ -93,6 +95,8 @@ def execute(self, node: ASTNode) -> ExecutionResult:
return self._execute_insert(node)
if isinstance(node, CreateCollectionStmt):
return self._execute_create(node)
if isinstance(node, CreateIndexStmt):
return self._execute_create_index(node)
if isinstance(node, DropCollectionStmt):
return self._execute_drop(node)
if isinstance(node, ShowCollectionsStmt):
Expand Down Expand Up @@ -321,6 +325,43 @@ def _execute_create(self, node: CreateCollectionStmt) -> ExecutionResult:
message=f"Collection '{node.collection}' created ({dims}-dimensional vectors, cosine distance)",
)

def _execute_create_index(self, node: CreateIndexStmt) -> ExecutionResult:
if not self._client.collection_exists(node.collection):
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")

schema_map = {
"keyword": PayloadSchemaType.KEYWORD,
"integer": PayloadSchemaType.INTEGER,
"float": PayloadSchemaType.FLOAT,
"bool": PayloadSchemaType.BOOL,
"text": PayloadSchemaType.TEXT,
"geo": PayloadSchemaType.GEO,
"datetime": PayloadSchemaType.DATETIME,
}
try:
field_schema = schema_map[node.schema]
except KeyError as e:
raise QQLRuntimeError(
"Unknown index type '"
f"{node.schema}'. Expected one of: keyword, integer, float, bool, text, geo, datetime"
) from e

try:
self._client.create_payload_index(
collection_name=node.collection,
field_name=node.field_name,
field_schema=field_schema,
)
except UnexpectedResponse as e:
raise QQLRuntimeError(f"Qdrant error during CREATE INDEX: {e}") from e

return ExecutionResult(
success=True,
message=(
f"Created index on '{node.collection}.{node.field_name}' as '{node.schema}'"
),
)

def _execute_drop(self, node: DropCollectionStmt) -> ExecutionResult:
if not self._client.collection_exists(node.collection):
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
Expand Down Expand Up @@ -648,9 +689,25 @@ def _execute_delete(self, node: DeleteStmt) -> ExecutionResult:
if not self._client.collection_exists(node.collection):
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")

from qdrant_client.models import PointIdsList

try:
if node.query_filter is not None:
self._client.delete(
collection_name=node.collection,
wait=True,
points_selector=self._wrap_as_filter(
self._build_qdrant_filter(node.query_filter)
),
)
return ExecutionResult(
success=True,
message=f"Deleted points from '{node.collection}' by filter",
)

from qdrant_client.models import PointIdsList

if node.point_id is None:
raise QQLRuntimeError("DELETE requires either a point id or a filter")

self._client.delete(
collection_name=node.collection,
wait=True,
Expand Down
8 changes: 8 additions & 0 deletions src/qql/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class TokenKind(Enum):
WITH = auto()
ACORN = auto()
CREATE = auto()
INDEX = auto()
ON = auto()
DROP = auto()
SHOW = auto()
COLLECTIONS = auto()
Expand All @@ -42,6 +44,8 @@ class TokenKind(Enum):
FROM = auto()
WHERE = auto()
ID = auto()
FOR = auto()
TYPE = auto()
# ── Filter keywords ───────────────────────────────────────────────────
AND = auto()
OR = auto()
Expand Down Expand Up @@ -96,6 +100,8 @@ class TokenKind(Enum):
"WITH": TokenKind.WITH,
"ACORN": TokenKind.ACORN,
"CREATE": TokenKind.CREATE,
"INDEX": TokenKind.INDEX,
"ON": TokenKind.ON,
"DROP": TokenKind.DROP,
"SHOW": TokenKind.SHOW,
"COLLECTIONS": TokenKind.COLLECTIONS,
Expand All @@ -117,6 +123,8 @@ class TokenKind(Enum):
"FROM": TokenKind.FROM,
"WHERE": TokenKind.WHERE,
"ID": TokenKind.ID,
"FOR": TokenKind.FOR,
"TYPE": TokenKind.TYPE,
# Filter keywords
"AND": TokenKind.AND,
"OR": TokenKind.OR,
Expand Down
86 changes: 51 additions & 35 deletions src/qql/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
BetweenExpr,
CompareExpr,
CreateCollectionStmt,
CreateIndexStmt,
DeleteStmt,
DropCollectionStmt,
FilterExpr,
Expand Down Expand Up @@ -150,34 +151,45 @@ def _parse_insert_bulk_body(self) -> InsertBulkStmt:

def _parse_create(self) -> CreateCollectionStmt:
self._expect(TokenKind.CREATE)
self._expect(TokenKind.COLLECTION)
collection = self._parse_identifier()
hybrid: bool = False
model: str | None = None

if self._peek().kind == TokenKind.HYBRID:
# Bare HYBRID shorthand — backward compat
if self._peek().kind == TokenKind.COLLECTION:
self._advance()
hybrid = True
elif self._peek().kind == TokenKind.USING:
self._advance() # consume USING
collection = self._parse_identifier()
hybrid: bool = False
model: str | None = None

if self._peek().kind == TokenKind.HYBRID:
self._advance() # consume HYBRID
# Bare HYBRID shorthand — backward compat
self._advance()
hybrid = True
# Optional DENSE MODEL sub-clause
if self._peek().kind == TokenKind.DENSE:
self._advance() # consume DENSE
elif self._peek().kind == TokenKind.USING:
self._advance() # consume USING
if self._peek().kind == TokenKind.HYBRID:
self._advance() # consume HYBRID
hybrid = True
# Optional DENSE MODEL sub-clause
if self._peek().kind == TokenKind.DENSE:
self._advance() # consume DENSE
self._expect(TokenKind.MODEL)
model = self._expect(TokenKind.STRING).value
else:
self._expect(TokenKind.MODEL)
model = self._expect(TokenKind.STRING).value
else:
self._expect(TokenKind.MODEL)
model = self._expect(TokenKind.STRING).value

return CreateCollectionStmt(
collection=collection,
hybrid=hybrid,
model=model,
)
return CreateCollectionStmt(
collection=collection,
hybrid=hybrid,
model=model,
)

self._expect(TokenKind.INDEX)
self._expect(TokenKind.ON)
self._expect(TokenKind.COLLECTION)
collection = self._parse_identifier()
self._expect(TokenKind.FOR)
field_name = self._parse_field_path()
self._expect(TokenKind.TYPE)
schema = self._expect(TokenKind.IDENTIFIER).value.lower()
return CreateIndexStmt(collection=collection, field_name=field_name, schema=schema)

def _parse_drop(self) -> DropCollectionStmt:
self._expect(TokenKind.DROP)
Expand Down Expand Up @@ -356,20 +368,24 @@ def _parse_delete(self) -> DeleteStmt:
self._expect(TokenKind.FROM)
collection = self._parse_identifier()
self._expect(TokenKind.WHERE)
self._expect(TokenKind.ID)
self._expect(TokenKind.EQUALS)
tok = self._peek()
if tok.kind == TokenKind.STRING:
self._advance()
point_id: str | int = tok.value
elif tok.kind == TokenKind.INTEGER:
if self._peek().kind == TokenKind.ID:
self._advance()
point_id = int(tok.value)
else:
raise QQLSyntaxError(
f"Expected string or integer for point id, got '{tok.value}'", tok.pos
)
return DeleteStmt(collection=collection, point_id=point_id)
self._expect(TokenKind.EQUALS)
tok = self._peek()
if tok.kind == TokenKind.STRING:
self._advance()
point_id: str | int = tok.value
elif tok.kind == TokenKind.INTEGER:
self._advance()
point_id = int(tok.value)
else:
raise QQLSyntaxError(
f"Expected string or integer for point id, got '{tok.value}'", tok.pos
)
return DeleteStmt(collection=collection, point_id=point_id)

query_filter = self._parse_filter_expr()
return DeleteStmt(collection=collection, query_filter=query_filter)

# ── WHERE clause filter parsing (precedence: NOT > AND > OR) ─────────

Expand Down
Loading
Loading