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
|
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
|
||||||
|
|||||||
@@ -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
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):
|
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)
|
||||||
|
|||||||
@@ -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
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