qql-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qql/__init__.py +45 -0
- qql/ast_nodes.py +49 -0
- qql/cli.py +186 -0
- qql/config.py +42 -0
- qql/embedder.py +33 -0
- qql/exceptions.py +15 -0
- qql/executor.py +180 -0
- qql/lexer.py +168 -0
- qql/parser.py +226 -0
- qql_cli-0.1.0.dist-info/METADATA +626 -0
- qql_cli-0.1.0.dist-info/RECORD +14 -0
- qql_cli-0.1.0.dist-info/WHEEL +4 -0
- qql_cli-0.1.0.dist-info/entry_points.txt +2 -0
- qql_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
qql/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = version("qql-cli")
|
|
5
|
+
except PackageNotFoundError:
|
|
6
|
+
__version__ = "0.0.0+unknown"
|
|
7
|
+
|
|
8
|
+
from .config import DEFAULT_MODEL, QQLConfig, load_config
|
|
9
|
+
from .exceptions import QQLError, QQLRuntimeError, QQLSyntaxError
|
|
10
|
+
from .executor import ExecutionResult, Executor
|
|
11
|
+
from .lexer import Lexer
|
|
12
|
+
from .parser import Parser
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"QQLConfig",
|
|
17
|
+
"QQLError",
|
|
18
|
+
"QQLRuntimeError",
|
|
19
|
+
"QQLSyntaxError",
|
|
20
|
+
"ExecutionResult",
|
|
21
|
+
"Executor",
|
|
22
|
+
"Lexer",
|
|
23
|
+
"Parser",
|
|
24
|
+
"run_query",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_query(
|
|
29
|
+
query: str,
|
|
30
|
+
url: str = "http://localhost:6333",
|
|
31
|
+
secret: str | None = None,
|
|
32
|
+
default_model: str | None = None,
|
|
33
|
+
) -> ExecutionResult:
|
|
34
|
+
"""Convenience function for programmatic use."""
|
|
35
|
+
from qdrant_client import QdrantClient
|
|
36
|
+
|
|
37
|
+
cfg = QQLConfig(
|
|
38
|
+
url=url,
|
|
39
|
+
secret=secret,
|
|
40
|
+
default_model=default_model or DEFAULT_MODEL,
|
|
41
|
+
)
|
|
42
|
+
client = QdrantClient(url=url, api_key=secret)
|
|
43
|
+
tokens = Lexer().tokenize(query)
|
|
44
|
+
node = Parser(tokens).parse()
|
|
45
|
+
return Executor(client, cfg).execute(node)
|
qql/ast_nodes.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class InsertStmt:
|
|
7
|
+
collection: str
|
|
8
|
+
values: dict[str, Any] # must contain "text" key
|
|
9
|
+
model: str | None # None → use default
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CreateCollectionStmt:
|
|
14
|
+
collection: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class DropCollectionStmt:
|
|
19
|
+
collection: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ShowCollectionsStmt:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SearchStmt:
|
|
29
|
+
collection: str
|
|
30
|
+
query_text: str
|
|
31
|
+
limit: int
|
|
32
|
+
model: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class DeleteStmt:
|
|
37
|
+
collection: str
|
|
38
|
+
point_id: str | int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Union type for all statement nodes
|
|
42
|
+
ASTNode = (
|
|
43
|
+
InsertStmt
|
|
44
|
+
| CreateCollectionStmt
|
|
45
|
+
| DropCollectionStmt
|
|
46
|
+
| ShowCollectionsStmt
|
|
47
|
+
| SearchStmt
|
|
48
|
+
| DeleteStmt
|
|
49
|
+
)
|
qql/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.prompt import Prompt
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .config import delete_config, load_config, save_config, QQLConfig
|
|
11
|
+
from .exceptions import QQLError
|
|
12
|
+
from .executor import Executor
|
|
13
|
+
from .lexer import Lexer
|
|
14
|
+
from .parser import Parser
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
err_console = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
HELP_TEXT = """
|
|
20
|
+
[bold cyan]QQL — Qdrant Query Language[/bold cyan]
|
|
21
|
+
|
|
22
|
+
Available statements:
|
|
23
|
+
|
|
24
|
+
[yellow]INSERT INTO COLLECTION[/yellow] <name> [yellow]VALUES[/yellow] {[yellow]'text'[/yellow]: '...', ...}
|
|
25
|
+
Insert a point. 'text' is required and auto-vectorized.
|
|
26
|
+
Optional: [yellow]USING MODEL[/yellow] '<model>'
|
|
27
|
+
|
|
28
|
+
[yellow]CREATE COLLECTION[/yellow] <name>
|
|
29
|
+
Create a new collection (uses default model dimensions).
|
|
30
|
+
|
|
31
|
+
[yellow]DROP COLLECTION[/yellow] <name>
|
|
32
|
+
Delete a collection and all its points.
|
|
33
|
+
|
|
34
|
+
[yellow]SHOW COLLECTIONS[/yellow]
|
|
35
|
+
List all collections in the connected Qdrant instance.
|
|
36
|
+
|
|
37
|
+
[yellow]SEARCH[/yellow] <name> [yellow]SIMILAR TO[/yellow] '<text>' [yellow]LIMIT[/yellow] <n>
|
|
38
|
+
Semantic search by vector similarity.
|
|
39
|
+
Optional: [yellow]USING MODEL[/yellow] '<model>'
|
|
40
|
+
|
|
41
|
+
[yellow]DELETE FROM[/yellow] <name> [yellow]WHERE id =[/yellow] '<id>'
|
|
42
|
+
Delete a point by its ID.
|
|
43
|
+
|
|
44
|
+
Type [bold]exit[/bold] or [bold]quit[/bold] to leave the shell.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── CLI entry point ────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
@click.group(invoke_without_command=True)
|
|
51
|
+
@click.pass_context
|
|
52
|
+
def main(ctx: click.Context) -> None:
|
|
53
|
+
"""QQL — Qdrant Query Language CLI."""
|
|
54
|
+
ctx.ensure_object(dict)
|
|
55
|
+
if ctx.invoked_subcommand is None:
|
|
56
|
+
# No subcommand → load saved config and launch REPL
|
|
57
|
+
cfg = load_config()
|
|
58
|
+
if cfg is None:
|
|
59
|
+
err_console.print(
|
|
60
|
+
"[bold red]Not connected.[/bold red] "
|
|
61
|
+
"Run: [bold]qql connect --url <url>[/bold]"
|
|
62
|
+
)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
_launch_repl(cfg)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── connect ────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
@main.command()
|
|
70
|
+
@click.option("--url", required=True, help="Qdrant instance URL, e.g. http://localhost:6333")
|
|
71
|
+
@click.option("--secret", default=None, help="API key / secret (optional)")
|
|
72
|
+
def connect(url: str, secret: str | None) -> None:
|
|
73
|
+
"""Connect to a Qdrant instance and launch the QQL shell."""
|
|
74
|
+
from qdrant_client import QdrantClient
|
|
75
|
+
|
|
76
|
+
console.print(f"Connecting to [bold]{url}[/bold]...")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
client = QdrantClient(url=url, api_key=secret)
|
|
80
|
+
client.get_collections() # validate connection
|
|
81
|
+
except Exception as e:
|
|
82
|
+
err_console.print(f"[bold red]Connection failed:[/bold red] {e}")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
cfg = QQLConfig(url=url, secret=secret)
|
|
86
|
+
save_config(cfg)
|
|
87
|
+
console.print(f"[bold green]Connected.[/bold green] Config saved to ~/.qql/config.json\n")
|
|
88
|
+
_launch_repl(cfg)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── disconnect ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
@main.command()
|
|
94
|
+
def disconnect() -> None:
|
|
95
|
+
"""Remove saved connection config."""
|
|
96
|
+
delete_config()
|
|
97
|
+
console.print("Disconnected. Config removed.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── REPL ───────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _launch_repl(cfg: QQLConfig) -> None:
|
|
103
|
+
from qdrant_client import QdrantClient
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
client = QdrantClient(url=cfg.url, api_key=cfg.secret)
|
|
107
|
+
client.get_collections()
|
|
108
|
+
except Exception as e:
|
|
109
|
+
err_console.print(f"[bold red]Could not connect to {cfg.url}:[/bold red] {e}")
|
|
110
|
+
err_console.print("Run [bold]qql connect --url <url>[/bold] to update your connection.")
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
executor = Executor(client, cfg)
|
|
114
|
+
|
|
115
|
+
console.print(f"[bold cyan]QQL Interactive Shell[/bold cyan] • {cfg.url}")
|
|
116
|
+
console.print("Type [bold]help[/bold] for available commands or [bold]exit[/bold] to quit.\n")
|
|
117
|
+
|
|
118
|
+
while True:
|
|
119
|
+
try:
|
|
120
|
+
query = Prompt.ask("[bold green]qql>[/bold green]").strip()
|
|
121
|
+
except (EOFError, KeyboardInterrupt):
|
|
122
|
+
console.print("\nBye.")
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
if not query:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
low = query.lower()
|
|
129
|
+
if low in ("exit", "quit", "\\q", ":q"):
|
|
130
|
+
console.print("Bye.")
|
|
131
|
+
break
|
|
132
|
+
if low in ("help", "\\h", "?"):
|
|
133
|
+
console.print(HELP_TEXT)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
_run_and_print(executor, query)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _run_and_print(executor: Executor, query: str) -> None:
|
|
140
|
+
try:
|
|
141
|
+
tokens = Lexer().tokenize(query)
|
|
142
|
+
node = Parser(tokens).parse()
|
|
143
|
+
result = executor.execute(node)
|
|
144
|
+
except QQLError as e:
|
|
145
|
+
err_console.print(f"[bold red]Error:[/bold red] {e}")
|
|
146
|
+
return
|
|
147
|
+
except Exception as e:
|
|
148
|
+
err_console.print(f"[bold red]Unexpected error:[/bold red] {e}")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if not result.success:
|
|
152
|
+
err_console.print(f"[bold red]Failed:[/bold red] {result.message}")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
console.print(f"[bold green]✓[/bold green] {result.message}")
|
|
156
|
+
|
|
157
|
+
if result.data is None:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Pretty-print collections list
|
|
161
|
+
if isinstance(result.data, list) and all(isinstance(x, str) for x in result.data):
|
|
162
|
+
if result.data:
|
|
163
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
164
|
+
table.add_column("Collection")
|
|
165
|
+
for name in result.data:
|
|
166
|
+
table.add_row(name)
|
|
167
|
+
console.print(table)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Pretty-print search results
|
|
171
|
+
if isinstance(result.data, list) and result.data and isinstance(result.data[0], dict) and "score" in result.data[0]:
|
|
172
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
173
|
+
table.add_column("Score", justify="right")
|
|
174
|
+
table.add_column("ID")
|
|
175
|
+
table.add_column("Payload")
|
|
176
|
+
for hit in result.data:
|
|
177
|
+
table.add_row(
|
|
178
|
+
str(hit["score"]),
|
|
179
|
+
hit["id"],
|
|
180
|
+
str(hit["payload"]),
|
|
181
|
+
)
|
|
182
|
+
console.print(table)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# Fallback: print data as-is
|
|
186
|
+
console.print(result.data)
|
qql/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".qql"
|
|
9
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class QQLConfig:
|
|
16
|
+
url: str
|
|
17
|
+
secret: str | None = None
|
|
18
|
+
default_model: str = DEFAULT_MODEL
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def save_config(cfg: QQLConfig) -> None:
|
|
22
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
with CONFIG_PATH.open("w") as f:
|
|
24
|
+
json.dump(asdict(cfg), f, indent=2)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config() -> QQLConfig | None:
|
|
28
|
+
"""Return saved config, or None if not yet connected."""
|
|
29
|
+
if not CONFIG_PATH.exists():
|
|
30
|
+
return None
|
|
31
|
+
with CONFIG_PATH.open() as f:
|
|
32
|
+
data = json.load(f)
|
|
33
|
+
return QQLConfig(
|
|
34
|
+
url=data["url"],
|
|
35
|
+
secret=data.get("secret"),
|
|
36
|
+
default_model=data.get("default_model", DEFAULT_MODEL),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def delete_config() -> None:
|
|
41
|
+
if CONFIG_PATH.exists():
|
|
42
|
+
CONFIG_PATH.unlink()
|
qql/embedder.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Embedder:
|
|
5
|
+
DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|
6
|
+
|
|
7
|
+
# Class-level cache: model_name → TextEmbedding instance
|
|
8
|
+
# Avoids reloading the same model across multiple Embedder() calls in one session.
|
|
9
|
+
_cache: dict[str, object] = {}
|
|
10
|
+
|
|
11
|
+
def __init__(self, model_name: str = DEFAULT_MODEL) -> None:
|
|
12
|
+
self._model_name = model_name
|
|
13
|
+
if model_name not in Embedder._cache:
|
|
14
|
+
# Import here so the module loads even without fastembed installed
|
|
15
|
+
# (the error surfaces only when embedding is actually attempted)
|
|
16
|
+
from fastembed import TextEmbedding
|
|
17
|
+
|
|
18
|
+
Embedder._cache[model_name] = TextEmbedding(model_name)
|
|
19
|
+
self._model = Embedder._cache[model_name]
|
|
20
|
+
|
|
21
|
+
def embed(self, text: str) -> list[float]:
|
|
22
|
+
"""Embed a single string and return a plain list[float]."""
|
|
23
|
+
result = next(iter(self._model.embed([text]))) # type: ignore[attr-defined]
|
|
24
|
+
return result.tolist()
|
|
25
|
+
|
|
26
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
27
|
+
"""Embed multiple strings."""
|
|
28
|
+
return [v.tolist() for v in self._model.embed(texts)] # type: ignore[attr-defined]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def dimensions(self) -> int:
|
|
32
|
+
"""Return the vector dimensionality by embedding a dummy string."""
|
|
33
|
+
return len(self.embed("probe"))
|
qql/exceptions.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class QQLError(Exception):
|
|
2
|
+
"""Base for all QQL errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class QQLSyntaxError(QQLError):
|
|
6
|
+
"""Raised by Lexer or Parser for malformed input."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, message: str, pos: int | None = None) -> None:
|
|
9
|
+
self.pos = pos
|
|
10
|
+
suffix = f" (at position {pos})" if pos is not None else ""
|
|
11
|
+
super().__init__(f"{message}{suffix}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QQLRuntimeError(QQLError):
|
|
15
|
+
"""Raised by Executor when the operation fails."""
|
qql/executor.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qdrant_client import QdrantClient
|
|
8
|
+
from qdrant_client.http.exceptions import UnexpectedResponse
|
|
9
|
+
from qdrant_client.models import Distance, PointStruct, VectorParams
|
|
10
|
+
|
|
11
|
+
from .ast_nodes import (
|
|
12
|
+
ASTNode,
|
|
13
|
+
CreateCollectionStmt,
|
|
14
|
+
DeleteStmt,
|
|
15
|
+
DropCollectionStmt,
|
|
16
|
+
InsertStmt,
|
|
17
|
+
SearchStmt,
|
|
18
|
+
ShowCollectionsStmt,
|
|
19
|
+
)
|
|
20
|
+
from .config import QQLConfig
|
|
21
|
+
from .embedder import Embedder
|
|
22
|
+
from .exceptions import QQLRuntimeError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ExecutionResult:
|
|
27
|
+
success: bool
|
|
28
|
+
message: str
|
|
29
|
+
data: Any = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Executor:
|
|
33
|
+
def __init__(self, client: QdrantClient, config: QQLConfig) -> None:
|
|
34
|
+
self._client = client
|
|
35
|
+
self._config = config
|
|
36
|
+
|
|
37
|
+
def execute(self, node: ASTNode) -> ExecutionResult:
|
|
38
|
+
if isinstance(node, InsertStmt):
|
|
39
|
+
return self._execute_insert(node)
|
|
40
|
+
if isinstance(node, CreateCollectionStmt):
|
|
41
|
+
return self._execute_create(node)
|
|
42
|
+
if isinstance(node, DropCollectionStmt):
|
|
43
|
+
return self._execute_drop(node)
|
|
44
|
+
if isinstance(node, ShowCollectionsStmt):
|
|
45
|
+
return self._execute_show(node)
|
|
46
|
+
if isinstance(node, SearchStmt):
|
|
47
|
+
return self._execute_search(node)
|
|
48
|
+
if isinstance(node, DeleteStmt):
|
|
49
|
+
return self._execute_delete(node)
|
|
50
|
+
raise QQLRuntimeError(f"Unknown AST node type: {type(node)}")
|
|
51
|
+
|
|
52
|
+
# ── Statement executors ───────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
def _execute_insert(self, node: InsertStmt) -> ExecutionResult:
|
|
55
|
+
if "text" not in node.values:
|
|
56
|
+
raise QQLRuntimeError("INSERT requires a 'text' field in VALUES")
|
|
57
|
+
|
|
58
|
+
model_name = node.model or self._config.default_model
|
|
59
|
+
embedder = Embedder(model_name)
|
|
60
|
+
vector = embedder.embed(node.values["text"])
|
|
61
|
+
|
|
62
|
+
self._ensure_collection(node.collection, len(vector))
|
|
63
|
+
|
|
64
|
+
point_id = str(uuid.uuid4())
|
|
65
|
+
payload = dict(node.values)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
self._client.upsert(
|
|
69
|
+
collection_name=node.collection,
|
|
70
|
+
points=[PointStruct(id=point_id, vector=vector, payload=payload)],
|
|
71
|
+
)
|
|
72
|
+
except UnexpectedResponse as e:
|
|
73
|
+
raise QQLRuntimeError(f"Qdrant error during INSERT: {e}") from e
|
|
74
|
+
|
|
75
|
+
return ExecutionResult(
|
|
76
|
+
success=True,
|
|
77
|
+
message=f"Inserted 1 point [{point_id}]",
|
|
78
|
+
data={"id": point_id, "collection": node.collection},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _execute_create(self, node: CreateCollectionStmt) -> ExecutionResult:
|
|
82
|
+
if self._client.collection_exists(node.collection):
|
|
83
|
+
return ExecutionResult(
|
|
84
|
+
success=True,
|
|
85
|
+
message=f"Collection '{node.collection}' already exists",
|
|
86
|
+
)
|
|
87
|
+
# Create with default model dimensions
|
|
88
|
+
embedder = Embedder(self._config.default_model)
|
|
89
|
+
dims = embedder.dimensions
|
|
90
|
+
self._client.create_collection(
|
|
91
|
+
collection_name=node.collection,
|
|
92
|
+
vectors_config=VectorParams(size=dims, distance=Distance.COSINE),
|
|
93
|
+
)
|
|
94
|
+
return ExecutionResult(
|
|
95
|
+
success=True,
|
|
96
|
+
message=f"Collection '{node.collection}' created ({dims}-dimensional vectors, cosine distance)",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _execute_drop(self, node: DropCollectionStmt) -> ExecutionResult:
|
|
100
|
+
if not self._client.collection_exists(node.collection):
|
|
101
|
+
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
|
|
102
|
+
self._client.delete_collection(node.collection)
|
|
103
|
+
return ExecutionResult(
|
|
104
|
+
success=True,
|
|
105
|
+
message=f"Collection '{node.collection}' dropped",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _execute_show(self, node: ShowCollectionsStmt) -> ExecutionResult:
|
|
109
|
+
response = self._client.get_collections()
|
|
110
|
+
names = [c.name for c in response.collections]
|
|
111
|
+
return ExecutionResult(
|
|
112
|
+
success=True,
|
|
113
|
+
message=f"{len(names)} collection(s) found",
|
|
114
|
+
data=names,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _execute_search(self, node: SearchStmt) -> ExecutionResult:
|
|
118
|
+
if not self._client.collection_exists(node.collection):
|
|
119
|
+
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
|
|
120
|
+
|
|
121
|
+
model_name = node.model or self._config.default_model
|
|
122
|
+
embedder = Embedder(model_name)
|
|
123
|
+
vector = embedder.embed(node.query_text)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
response = self._client.query_points(
|
|
127
|
+
collection_name=node.collection,
|
|
128
|
+
query=vector,
|
|
129
|
+
limit=node.limit,
|
|
130
|
+
)
|
|
131
|
+
except UnexpectedResponse as e:
|
|
132
|
+
raise QQLRuntimeError(f"Qdrant error during SEARCH: {e}") from e
|
|
133
|
+
|
|
134
|
+
results = [
|
|
135
|
+
{"id": str(h.id), "score": round(h.score, 4), "payload": h.payload}
|
|
136
|
+
for h in response.points
|
|
137
|
+
]
|
|
138
|
+
return ExecutionResult(
|
|
139
|
+
success=True,
|
|
140
|
+
message=f"Found {len(results)} result(s)",
|
|
141
|
+
data=results,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _execute_delete(self, node: DeleteStmt) -> ExecutionResult:
|
|
145
|
+
if not self._client.collection_exists(node.collection):
|
|
146
|
+
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
|
|
147
|
+
|
|
148
|
+
from qdrant_client.models import PointIdsList
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
self._client.delete(
|
|
152
|
+
collection_name=node.collection,
|
|
153
|
+
points_selector=PointIdsList(points=[node.point_id]),
|
|
154
|
+
)
|
|
155
|
+
except UnexpectedResponse as e:
|
|
156
|
+
raise QQLRuntimeError(f"Qdrant error during DELETE: {e}") from e
|
|
157
|
+
|
|
158
|
+
return ExecutionResult(
|
|
159
|
+
success=True,
|
|
160
|
+
message=f"Deleted point '{node.point_id}' from '{node.collection}'",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# ── Helpers ───────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _ensure_collection(self, name: str, vector_size: int) -> None:
|
|
166
|
+
"""Create the collection if it doesn't exist. Raises on dimension mismatch."""
|
|
167
|
+
if self._client.collection_exists(name):
|
|
168
|
+
info = self._client.get_collection(name)
|
|
169
|
+
existing_size = info.config.params.vectors.size # type: ignore[union-attr]
|
|
170
|
+
if existing_size != vector_size:
|
|
171
|
+
raise QQLRuntimeError(
|
|
172
|
+
f"Vector dimension mismatch: collection '{name}' expects "
|
|
173
|
+
f"{existing_size} dims, but model produces {vector_size} dims. "
|
|
174
|
+
f"Specify a compatible model with USING MODEL '<model>'."
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
self._client.create_collection(
|
|
178
|
+
collection_name=name,
|
|
179
|
+
vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE),
|
|
180
|
+
)
|