From 4df745046133169748928c7024bd2a561981be01 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 24 May 2026 20:31:59 +0000 Subject: [PATCH] feat(customer1): add pgvector RAG knowledge base with embedding service - Custom PostgreSQL 15.2 image with pgvector 0.8.0 extension - Updated pg-cluster-hermes.yaml: custom image, sharedPreloadLibraries, maintenance_work_mem - RAG schema: documents table with vector(768) embeddings + HNSW index - RAG init job: ConfigMap + Job to apply schema to agent_memory db - Embedding service: FastAPI with nomic-embed-text-v1.5 - OpenAI-compatible /v1/embeddings endpoint - Deployment (1 replica, 2Gi-4Gi memory) + Service manifests - Updated kustomization.yaml to include new resources --- .../embedding-service/Dockerfile.embedding | 18 +++ apps/base/customer1/embedding-service/app.py | 150 ++++++++++++++++++ .../embedding-deployment.yaml | 55 +++++++ .../embedding-service/requirements.txt | 7 + apps/base/customer1/hermes-db/.dockerignore | 5 + .../hermes-db/Dockerfile.postgres-pgvector | 29 ++++ .../hermes-db/agent-memory-rag-db.yaml | 12 ++ .../customer1/hermes-db/kustomization.yaml | 3 + .../hermes-db/pg-cluster-hermes.yaml | 12 +- apps/base/customer1/hermes-db/rag-db-init.sql | 35 ++++ .../customer1/hermes-db/rag-init-job.yaml | 41 +++++ .../hermes-db/rag-init-sql-configmap.yaml | 45 ++++++ apps/base/customer1/hermes-db/rag-schema.sql | 52 ++++++ 13 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 apps/base/customer1/embedding-service/Dockerfile.embedding create mode 100644 apps/base/customer1/embedding-service/app.py create mode 100644 apps/base/customer1/embedding-service/embedding-deployment.yaml create mode 100644 apps/base/customer1/embedding-service/requirements.txt create mode 100644 apps/base/customer1/hermes-db/.dockerignore create mode 100644 apps/base/customer1/hermes-db/Dockerfile.postgres-pgvector create mode 100644 apps/base/customer1/hermes-db/agent-memory-rag-db.yaml create mode 100644 apps/base/customer1/hermes-db/rag-db-init.sql create mode 100644 apps/base/customer1/hermes-db/rag-init-job.yaml create mode 100644 apps/base/customer1/hermes-db/rag-init-sql-configmap.yaml create mode 100644 apps/base/customer1/hermes-db/rag-schema.sql diff --git a/apps/base/customer1/embedding-service/Dockerfile.embedding b/apps/base/customer1/embedding-service/Dockerfile.embedding new file mode 100644 index 0000000..19d0703 --- /dev/null +++ b/apps/base/customer1/embedding-service/Dockerfile.embedding @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install sentence-transformers and deps +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY app.py . + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +EXPOSE 8000 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/apps/base/customer1/embedding-service/app.py b/apps/base/customer1/embedding-service/app.py new file mode 100644 index 0000000..25c0202 --- /dev/null +++ b/apps/base/customer1/embedding-service/app.py @@ -0,0 +1,150 @@ +""" +Lightweight embedding service wrapping nomic-embed-text-v1.5 +OpenAI-compatible /v1/embeddings endpoint. +""" + +import os +import time +import uuid +from contextlib import asynccontextmanager +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + + +# Model globals (loaded at startup) +_model = None +_tokenizer = None +_dimensions = 768 # nomic-embed-text-v1.5 output dimensions +_model_name = "nomic-embed-text-v1.5" + + +def load_model(): + """Load the embedding model at startup.""" + global _model, _tokenizer + from sentence_transformers import SentenceTransformer + + model_path = os.getenv("MODEL_PATH", _model_name) + print(f"Loading model: {model_path}") + _model = SentenceTransformer(model_path, device="cpu") + _model.max_seq_length = 8192 # nomic supports long contexts + print(f"Model loaded. Dimensions: {_model.get_sentence_embedding_dimension()}") + _dimensions = _model.get_sentence_embedding_dimension() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup: load model.""" + load_model() + yield + # Shutdown: no cleanup needed for CPU model + + +app = FastAPI( + title="Embedding Service", + description="OpenAI-compatible embedding service using nomic-embed-text-v1.5", + version="1.0.0", + lifespan=lifespan, +) + + +# --- Request/Response Models --- + +class EmbeddingInput(BaseModel): + input: str | list[str] = Field(..., description="Text to embed. Can be a string or list of strings.") + model: str = Field(default=_model_name, description="Model name. Only nomic-embed-text-v1.5 is supported.") + encoding_format: str = Field(default="float", description="Output format. Only 'float' is supported.") + + +class EmbeddingObject(BaseModel): + object: str = "embedding" + embedding: list[float] + index: int + + +class UsageInfo(BaseModel): + prompt_tokens: int + total_tokens: int + + +class EmbeddingResponse(BaseModel): + object: str = "list" + data: list[EmbeddingObject] + model: str + usage: UsageInfo + + +# --- Endpoints --- + +@app.post("/v1/embeddings") +def create_embeddings(req: EmbeddingInput) -> EmbeddingResponse: + """Create embeddings for input text(s). OpenAI-compatible.""" + # Normalize input to list + if isinstance(req.input, str): + texts = [req.input] + else: + texts = req.input + + if not texts: + raise HTTPException(status_code=400, detail="Input must not be empty.") + + if len(texts) > 2048: + raise HTTPException(status_code=400, detail="Input must have at most 2048 elements.") + + # Generate embeddings + start = time.time() + embeddings = _model.encode( + texts, + normalize_embeddings=True, # cosine similarity ready + show_progress_bar=False, + ).tolist() + + elapsed = time.time() - start + + # Build response + data = [] + total_tokens = 0 + for i, (text, emb) in enumerate(zip(texts, embeddings)): + tokens = len(text.split()) # rough token count + total_tokens += tokens + data.append(EmbeddingObject( + object="embedding", + embedding=emb, + index=i, + )) + + return EmbeddingResponse( + object="list", + data=data, + model=req.model, + usage=UsageInfo( + prompt_tokens=total_tokens, + total_tokens=total_tokens, + ), + ) + + +@app.get("/v1/models") +def list_models(): + """List available models. OpenAI-compatible.""" + return { + "object": "list", + "data": [ + { + "id": _model_name, + "object": "model", + "created": int(time.time()), + "owned_by": "self", + } + ], + } + + +@app.get("/health") +def health(): + """Health check.""" + return { + "status": "healthy", + "model": _model_name, + "dimensions": _dimensions, + "ready": _model is not None, + } diff --git a/apps/base/customer1/embedding-service/embedding-deployment.yaml b/apps/base/customer1/embedding-service/embedding-deployment.yaml new file mode 100644 index 0000000..3269be3 --- /dev/null +++ b/apps/base/customer1/embedding-service/embedding-deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: embedding-service + namespace: customer1 + labels: + app: embedding-service +spec: + replicas: 1 + selector: + matchLabels: + app: embedding-service + template: + metadata: + labels: + app: embedding-service + spec: + containers: + - name: embedding-service + image: gcr.io/devops-lab-cluster/embedding-service:1.0.0 + ports: + - containerPort: 8000 + name: http + protocol: TCP + env: + - name: MODEL_NAME + value: "nomic-embed-text-v1.5" + resources: + requests: + cpu: "500m" + memory: "2Gi" + limits: + cpu: "2000m" + memory: "4Gi" + startupProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 12 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 180 + periodSeconds: 30 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 3 diff --git a/apps/base/customer1/embedding-service/requirements.txt b/apps/base/customer1/embedding-service/requirements.txt new file mode 100644 index 0000000..55f2510 --- /dev/null +++ b/apps/base/customer1/embedding-service/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +sentence-transformers==3.3.0 +torch==2.5.1 +transformers==4.46.0 +numpy==2.1.0 +pydantic==2.10.0 diff --git a/apps/base/customer1/hermes-db/.dockerignore b/apps/base/customer1/hermes-db/.dockerignore new file mode 100644 index 0000000..7c03378 --- /dev/null +++ b/apps/base/customer1/hermes-db/.dockerignore @@ -0,0 +1,5 @@ +# .dockerignore for pgvector image builds +.git +*.md +*.yaml +*.yml diff --git a/apps/base/customer1/hermes-db/Dockerfile.postgres-pgvector b/apps/base/customer1/hermes-db/Dockerfile.postgres-pgvector new file mode 100644 index 0000000..cb76aba --- /dev/null +++ b/apps/base/customer1/hermes-db/Dockerfile.postgres-pgvector @@ -0,0 +1,29 @@ +# Custom PostgreSQL 15 image with pgvector extension +# Based on CNPG's official image - must preserve OS user, entrypoint, and PGDATA +FROM ghcr.io/cloudnative-pg/postgresql:15.2 + +# pgvector version (latest stable as of 2026-05) +ARG PGVECTOR_VERSION=0.8.0 + +# Install build dependencies for compiling pgvector from source +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + git \ + && \ + cd /tmp && \ + git clone --branch "v${PGVECTOR_VERSION}" --depth 1 https://github.com/pgvector/pgvector.git && \ + cd pgvector && \ + make && \ + make install && \ + apt-get remove -y build-essential git && \ + apt-get autoremove -y && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/pgvector + +# Verify pgvector is installed +RUN pg_config --version && \ + ls -la /usr/lib/postgresql/*/lib/vector.so + +# CNPG requirements: same OS user (1000), same entrypoint, same PGDATA +# The base image already sets these correctly, so no changes needed. diff --git a/apps/base/customer1/hermes-db/agent-memory-rag-db.yaml b/apps/base/customer1/hermes-db/agent-memory-rag-db.yaml new file mode 100644 index 0000000..90e3c11 --- /dev/null +++ b/apps/base/customer1/hermes-db/agent-memory-rag-db.yaml @@ -0,0 +1,12 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: agent-memory-rag + namespace: customer1 +spec: + cluster: + name: hermes-pgdb + name: agent_memory + owner: memory + sql: + - "@rag-db-init.sql" diff --git a/apps/base/customer1/hermes-db/kustomization.yaml b/apps/base/customer1/hermes-db/kustomization.yaml index 7047ec6..4a34648 100644 --- a/apps/base/customer1/hermes-db/kustomization.yaml +++ b/apps/base/customer1/hermes-db/kustomization.yaml @@ -8,6 +8,9 @@ resources: - memory-db-credentials.yaml - trading-data-db.yaml - agent-memory-db.yaml + - agent-memory-rag-db.yaml - hermes-scheduled-backup.yaml - kafka-broker.yaml - redis-cluster.yaml + - rag-init-sql-configmap.yaml + - rag-init-job.yaml diff --git a/apps/base/customer1/hermes-db/pg-cluster-hermes.yaml b/apps/base/customer1/hermes-db/pg-cluster-hermes.yaml index 158fd4e..27cb152 100644 --- a/apps/base/customer1/hermes-db/pg-cluster-hermes.yaml +++ b/apps/base/customer1/hermes-db/pg-cluster-hermes.yaml @@ -6,10 +6,20 @@ metadata: spec: instances: 1 - imageName: ghcr.io/cloudnative-pg/postgresql:15.2 + # Custom image with pgvector extension + imageName: "gcr.io/devops-lab-cluster/postgres-pgvector:15.2-0.8.0" storage: size: 20Gi + # pgvector extension configuration + sharedPreloadLibraries: + - pgvector + + postgresql: + parameters: + # pgvector HNSW index memory settings + maintenance_work_mem: "256MB" + managed: roles: - name: hermes diff --git a/apps/base/customer1/hermes-db/rag-db-init.sql b/apps/base/customer1/hermes-db/rag-db-init.sql new file mode 100644 index 0000000..f2b707c --- /dev/null +++ b/apps/base/customer1/hermes-db/rag-db-init.sql @@ -0,0 +1,35 @@ +-- RAG knowledge base initialization SQL +-- Applied to agent_memory database via CNPG Database resource + +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + content TEXT NOT NULL, + metadata JSONB DEFAULT '{}'::jsonb, + embedding vector(768), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_documents_embedding_hnsw + ON documents USING hnsw (embedding vector_cosine_ops); + +CREATE INDEX IF NOT EXISTS idx_documents_content + ON documents USING gin (to_tsvector('english', content)); + +CREATE INDEX IF NOT EXISTS idx_documents_metadata + ON documents USING gin (metadata); + +CREATE OR REPLACE FUNCTION update_documents_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_updated_at + BEFORE UPDATE ON documents + FOR EACH ROW + EXECUTE FUNCTION update_documents_updated_at(); diff --git a/apps/base/customer1/hermes-db/rag-init-job.yaml b/apps/base/customer1/hermes-db/rag-init-job.yaml new file mode 100644 index 0000000..8cb26f8 --- /dev/null +++ b/apps/base/customer1/hermes-db/rag-init-job.yaml @@ -0,0 +1,41 @@ +# Job to initialize RAG schema in agent_memory database +# Runs once after the cluster is available with pgvector + +apiVersion: batch/v1 +kind: Job +metadata: + name: init-rag-schema + namespace: customer1 + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-delete-policy": hook-succeeded +spec: + template: + spec: + restartPolicy: Never + containers: + - name: psql + image: ghcr.io/cloudnative-pg/postgresql:15.2 + command: + - /bin/sh + - -c + - | + PGPASSWORD=$(cat /run/secrets/postgresql/password) psql \ + -h hermes-pgdb-rpostgres.customer1.svc.cluster.local \ + -p 5432 \ + -U memory \ + -d agent_memory \ + -f /sql/rag-db-init.sql + env: + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: memory-db-credentials + key: password + volumeMounts: + - name: rag-sql + mountPath: /sql + volumes: + - name: rag-sql + configMap: + name: rag-init-sql diff --git a/apps/base/customer1/hermes-db/rag-init-sql-configmap.yaml b/apps/base/customer1/hermes-db/rag-init-sql-configmap.yaml new file mode 100644 index 0000000..46c0054 --- /dev/null +++ b/apps/base/customer1/hermes-db/rag-init-sql-configmap.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: rag-init-sql + namespace: customer1 +data: + rag-db-init.sql: | + -- Enable pgvector extension + CREATE EXTENSION IF NOT EXISTS vector; + + -- Documents table for RAG knowledge base + CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + content TEXT NOT NULL, + metadata JSONB DEFAULT '{}'::jsonb, + embedding vector(768), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + -- HNSW index for vector similarity search (cosine distance) + CREATE INDEX IF NOT EXISTS idx_documents_embedding_hnsw + ON documents USING hnsw (embedding vector_cosine_ops); + + -- Full-text search index + CREATE INDEX IF NOT EXISTS idx_documents_content + ON documents USING gin (to_tsvector('english', content)); + + -- Metadata filter index + CREATE INDEX IF NOT EXISTS idx_documents_metadata + ON documents USING gin (metadata); + + -- Auto-update updated_at timestamp + CREATE OR REPLACE FUNCTION update_documents_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = now(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER trg_documents_updated_at + BEFORE UPDATE ON documents + FOR EACH ROW + EXECUTE FUNCTION update_documents_updated_at(); diff --git a/apps/base/customer1/hermes-db/rag-schema.sql b/apps/base/customer1/hermes-db/rag-schema.sql new file mode 100644 index 0000000..0fe817c --- /dev/null +++ b/apps/base/customer1/hermes-db/rag-schema.sql @@ -0,0 +1,52 @@ +-- ============================================================ +-- RAG Knowledge Base Schema for agent_memory database +-- Embedding dimensions: 768 (nomic-embed-text-v1.5) +-- ============================================================ + +-- Enable pgvector extension +CREATE EXTENSION IF NOT EXISTS vector; + +-- Documents table for RAG knowledge base +CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + content TEXT NOT NULL, + metadata JSONB DEFAULT '{}'::jsonb, + embedding vector(768), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Index on content for full-text search +CREATE INDEX IF NOT EXISTS idx_documents_content ON documents USING gin (to_tsvector('english', content)); + +-- HNSW index for vector similarity search (cosine distance) +CREATE INDEX IF NOT EXISTS idx_documents_embedding_hnsw ON documents USING hnsw (embedding vector_cosine_ops); + +-- Index on metadata for filtering +CREATE INDEX IF NOT EXISTS idx_documents_metadata ON documents USING gin (metadata); + +-- Updated_at trigger +CREATE OR REPLACE FUNCTION update_documents_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_documents_updated_at + BEFORE UPDATE ON documents + FOR EACH ROW + EXECUTE FUNCTION update_documents_updated_at(); + +-- Comments for documentation +COMMENT ON TABLE documents IS 'RAG knowledge base documents with vector embeddings'; +COMMENT ON COLUMN documents.content IS 'Full text content of the document'; +COMMENT ON COLUMN documents.metadata IS 'JSON metadata: source, chunk_id, title, tags, etc.'; +COMMENT ON COLUMN documents.embedding IS '768-dim vector embedding (nomic-embed-text-v1.5)'; + +-- Example query for similarity search: +-- SELECT id, content, metadata, 1 - (embedding <=> 'your_embedding_here'::vector) AS similarity +-- FROM documents +-- ORDER BY embedding <=> 'your_embedding_here'::vector +-- LIMIT 5;