pgsemantic 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.
- pgsemantic/__init__.py +3 -0
- pgsemantic/__main__.py +4 -0
- pgsemantic/cli.py +52 -0
- pgsemantic/commands/__init__.py +0 -0
- pgsemantic/commands/apply.py +491 -0
- pgsemantic/commands/index.py +246 -0
- pgsemantic/commands/inspect.py +144 -0
- pgsemantic/commands/integrate.py +142 -0
- pgsemantic/commands/search.py +130 -0
- pgsemantic/commands/serve.py +86 -0
- pgsemantic/commands/status.py +131 -0
- pgsemantic/commands/worker.py +80 -0
- pgsemantic/config.py +167 -0
- pgsemantic/db/__init__.py +0 -0
- pgsemantic/db/client.py +108 -0
- pgsemantic/db/introspect.py +369 -0
- pgsemantic/db/queue.py +210 -0
- pgsemantic/db/vectors.py +812 -0
- pgsemantic/embeddings/__init__.py +41 -0
- pgsemantic/embeddings/base.py +32 -0
- pgsemantic/embeddings/local.py +92 -0
- pgsemantic/embeddings/ollama_provider.py +72 -0
- pgsemantic/embeddings/openai_provider.py +51 -0
- pgsemantic/exceptions.py +54 -0
- pgsemantic/mcp_server/__init__.py +0 -0
- pgsemantic/mcp_server/server.py +266 -0
- pgsemantic/worker/__init__.py +0 -0
- pgsemantic/worker/daemon.py +228 -0
- pgsemantic-0.1.0.dist-info/METADATA +615 -0
- pgsemantic-0.1.0.dist-info/RECORD +34 -0
- pgsemantic-0.1.0.dist-info/WHEEL +5 -0
- pgsemantic-0.1.0.dist-info/entry_points.txt +2 -0
- pgsemantic-0.1.0.dist-info/licenses/LICENSE +21 -0
- pgsemantic-0.1.0.dist-info/top_level.txt +1 -0
pgsemantic/__init__.py
ADDED
pgsemantic/__main__.py
ADDED
pgsemantic/cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""CLI root — registers all subcommands for pgsemantic."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from pgsemantic import __version__
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="pgsemantic",
|
|
10
|
+
help="Zero-config semantic search bootstrap for any PostgreSQL database.",
|
|
11
|
+
no_args_is_help=True,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def version_callback(value: bool) -> None:
|
|
16
|
+
if value:
|
|
17
|
+
print(f"pgsemantic {__version__}") # noqa: T201
|
|
18
|
+
raise typer.Exit()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def main(
|
|
23
|
+
version: bool = typer.Option(
|
|
24
|
+
False,
|
|
25
|
+
"--version",
|
|
26
|
+
"-v",
|
|
27
|
+
help="Show version and exit.",
|
|
28
|
+
callback=version_callback,
|
|
29
|
+
is_eager=True,
|
|
30
|
+
),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""pgsemantic: Point it at your Postgres database. Get semantic search in 60 seconds."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Register subcommands
|
|
36
|
+
from pgsemantic.commands.apply import apply_command # noqa: E402
|
|
37
|
+
from pgsemantic.commands.index import index_command # noqa: E402
|
|
38
|
+
from pgsemantic.commands.inspect import inspect_command # noqa: E402
|
|
39
|
+
from pgsemantic.commands.integrate import integrate_command # noqa: E402
|
|
40
|
+
from pgsemantic.commands.search import search_command # noqa: E402
|
|
41
|
+
from pgsemantic.commands.serve import serve_command # noqa: E402
|
|
42
|
+
from pgsemantic.commands.status import status_command # noqa: E402
|
|
43
|
+
from pgsemantic.commands.worker import worker_command # noqa: E402
|
|
44
|
+
|
|
45
|
+
app.command(name="inspect")(inspect_command)
|
|
46
|
+
app.command(name="apply")(apply_command)
|
|
47
|
+
app.command(name="index")(index_command)
|
|
48
|
+
app.command(name="search")(search_command)
|
|
49
|
+
app.command(name="worker")(worker_command)
|
|
50
|
+
app.command(name="serve")(serve_command)
|
|
51
|
+
app.command(name="status")(status_command)
|
|
52
|
+
app.command(name="integrate")(integrate_command)
|
|
File without changes
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""pgsemantic apply — set up semantic search on a table.
|
|
2
|
+
|
|
3
|
+
Performs a 10-step setup: extension check, column creation, HNSW index,
|
|
4
|
+
queue table, trigger function, trigger installation, config save.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
from pgsemantic.config import (
|
|
16
|
+
DEFAULT_LOCAL_DIMENSIONS,
|
|
17
|
+
DEFAULT_LOCAL_MODEL,
|
|
18
|
+
HNSW_EF_CONSTRUCTION,
|
|
19
|
+
HNSW_M,
|
|
20
|
+
OPENAI_DIMENSIONS,
|
|
21
|
+
OPENAI_MODEL,
|
|
22
|
+
SHADOW_TABLE_PREFIX,
|
|
23
|
+
ProjectConfig,
|
|
24
|
+
TableConfig,
|
|
25
|
+
load_project_config,
|
|
26
|
+
load_settings,
|
|
27
|
+
save_project_config,
|
|
28
|
+
)
|
|
29
|
+
from pgsemantic.db.client import ensure_pgvector_extension, get_connection
|
|
30
|
+
from pgsemantic.db.introspect import column_exists, get_primary_key_columns
|
|
31
|
+
from pgsemantic.db.queue import create_queue_table
|
|
32
|
+
from pgsemantic.db.vectors import create_shadow_table
|
|
33
|
+
from pgsemantic.exceptions import ExtensionNotFoundError
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
# -- SQL Constants ----------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
# Add a vector column to an existing table.
|
|
40
|
+
SQL_ADD_VECTOR_COLUMN = """
|
|
41
|
+
ALTER TABLE "{schema}"."{table}"
|
|
42
|
+
ADD COLUMN "embedding" vector({dimensions});
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Create an HNSW index for cosine similarity search.
|
|
46
|
+
# Must be run with conn.autocommit = True because CREATE INDEX CONCURRENTLY
|
|
47
|
+
# cannot execute inside a transaction block.
|
|
48
|
+
SQL_CREATE_HNSW_INDEX = """
|
|
49
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_{table}_embedding_hnsw"
|
|
50
|
+
ON "{schema}"."{table}"
|
|
51
|
+
USING hnsw ("embedding" vector_cosine_ops)
|
|
52
|
+
WITH (m = {m}, ef_construction = {ef_construction});
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def _build_trigger_function_sql(table: str, pk_columns: list[str]) -> str:
|
|
56
|
+
"""Build a per-table trigger function with the correct PK columns.
|
|
57
|
+
|
|
58
|
+
Each table gets its own function (pgvector_setup_{table}_fn) so that
|
|
59
|
+
tables with different primary keys don't conflict.
|
|
60
|
+
|
|
61
|
+
For single-column PKs: row_id = NEW.pk_col::TEXT
|
|
62
|
+
For composite PKs: row_id = NEW.col1::TEXT || ',' || NEW.col2::TEXT
|
|
63
|
+
"""
|
|
64
|
+
if len(pk_columns) == 1:
|
|
65
|
+
new_pk_expr = f"NEW.{pk_columns[0]}::TEXT"
|
|
66
|
+
old_pk_expr = f"OLD.{pk_columns[0]}::TEXT"
|
|
67
|
+
else:
|
|
68
|
+
new_pk_expr = " || ',' || ".join(f"NEW.{col}::TEXT" for col in pk_columns)
|
|
69
|
+
old_pk_expr = " || ',' || ".join(f"OLD.{col}::TEXT" for col in pk_columns)
|
|
70
|
+
|
|
71
|
+
fn_name = f"pgvector_setup_{table}_fn"
|
|
72
|
+
|
|
73
|
+
return f"""
|
|
74
|
+
CREATE OR REPLACE FUNCTION {fn_name}()
|
|
75
|
+
RETURNS TRIGGER AS $$
|
|
76
|
+
DECLARE
|
|
77
|
+
v_row_id TEXT;
|
|
78
|
+
BEGIN
|
|
79
|
+
IF TG_OP = 'DELETE' THEN
|
|
80
|
+
v_row_id := {old_pk_expr};
|
|
81
|
+
INSERT INTO pgvector_setup_queue (table_name, row_id, column_name, operation)
|
|
82
|
+
VALUES (TG_TABLE_NAME, v_row_id, TG_ARGV[0], 'DELETE')
|
|
83
|
+
ON CONFLICT (table_name, row_id, column_name)
|
|
84
|
+
DO UPDATE SET status = 'pending', operation = 'DELETE', retries = 0, error_msg = NULL, updated_at = NOW();
|
|
85
|
+
RETURN OLD;
|
|
86
|
+
ELSE
|
|
87
|
+
v_row_id := {new_pk_expr};
|
|
88
|
+
INSERT INTO pgvector_setup_queue (table_name, row_id, column_name, operation)
|
|
89
|
+
VALUES (TG_TABLE_NAME, v_row_id, TG_ARGV[0], TG_OP)
|
|
90
|
+
ON CONFLICT (table_name, row_id, column_name)
|
|
91
|
+
DO UPDATE SET status = 'pending', operation = TG_OP, retries = 0, error_msg = NULL, updated_at = NOW();
|
|
92
|
+
RETURN NEW;
|
|
93
|
+
END IF;
|
|
94
|
+
END;
|
|
95
|
+
$$ LANGUAGE plpgsql;
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
# Install a per-table trigger that fires on INSERT, UPDATE, or DELETE
|
|
99
|
+
# and enqueues the affected row_id into the queue table.
|
|
100
|
+
SQL_CREATE_TRIGGER = """
|
|
101
|
+
CREATE OR REPLACE TRIGGER pgvector_setup_{table}_trigger
|
|
102
|
+
AFTER INSERT OR UPDATE OF "{column}" OR DELETE
|
|
103
|
+
ON "{schema}"."{table}"
|
|
104
|
+
FOR EACH ROW
|
|
105
|
+
EXECUTE FUNCTION pgvector_setup_{table}_fn('{column}');
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# Trigger for multi-column mode: fires on UPDATE of any watched column.
|
|
109
|
+
SQL_CREATE_TRIGGER_MULTI = """
|
|
110
|
+
CREATE OR REPLACE TRIGGER pgvector_setup_{table}_trigger
|
|
111
|
+
AFTER INSERT OR UPDATE OF {column_list} OR DELETE
|
|
112
|
+
ON "{schema}"."{table}"
|
|
113
|
+
FOR EACH ROW
|
|
114
|
+
EXECUTE FUNCTION pgvector_setup_{table}_fn('{column_arg}');
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# HNSW index on shadow table (CONCURRENTLY).
|
|
118
|
+
SQL_CREATE_SHADOW_HNSW_INDEX = """
|
|
119
|
+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_{shadow_table}_embedding_hnsw"
|
|
120
|
+
ON "{schema}"."{shadow_table}"
|
|
121
|
+
USING hnsw ("embedding" vector_cosine_ops)
|
|
122
|
+
WITH (m = {m}, ef_construction = {ef_construction});
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def apply_command(
|
|
127
|
+
table: str = typer.Option(
|
|
128
|
+
...,
|
|
129
|
+
"--table",
|
|
130
|
+
"-t",
|
|
131
|
+
help="Table to enable semantic search on.",
|
|
132
|
+
),
|
|
133
|
+
column: str = typer.Option(
|
|
134
|
+
"",
|
|
135
|
+
"--column",
|
|
136
|
+
"-c",
|
|
137
|
+
help="Text column to embed (mutually exclusive with --columns).",
|
|
138
|
+
),
|
|
139
|
+
columns: str = typer.Option(
|
|
140
|
+
"",
|
|
141
|
+
"--columns",
|
|
142
|
+
help="Comma-separated columns to concatenate and embed (e.g. 'title,description').",
|
|
143
|
+
),
|
|
144
|
+
model: str = typer.Option(
|
|
145
|
+
"local",
|
|
146
|
+
"--model",
|
|
147
|
+
"-m",
|
|
148
|
+
help="Embedding provider: 'local' (384d, free) or 'openai' (1536d).",
|
|
149
|
+
),
|
|
150
|
+
external: bool = typer.Option(
|
|
151
|
+
False,
|
|
152
|
+
"--external",
|
|
153
|
+
help="Store embeddings in a separate shadow table (don't modify source table).",
|
|
154
|
+
),
|
|
155
|
+
db: str = typer.Option(
|
|
156
|
+
"",
|
|
157
|
+
"--db",
|
|
158
|
+
"-d",
|
|
159
|
+
help="Database URL (overrides DATABASE_URL env var).",
|
|
160
|
+
),
|
|
161
|
+
schema: str = typer.Option(
|
|
162
|
+
"public",
|
|
163
|
+
"--schema",
|
|
164
|
+
"-s",
|
|
165
|
+
help="Database schema.",
|
|
166
|
+
),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Set up semantic search on a table: extension, column, index, trigger."""
|
|
169
|
+
settings = load_settings()
|
|
170
|
+
database_url = db if db else settings.database_url
|
|
171
|
+
|
|
172
|
+
if not database_url:
|
|
173
|
+
console.print(Panel(
|
|
174
|
+
"[red]No database URL provided.[/red]\n\n"
|
|
175
|
+
"Set DATABASE_URL in your .env file or pass --db.",
|
|
176
|
+
title="Missing Database URL",
|
|
177
|
+
border_style="red",
|
|
178
|
+
))
|
|
179
|
+
raise typer.Exit(code=1)
|
|
180
|
+
|
|
181
|
+
# Validate --column / --columns (mutually exclusive, one required)
|
|
182
|
+
if column and columns:
|
|
183
|
+
console.print(Panel(
|
|
184
|
+
"[red]Cannot use both --column and --columns.[/red]\n\n"
|
|
185
|
+
"Use --column for a single column or --columns for multiple.",
|
|
186
|
+
title="Invalid Options",
|
|
187
|
+
border_style="red",
|
|
188
|
+
))
|
|
189
|
+
raise typer.Exit(code=1)
|
|
190
|
+
|
|
191
|
+
if not column and not columns:
|
|
192
|
+
console.print(Panel(
|
|
193
|
+
"[red]Either --column or --columns is required.[/red]\n\n"
|
|
194
|
+
"Example: --column description\n"
|
|
195
|
+
" or: --columns title,description",
|
|
196
|
+
title="Missing Column",
|
|
197
|
+
border_style="red",
|
|
198
|
+
))
|
|
199
|
+
raise typer.Exit(code=1)
|
|
200
|
+
|
|
201
|
+
# Parse column list
|
|
202
|
+
source_columns: list[str] = (
|
|
203
|
+
[c.strip() for c in columns.split(",") if c.strip()]
|
|
204
|
+
if columns
|
|
205
|
+
else [column]
|
|
206
|
+
)
|
|
207
|
+
# Use the first column as the "primary" column for backward compat
|
|
208
|
+
primary_column = source_columns[0]
|
|
209
|
+
is_multi = len(source_columns) > 1
|
|
210
|
+
|
|
211
|
+
# Resolve storage mode
|
|
212
|
+
storage_mode = "external" if external else "inline"
|
|
213
|
+
shadow_table_name = f"{SHADOW_TABLE_PREFIX}{table}" if external else None
|
|
214
|
+
|
|
215
|
+
# Resolve model dimensions
|
|
216
|
+
if model == "openai":
|
|
217
|
+
dimensions = OPENAI_DIMENSIONS
|
|
218
|
+
model_name = OPENAI_MODEL
|
|
219
|
+
else:
|
|
220
|
+
dimensions = DEFAULT_LOCAL_DIMENSIONS
|
|
221
|
+
model_name = DEFAULT_LOCAL_MODEL
|
|
222
|
+
|
|
223
|
+
col_display = ", ".join(source_columns)
|
|
224
|
+
console.print()
|
|
225
|
+
console.print(Panel(
|
|
226
|
+
f"Setting up semantic search on [cyan]{schema}.{table}[/cyan]\n"
|
|
227
|
+
f"Column(s): [cyan]{col_display}[/cyan]\n"
|
|
228
|
+
f"Model: [green]{model_name}[/green] ({dimensions} dimensions)\n"
|
|
229
|
+
f"Storage: [green]{storage_mode}[/green]"
|
|
230
|
+
+ (f" → {shadow_table_name}" if shadow_table_name else ""),
|
|
231
|
+
title="pgsemantic apply",
|
|
232
|
+
border_style="blue",
|
|
233
|
+
))
|
|
234
|
+
console.print()
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
with get_connection(database_url, register_vector_type=False) as conn:
|
|
238
|
+
# Step 1: Check/install pgvector extension
|
|
239
|
+
console.print(" [dim][1/9][/dim] Checking pgvector extension...", end=" ")
|
|
240
|
+
pgv_version = ensure_pgvector_extension(conn)
|
|
241
|
+
version_str = ".".join(str(v) for v in pgv_version)
|
|
242
|
+
console.print(f"[green]v{version_str}[/green]")
|
|
243
|
+
|
|
244
|
+
# Step 2: Check if source column(s) exist and detect primary key
|
|
245
|
+
console.print(" [dim][2/9][/dim] Verifying source column(s)...", end=" ")
|
|
246
|
+
for src_col in source_columns:
|
|
247
|
+
if not column_exists(conn, table, src_col, schema):
|
|
248
|
+
console.print("[red]not found[/red]")
|
|
249
|
+
console.print(Panel(
|
|
250
|
+
f"[red]Column '{src_col}' not found on table "
|
|
251
|
+
f"'{schema}.{table}'.[/red]\n\n"
|
|
252
|
+
"Check the table and column names and try again.",
|
|
253
|
+
title="Column Not Found",
|
|
254
|
+
border_style="red",
|
|
255
|
+
))
|
|
256
|
+
raise typer.Exit(code=1)
|
|
257
|
+
|
|
258
|
+
pk_columns = get_primary_key_columns(conn, table, schema)
|
|
259
|
+
if not pk_columns:
|
|
260
|
+
console.print("[red]no primary key[/red]")
|
|
261
|
+
console.print(Panel(
|
|
262
|
+
f"[red]Table '{schema}.{table}' has no primary key.[/red]\n\n"
|
|
263
|
+
"pgsemantic requires a primary key to track row changes.\n"
|
|
264
|
+
"Add one with: ALTER TABLE {table} ADD PRIMARY KEY (column);",
|
|
265
|
+
title="Primary Key Required",
|
|
266
|
+
border_style="red",
|
|
267
|
+
))
|
|
268
|
+
raise typer.Exit(code=1)
|
|
269
|
+
pk_display = ", ".join(pk_columns)
|
|
270
|
+
console.print(f"[green]found[/green] (PK: {pk_display})")
|
|
271
|
+
|
|
272
|
+
# Step 3: Embedding storage setup
|
|
273
|
+
if external:
|
|
274
|
+
# External mode: create shadow table
|
|
275
|
+
console.print(
|
|
276
|
+
" [dim][3/9][/dim] Creating shadow table...", end=" "
|
|
277
|
+
)
|
|
278
|
+
create_shadow_table(
|
|
279
|
+
conn,
|
|
280
|
+
shadow_table=shadow_table_name,
|
|
281
|
+
dimensions=dimensions,
|
|
282
|
+
schema=schema,
|
|
283
|
+
)
|
|
284
|
+
console.print("[green]done[/green]")
|
|
285
|
+
console.print(
|
|
286
|
+
f" [dim] Your table remains unchanged. Embeddings "
|
|
287
|
+
f"stored in {shadow_table_name}[/dim]"
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
# Inline mode: check/add embedding column on source table
|
|
291
|
+
console.print(" [dim][3/9][/dim] Checking embedding column...", end=" ")
|
|
292
|
+
if column_exists(conn, table, "embedding", schema):
|
|
293
|
+
console.print("[yellow]already exists[/yellow]")
|
|
294
|
+
console.print(
|
|
295
|
+
" [dim] Skipping ALTER TABLE — embedding column "
|
|
296
|
+
"already present.[/dim]"
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
console.print("[dim]needs creation[/dim]")
|
|
300
|
+
|
|
301
|
+
alter_sql = (
|
|
302
|
+
f'ALTER TABLE "{schema}"."{table}" '
|
|
303
|
+
f'ADD COLUMN "embedding" vector({dimensions});'
|
|
304
|
+
)
|
|
305
|
+
console.print()
|
|
306
|
+
console.print(Panel(
|
|
307
|
+
f"[cyan]{alter_sql}[/cyan]",
|
|
308
|
+
title="SQL Preview — ALTER TABLE",
|
|
309
|
+
border_style="yellow",
|
|
310
|
+
))
|
|
311
|
+
|
|
312
|
+
if not typer.confirm(
|
|
313
|
+
"This will add an embedding column to your table. Continue?"
|
|
314
|
+
):
|
|
315
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
316
|
+
raise typer.Exit(code=0)
|
|
317
|
+
|
|
318
|
+
console.print(
|
|
319
|
+
" [dim][4/9][/dim] Adding embedding column...",
|
|
320
|
+
end=" ",
|
|
321
|
+
)
|
|
322
|
+
sql = SQL_ADD_VECTOR_COLUMN.format(
|
|
323
|
+
schema=schema, table=table, dimensions=dimensions
|
|
324
|
+
)
|
|
325
|
+
conn.execute(sql)
|
|
326
|
+
conn.commit()
|
|
327
|
+
console.print("[green]done[/green]")
|
|
328
|
+
|
|
329
|
+
# Step 5: Create HNSW index (requires autocommit)
|
|
330
|
+
console.print(
|
|
331
|
+
" [dim][5/9][/dim] Creating HNSW index (CONCURRENTLY)...",
|
|
332
|
+
end=" ",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
|
|
336
|
+
with psycopg.connect(database_url, autocommit=True) as autocommit_conn:
|
|
337
|
+
if external:
|
|
338
|
+
index_sql = SQL_CREATE_SHADOW_HNSW_INDEX.format(
|
|
339
|
+
schema=schema,
|
|
340
|
+
shadow_table=shadow_table_name,
|
|
341
|
+
m=HNSW_M,
|
|
342
|
+
ef_construction=HNSW_EF_CONSTRUCTION,
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
index_sql = SQL_CREATE_HNSW_INDEX.format(
|
|
346
|
+
schema=schema,
|
|
347
|
+
table=table,
|
|
348
|
+
m=HNSW_M,
|
|
349
|
+
ef_construction=HNSW_EF_CONSTRUCTION,
|
|
350
|
+
)
|
|
351
|
+
autocommit_conn.execute(index_sql)
|
|
352
|
+
console.print("[green]done[/green]")
|
|
353
|
+
|
|
354
|
+
# Reopen the regular connection for the remaining steps
|
|
355
|
+
with get_connection(database_url) as conn:
|
|
356
|
+
# Step 6: Create queue table
|
|
357
|
+
console.print(
|
|
358
|
+
" [dim][6/9][/dim] Creating queue table...", end=" "
|
|
359
|
+
)
|
|
360
|
+
create_queue_table(conn)
|
|
361
|
+
console.print("[green]done[/green]")
|
|
362
|
+
|
|
363
|
+
# Step 7: Install trigger function (PK-aware)
|
|
364
|
+
console.print(
|
|
365
|
+
" [dim][7/9][/dim] Installing trigger function...", end=" "
|
|
366
|
+
)
|
|
367
|
+
trigger_fn_sql = _build_trigger_function_sql(table, pk_columns)
|
|
368
|
+
conn.execute(trigger_fn_sql)
|
|
369
|
+
conn.commit()
|
|
370
|
+
console.print("[green]done[/green]")
|
|
371
|
+
|
|
372
|
+
# Step 8: Install trigger on table
|
|
373
|
+
console.print(
|
|
374
|
+
" [dim][8/9][/dim] Installing trigger on table...", end=" "
|
|
375
|
+
)
|
|
376
|
+
if is_multi:
|
|
377
|
+
# Multi-column trigger: fires on UPDATE OF col1, col2, ...
|
|
378
|
+
column_list = ", ".join(f'"{c}"' for c in source_columns)
|
|
379
|
+
column_arg = "+".join(source_columns)
|
|
380
|
+
trigger_sql = SQL_CREATE_TRIGGER_MULTI.format(
|
|
381
|
+
schema=schema,
|
|
382
|
+
table=table,
|
|
383
|
+
column_list=column_list,
|
|
384
|
+
column_arg=column_arg,
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
trigger_sql = SQL_CREATE_TRIGGER.format(
|
|
388
|
+
schema=schema, table=table, column=primary_column
|
|
389
|
+
)
|
|
390
|
+
conn.execute(trigger_sql)
|
|
391
|
+
conn.commit()
|
|
392
|
+
console.print("[green]done[/green]")
|
|
393
|
+
|
|
394
|
+
# Step 9: Save config
|
|
395
|
+
console.print(" [dim][9/9][/dim] Saving config...", end=" ")
|
|
396
|
+
_save_config(
|
|
397
|
+
table=table,
|
|
398
|
+
schema=schema,
|
|
399
|
+
column=primary_column,
|
|
400
|
+
model=model,
|
|
401
|
+
model_name=model_name,
|
|
402
|
+
dimensions=dimensions,
|
|
403
|
+
primary_key=pk_columns,
|
|
404
|
+
columns=source_columns if is_multi else None,
|
|
405
|
+
storage_mode=storage_mode,
|
|
406
|
+
shadow_table=shadow_table_name,
|
|
407
|
+
)
|
|
408
|
+
console.print("[green]done[/green]")
|
|
409
|
+
|
|
410
|
+
# Summary
|
|
411
|
+
console.print()
|
|
412
|
+
storage_note = (
|
|
413
|
+
f"\nEmbeddings stored in: [cyan]{shadow_table_name}[/cyan]"
|
|
414
|
+
if external else ""
|
|
415
|
+
)
|
|
416
|
+
console.print(Panel(
|
|
417
|
+
f"[green]Semantic search is ready on "
|
|
418
|
+
f"{schema}.{table} ({col_display})[/green]"
|
|
419
|
+
f"{storage_note}\n\n"
|
|
420
|
+
"Next steps:\n"
|
|
421
|
+
f" 1. Embed existing rows: "
|
|
422
|
+
f"[cyan]pgsemantic index --table {table}[/cyan]\n"
|
|
423
|
+
f" 2. Start live sync: "
|
|
424
|
+
f"[cyan]pgsemantic worker[/cyan]\n"
|
|
425
|
+
f" 3. Start MCP server: "
|
|
426
|
+
f"[cyan]pgsemantic serve[/cyan]",
|
|
427
|
+
title="Setup Complete",
|
|
428
|
+
border_style="green",
|
|
429
|
+
))
|
|
430
|
+
console.print()
|
|
431
|
+
|
|
432
|
+
except typer.Exit:
|
|
433
|
+
raise
|
|
434
|
+
except ExtensionNotFoundError as e:
|
|
435
|
+
console.print()
|
|
436
|
+
console.print(Panel(
|
|
437
|
+
f"[red]pgvector extension not found.[/red]\n\n{e}",
|
|
438
|
+
title="Setup Required",
|
|
439
|
+
border_style="red",
|
|
440
|
+
))
|
|
441
|
+
raise typer.Exit(code=1) from None
|
|
442
|
+
except Exception as e:
|
|
443
|
+
console.print()
|
|
444
|
+
console.print(Panel(
|
|
445
|
+
f"[red]Apply failed:[/red]\n\n{e}",
|
|
446
|
+
title="Error",
|
|
447
|
+
border_style="red",
|
|
448
|
+
))
|
|
449
|
+
raise typer.Exit(code=1) from None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _save_config(
|
|
453
|
+
table: str,
|
|
454
|
+
schema: str,
|
|
455
|
+
column: str,
|
|
456
|
+
model: str,
|
|
457
|
+
model_name: str,
|
|
458
|
+
dimensions: int,
|
|
459
|
+
primary_key: list[str] | None = None,
|
|
460
|
+
columns: list[str] | None = None,
|
|
461
|
+
storage_mode: str = "inline",
|
|
462
|
+
shadow_table: str | None = None,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Save or update the .pgsemantic.json project config."""
|
|
465
|
+
from pgsemantic import __version__
|
|
466
|
+
|
|
467
|
+
config = load_project_config()
|
|
468
|
+
if config is None:
|
|
469
|
+
config = ProjectConfig(version=__version__)
|
|
470
|
+
|
|
471
|
+
# Remove existing config for this table if present
|
|
472
|
+
config.tables = [tc for tc in config.tables if tc.table != table]
|
|
473
|
+
|
|
474
|
+
table_config = TableConfig(
|
|
475
|
+
table=table,
|
|
476
|
+
schema=schema,
|
|
477
|
+
column=column,
|
|
478
|
+
embedding_column="embedding",
|
|
479
|
+
model=model,
|
|
480
|
+
model_name=model_name,
|
|
481
|
+
dimensions=dimensions,
|
|
482
|
+
hnsw_m=HNSW_M,
|
|
483
|
+
hnsw_ef_construction=HNSW_EF_CONSTRUCTION,
|
|
484
|
+
applied_at=datetime.now(tz=timezone.utc).isoformat(),
|
|
485
|
+
primary_key=primary_key or ["id"],
|
|
486
|
+
columns=columns,
|
|
487
|
+
storage_mode=storage_mode,
|
|
488
|
+
shadow_table=shadow_table,
|
|
489
|
+
)
|
|
490
|
+
config.tables.append(table_config)
|
|
491
|
+
save_project_config(config)
|