Merge pull request #128 from sirius0xdev/backend/t_438b663e-pgvector-rag-kb
feat(customer1): add pgvector RAG knowledge base with embedding service
This commit is contained in:
commit
9f9bdba2b7
16 changed files with 486 additions and 1 deletions
18
apps/base/customer1/embedding-service/Dockerfile.embedding
Normal file
18
apps/base/customer1/embedding-service/Dockerfile.embedding
Normal 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"]
|
||||
150
apps/base/customer1/embedding-service/app.py
Normal file
150
apps/base/customer1/embedding-service/app.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
16
apps/base/customer1/embedding-service/embedding-service.yaml
Normal file
16
apps/base/customer1/embedding-service/embedding-service.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: embedding-service
|
||||
namespace: customer1
|
||||
labels:
|
||||
app: embedding-service
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: embedding-service
|
||||
ports:
|
||||
- name: http
|
||||
port: 8000
|
||||
targetPort: 8000
|
||||
protocol: TCP
|
||||
6
apps/base/customer1/embedding-service/kustomization.yaml
Normal file
6
apps/base/customer1/embedding-service/kustomization.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- embedding-deployment.yaml
|
||||
- embedding-service.yaml
|
||||
7
apps/base/customer1/embedding-service/requirements.txt
Normal file
7
apps/base/customer1/embedding-service/requirements.txt
Normal 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
|
||||
5
apps/base/customer1/hermes-db/.dockerignore
Normal file
5
apps/base/customer1/hermes-db/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# .dockerignore for pgvector image builds
|
||||
.git
|
||||
*.md
|
||||
*.yaml
|
||||
*.yml
|
||||
29
apps/base/customer1/hermes-db/Dockerfile.postgres-pgvector
Normal file
29
apps/base/customer1/hermes-db/Dockerfile.postgres-pgvector
Normal 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.
|
||||
12
apps/base/customer1/hermes-db/agent-memory-rag-db.yaml
Normal file
12
apps/base/customer1/hermes-db/agent-memory-rag-db.yaml
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
apps/base/customer1/hermes-db/rag-db-init.sql
Normal file
35
apps/base/customer1/hermes-db/rag-db-init.sql
Normal 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();
|
||||
41
apps/base/customer1/hermes-db/rag-init-job.yaml
Normal file
41
apps/base/customer1/hermes-db/rag-init-job.yaml
Normal 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
|
||||
45
apps/base/customer1/hermes-db/rag-init-sql-configmap.yaml
Normal file
45
apps/base/customer1/hermes-db/rag-init-sql-configmap.yaml
Normal 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();
|
||||
52
apps/base/customer1/hermes-db/rag-schema.sql
Normal file
52
apps/base/customer1/hermes-db/rag-schema.sql
Normal 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;
|
||||
|
|
@ -9,6 +9,7 @@ resources:
|
|||
- ../../base/customer1/paaas-landing/
|
||||
- ../../base/customer1/hermes-agent/
|
||||
- ../../base/customer1/hermes-db/
|
||||
- ../../base/customer1/embedding-service/
|
||||
- ../../base/customer1/trade-dashboard/
|
||||
- ../../base/customer1/siriusdevops-db/
|
||||
- ../../base/customer1/trading-platform/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue