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 ADDED
@@ -0,0 +1,3 @@
1
+ """pgsemantic: Zero-config semantic search bootstrap for PostgreSQL."""
2
+
3
+ __version__ = "0.1.0"
pgsemantic/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Allow running pgsemantic as: python3 -m pgsemantic"""
2
+ from pgsemantic.cli import app
3
+
4
+ app()
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)