feat(llm): pgvector provisioning — llm role/db, loopback port, migrate (refs #565)
This commit is contained in:
@@ -130,6 +130,10 @@ services:
|
||||
container_name: postgres
|
||||
networks:
|
||||
- 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:
|
||||
- POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD:-changeme}
|
||||
- POSTGRESQL_DATABASE=gitea
|
||||
|
||||
@@ -63,6 +63,13 @@ CREDENTIALS: tuple[Credential, ...] = (
|
||||
Format.PASSWORD,
|
||||
Provisioner.POSTGRES,
|
||||
),
|
||||
Credential(
|
||||
"LLM_DB_PASSWORD",
|
||||
"postgres/llm",
|
||||
Tier.SERVICE,
|
||||
Format.PASSWORD,
|
||||
Provisioner.POSTGRES,
|
||||
),
|
||||
# ── RustFS S3 ────────────────────────────────────────────
|
||||
Credential(
|
||||
"RUSTFS_ACCESS_KEY",
|
||||
@@ -159,10 +166,12 @@ POSTGRES_ROLES: dict[str, str] = {
|
||||
"git": "GITEA_DB_PASSWORD",
|
||||
"nessie": "NESSIE_DB_PASSWORD",
|
||||
"polaris": "POLARIS_DB_PASSWORD",
|
||||
"llm": "LLM_DB_PASSWORD",
|
||||
}
|
||||
|
||||
POSTGRES_DATABASES: dict[str, str] = {
|
||||
"git": "gitea",
|
||||
"nessie": "nessie",
|
||||
"polaris": "polaris",
|
||||
"llm": "llm",
|
||||
}
|
||||
|
||||
51
src/llm/migrate.py
Normal file
51
src/llm/migrate.py
Normal 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)))
|
||||
@@ -65,8 +65,8 @@ class TestBootstrapE2E:
|
||||
|
||||
def test_credential_count(self):
|
||||
skip_count = sum(1 for c in CREDENTIALS if c.provisioner is Provisioner.SKIP)
|
||||
assert len(CREDENTIALS) == 16
|
||||
assert len(CREDENTIALS) - skip_count == 16
|
||||
assert len(CREDENTIALS) == 17
|
||||
assert len(CREDENTIALS) - skip_count == 17
|
||||
|
||||
def test_derive_all_count(self):
|
||||
values = derive_all(ROOT, COMMIT)
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestManifestInvariants:
|
||||
assert cred.fmt == parent.fmt
|
||||
|
||||
def test_credential_count(self):
|
||||
assert len(CREDENTIALS) == 16
|
||||
assert len(CREDENTIALS) == 17
|
||||
|
||||
|
||||
class TestDeriveAll:
|
||||
|
||||
26
tests/llm/test_migrate.py
Normal file
26
tests/llm/test_migrate.py
Normal 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
|
||||
Reference in New Issue
Block a user