feat(llm): pgvector provisioning — llm role/db, loopback port, migrate (refs #565)

This commit is contained in:
kert
2026-07-17 08:36:35 -04:00
parent 6e5ffe7217
commit 51dc1d859a
6 changed files with 93 additions and 3 deletions

View File

@@ -130,6 +130,10 @@ services:
container_name: postgres container_name: postgres
networks: networks:
- storage - storage
ports:
# Loopback-only: host-side llm batch runs (indexer/tagger) need
# pgvector; everything else still uses the storage net.
- "127.0.0.1:5432:5432"
environment: environment:
- POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD:-changeme} - POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD:-changeme}
- POSTGRESQL_DATABASE=gitea - POSTGRESQL_DATABASE=gitea

View File

@@ -63,6 +63,13 @@ CREDENTIALS: tuple[Credential, ...] = (
Format.PASSWORD, Format.PASSWORD,
Provisioner.POSTGRES, Provisioner.POSTGRES,
), ),
Credential(
"LLM_DB_PASSWORD",
"postgres/llm",
Tier.SERVICE,
Format.PASSWORD,
Provisioner.POSTGRES,
),
# ── RustFS S3 ──────────────────────────────────────────── # ── RustFS S3 ────────────────────────────────────────────
Credential( Credential(
"RUSTFS_ACCESS_KEY", "RUSTFS_ACCESS_KEY",
@@ -159,10 +166,12 @@ POSTGRES_ROLES: dict[str, str] = {
"git": "GITEA_DB_PASSWORD", "git": "GITEA_DB_PASSWORD",
"nessie": "NESSIE_DB_PASSWORD", "nessie": "NESSIE_DB_PASSWORD",
"polaris": "POLARIS_DB_PASSWORD", "polaris": "POLARIS_DB_PASSWORD",
"llm": "LLM_DB_PASSWORD",
} }
POSTGRES_DATABASES: dict[str, str] = { POSTGRES_DATABASES: dict[str, str] = {
"git": "gitea", "git": "gitea",
"nessie": "nessie", "nessie": "nessie",
"polaris": "polaris", "polaris": "polaris",
"llm": "llm",
} }

51
src/llm/migrate.py Normal file
View File

@@ -0,0 +1,51 @@
"""Idempotent DDL for the llm database.
The ``vector`` extension and the ``llm`` role/database are provisioned by
the auth manifest (``api.auth.provision.bootstrap_postgres``) as superuser;
everything here runs as the unprivileged ``llm`` role.
``index_state`` is the resume ledger: one row per (item, collection) with
the content hash that was last embedded. The indexer skips items whose
hash is unchanged and re-embeds the rest.
"""
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.engine import Engine
INDEX_STATE_DDL = """
CREATE TABLE IF NOT EXISTS index_state (
item_key TEXT NOT NULL,
collection TEXT NOT NULL,
content_hash TEXT NOT NULL,
chunk_count INTEGER NOT NULL DEFAULT 0,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (item_key, collection)
)
"""
# langchain-postgres creates langchain_pg_embedding with an untyped vector
# column; HNSW needs a typed one. ALTER is a no-op when already typed.
_HNSW_DDL = [
"ALTER TABLE langchain_pg_embedding ALTER COLUMN embedding TYPE vector({dim})",
"CREATE INDEX IF NOT EXISTS ix_embedding_hnsw "
"ON langchain_pg_embedding USING hnsw (embedding vector_cosine_ops)",
]
def migrate(engine: Engine) -> None:
"""Create llm-owned tables. Safe to run on every start."""
with engine.begin() as conn:
conn.execute(text(INDEX_STATE_DDL))
def ensure_hnsw(engine: Engine, dim: int) -> None:
"""Type the embedding column and build the HNSW index.
Call after the PGVector store has created its tables (first
``add_embeddings``), not before.
"""
with engine.begin() as conn:
for ddl in _HNSW_DDL:
conn.execute(text(ddl.format(dim=dim)))

View File

@@ -65,8 +65,8 @@ class TestBootstrapE2E:
def test_credential_count(self): def test_credential_count(self):
skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP) skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP)
assert len(CREDENTIALS) == 16 assert len(CREDENTIALS) == 17
assert len(CREDENTIALS) - skip_count == 16 assert len(CREDENTIALS) - skip_count == 17
def test_derive_all_count(self): def test_derive_all_count(self):
values = derive_all(ROOT, COMMIT) values = derive_all(ROOT, COMMIT)

View File

@@ -50,7 +50,7 @@ class TestManifestInvariants:
assert cred.fmt == parent.fmt assert cred.fmt == parent.fmt
def test_credential_count(self): def test_credential_count(self):
assert len(CREDENTIALS) == 16 assert len(CREDENTIALS) == 17
class TestDeriveAll: class TestDeriveAll:

26
tests/llm/test_migrate.py Normal file
View File

@@ -0,0 +1,26 @@
"""llm.migrate — idempotent DDL for the llm database."""
from unittest.mock import MagicMock
from llm import migrate
class TestDdl:
def test_index_state_ddl_is_idempotent_sql(self):
assert "CREATE TABLE IF NOT EXISTS index_state" in migrate.INDEX_STATE_DDL
assert "PRIMARY KEY (item_key, collection)" in migrate.INDEX_STATE_DDL
def test_migrate_executes_ddl(self):
engine = MagicMock()
conn = engine.begin.return_value.__enter__.return_value
migrate.migrate(engine)
executed = " ".join(str(call.args[0]) for call in conn.execute.call_args_list)
assert "index_state" in executed
def test_ensure_hnsw_types_column_then_indexes(self):
engine = MagicMock()
conn = engine.begin.return_value.__enter__.return_value
migrate.ensure_hnsw(engine, 768)
executed = " ".join(str(call.args[0]) for call in conn.execute.call_args_list)
assert "vector(768)" in executed
assert "USING hnsw" in executed