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
This commit is contained in:
Hermes Agent 2026-05-24 20:31:59 +00:00
parent 99a864171b
commit 4df7450461
13 changed files with 463 additions and 1 deletions

View file

@ -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"]

View file

@ -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,
}

View file

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

View file

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

View file

@ -0,0 +1,5 @@
# .dockerignore for pgvector image builds
.git
*.md
*.yaml
*.yml

View file

@ -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.

View file

@ -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"

View file

@ -8,6 +8,9 @@ resources:
- memory-db-credentials.yaml - memory-db-credentials.yaml
- trading-data-db.yaml - trading-data-db.yaml
- agent-memory-db.yaml - agent-memory-db.yaml
- agent-memory-rag-db.yaml
- hermes-scheduled-backup.yaml - hermes-scheduled-backup.yaml
- kafka-broker.yaml - kafka-broker.yaml
- redis-cluster.yaml - redis-cluster.yaml
- rag-init-sql-configmap.yaml
- rag-init-job.yaml

View file

@ -6,10 +6,20 @@ metadata:
spec: spec:
instances: 1 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: storage:
size: 20Gi size: 20Gi
# pgvector extension configuration
sharedPreloadLibraries:
- pgvector
postgresql:
parameters:
# pgvector HNSW index memory settings
maintenance_work_mem: "256MB"
managed: managed:
roles: roles:
- name: hermes - name: hermes

View file

@ -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();

View file

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

View file

@ -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();

View file

@ -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;