Add Vertex AI embeddings + semantic search for Learning Hub

- New: Vector search with pgvector extension (cosine similarity)
- Embeddings: Vertex AI text-embedding-005 (768 dims, HIPAA-eligible)
- 3 search modes: keyword, semantic, hybrid (best of both)
- Auto-generate embeddings on content create/update
- Admin endpoints: /api/admin/learning/embeddings/generate (backfill), /status
- User endpoints: /api/learning/search/semantic, /search/hybrid
- Falls back to OpenAI embeddings if Vertex not configured
- Supports LiteLLM proxy routing

Models tested:
- vertex_ai/text-embedding-005 (768 dims, English+code) 
- vertex_ai/gemini-embedding-001 (3072 dims, multilingual) 
- vertex_ai/text-multilingual-embedding-002 (768 dims) 
This commit is contained in:
ifedan-ed 2026-03-31 14:36:49 +00:00
parent 0658b31df3
commit 106e4baf17
5 changed files with 575 additions and 7 deletions

View file

@ -125,6 +125,27 @@ NEXTCLOUD_URL=https://cloud.yourdomain.com
# S3_SECRET_ACCESS_KEY=minio-secret-key
# S3_FORCE_PATH_STYLE=true
# ============================================================
# EMBEDDINGS (for Learning Hub semantic search)
# ============================================================
# Enables vector-based semantic search in Learning Hub
# Requires pgvector extension: apt-get install postgresql-16-pgvector
# Default model (Vertex AI text-embedding-005, 768 dims, English + code optimized)
EMBEDDING_MODEL=vertex_ai/text-embedding-005
EMBEDDING_DIMENSIONS=768
# Other Vertex AI embedding models:
# - vertex_ai/text-embedding-005 → 768 dims, English + code (recommended)
# - vertex_ai/gemini-embedding-001 → up to 3072 dims, multilingual + code
# - vertex_ai/text-multilingual-embedding-002 → 768 dims, multilingual focus
#
# LiteLLM usage (if using LiteLLM proxy):
# EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
#
# OpenAI fallback (NOT HIPAA-eligible):
# Uses text-embedding-3-small if OPENAI_API_KEY is set and no Vertex/LiteLLM configured
# ============================================================
# DATABASE
# ============================================================

View file

@ -18,6 +18,14 @@ pool.query('SELECT NOW()')
async function initDatabase() {
var client = await pool.connect();
try {
// Enable pgvector extension for embeddings
try {
await client.query('CREATE EXTENSION IF NOT EXISTS vector');
console.log('✅ pgvector extension: enabled');
} catch (err) {
console.warn('⚠️ pgvector extension not available. Vector search disabled. Install: apt-get install postgresql-16-pgvector');
}
await client.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
@ -249,6 +257,36 @@ async function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_user_docs_user ON user_documents(user_id);
`); } catch(e) {}
// Add embedding column to learning_content for vector search (768 dims = Vertex AI text-embedding-005)
try {
await client.query('ALTER TABLE learning_content ADD COLUMN IF NOT EXISTS embedding vector(768)');
console.log('✅ learning_content.embedding: added');
} catch(e) {
console.warn('⚠️ Could not add embedding column:', e.message);
}
// Create IVFFLAT index for fast similarity search (after data is populated)
try {
var indexExists = await client.query(
"SELECT 1 FROM pg_indexes WHERE indexname = 'idx_learning_content_embedding'"
);
if (indexExists.rows.length === 0) {
// Check if we have at least some embeddings before creating index
var embCount = await client.query('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL');
if (parseInt(embCount.rows[0]?.count || 0) >= 10) {
// IVFFLAT requires lists parameter — use sqrt(rows) as heuristic
var lists = Math.max(10, Math.floor(Math.sqrt(embCount.rows[0].count)));
await client.query(
`CREATE INDEX idx_learning_content_embedding ON learning_content
USING ivfflat (embedding vector_cosine_ops) WITH (lists = ${lists})`
);
console.log('✅ Vector search index: created');
}
}
} catch(e) {
// Index creation can fail if not enough data or pgvector not installed — safe to ignore
}
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
var defaults = [
// Core

View file

@ -6,6 +6,7 @@ var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
var { generateContentEmbedding, isEmbeddingsAvailable } = require('../utils/embeddings');
router.use(authMiddleware);
router.use(moderatorMiddleware);
@ -149,7 +150,22 @@ router.post('/content', async function(req, res) {
[title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]
);
res.json({ success: true, id: result.lastInsertRowid, slug: slug });
var contentId = result.lastInsertRowid;
// Generate embedding asynchronously (don't block response)
if (isEmbeddingsAvailable() && body && body.trim()) {
generateContentEmbedding({ title: title.trim(), subject: subject || '', body: body })
.then(function(embedding) {
return db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), contentId]
);
})
.then(function() { console.log('[Embeddings] Generated for content ID:', contentId); })
.catch(function(err) { console.error('[Embeddings] Failed for content ID ' + contentId + ':', err.message); });
}
res.json({ success: true, id: contentId, slug: slug });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
@ -160,19 +176,38 @@ router.put('/content/:id', async function(req, res) {
var { title, body, category_id, subject, content_type, published } = req.body;
var newTitle = title !== undefined ? title : item.title;
var newBody = body !== undefined ? body : item.body;
var newSubject = subject !== undefined ? subject : item.subject;
await db.run(
'UPDATE learning_content SET title = ?, body = ?, category_id = ?, subject = ?, content_type = ?, published = ?, updated_at = NOW() WHERE id = ?',
[
title !== undefined ? title : item.title,
body !== undefined ? body : item.body,
newTitle,
newBody,
category_id !== undefined ? (category_id || null) : item.category_id,
subject !== undefined ? subject : item.subject,
newSubject,
content_type !== undefined ? content_type : item.content_type,
published !== undefined ? published : item.published,
item.id
]
);
// Regenerate embedding if title/body/subject changed (async, don't block)
if (isEmbeddingsAvailable() && (title !== undefined || body !== undefined || subject !== undefined)) {
if (newBody && newBody.trim()) {
generateContentEmbedding({ title: newTitle, subject: newSubject, body: newBody })
.then(function(embedding) {
return db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), item.id]
);
})
.then(function() { console.log('[Embeddings] Regenerated for content ID:', item.id); })
.catch(function(err) { console.error('[Embeddings] Failed to regenerate for content ID ' + item.id + ':', err.message); });
}
}
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
@ -264,6 +299,7 @@ router.get('/stats', async function(req, res) {
var totalCategories = await db.get('SELECT COUNT(*) as count FROM learning_categories', []);
var totalQuizzes = await db.get("SELECT COUNT(DISTINCT content_id) as count FROM learning_questions", []);
var totalAttempts = await db.get('SELECT COUNT(*) as count FROM learning_progress', []);
var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []);
res.json({
success: true,
@ -272,10 +308,112 @@ router.get('/stats', async function(req, res) {
publishedContent: parseInt(published.count),
totalCategories: parseInt(totalCategories.count),
totalQuizzes: parseInt(totalQuizzes.count),
totalAttempts: parseInt(totalAttempts.count)
totalAttempts: parseInt(totalAttempts.count),
withEmbeddings: parseInt(withEmbeddings.count),
embeddingsEnabled: isEmbeddingsAvailable()
}
});
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// EMBEDDINGS — Backfill & Management
// ============================================================
// Generate embeddings for all content (or just missing ones)
router.post('/embeddings/generate', async function(req, res) {
try {
if (!isEmbeddingsAvailable()) {
return res.status(400).json({ error: 'Embeddings not configured. Set LITELLM_API_BASE, VERTEX_PROJECT, or OPENAI_API_KEY' });
}
var { regenerateAll } = req.body;
// Get content without embeddings (or all if regenerateAll=true)
var whereClause = regenerateAll ? '' : 'WHERE embedding IS NULL';
var content = await db.all(
'SELECT id, title, subject, body FROM learning_content ' + whereClause + ' ORDER BY id ASC',
[]
);
if (content.length === 0) {
return res.json({ success: true, message: 'All content already has embeddings', processed: 0 });
}
// Process in background
var processed = 0;
var failed = 0;
console.log('[Embeddings] Starting batch generation for ' + content.length + ' items...');
// Don't await — run in background
(async function() {
for (var i = 0; i < content.length; i++) {
var item = content[i];
try {
if (!item.body || !item.body.trim()) {
console.log('[Embeddings] Skipping empty content ID:', item.id);
continue;
}
var embedding = await generateContentEmbedding(item);
await db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), item.id]
);
processed++;
console.log('[Embeddings] Generated ' + processed + '/' + content.length + ' (ID: ' + item.id + ')');
} catch (err) {
failed++;
console.error('[Embeddings] Failed for content ID ' + item.id + ':', err.message);
}
}
console.log('[Embeddings] Batch complete: ' + processed + ' succeeded, ' + failed + ' failed');
// Create index if we have enough embeddings now
if (processed >= 10) {
try {
var lists = Math.max(10, Math.floor(Math.sqrt(processed)));
await db.query(
'CREATE INDEX IF NOT EXISTS idx_learning_content_embedding ON learning_content USING ivfflat (embedding vector_cosine_ops) WITH (lists = ' + lists + ')'
);
console.log('[Embeddings] Vector index created/updated');
} catch (e) {
console.error('[Embeddings] Index creation failed:', e.message);
}
}
})();
res.json({
success: true,
message: 'Embedding generation started in background',
total: content.length
});
} catch (err) {
console.error('[LearningAdmin]', err.message);
res.status(500).json({ error: err.message });
}
});
// Check embedding status
router.get('/embeddings/status', async function(req, res) {
try {
var total = await db.get('SELECT COUNT(*) as count FROM learning_content', []);
var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []);
var missing = parseInt(total.count) - parseInt(withEmbeddings.count);
res.json({
success: true,
enabled: isEmbeddingsAvailable(),
total: parseInt(total.count),
withEmbeddings: parseInt(withEmbeddings.count),
missing: missing,
model: process.env.EMBEDDING_MODEL || 'vertex_ai/text-embedding-005',
dimensions: parseInt(process.env.EMBEDDING_DIMENSIONS) || 768
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View file

@ -6,6 +6,7 @@ var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { searchSimilar, isEmbeddingsAvailable } = require('../utils/embeddings');
router.use(authMiddleware);
@ -228,7 +229,7 @@ router.post('/submit-quiz', async function(req, res) {
});
// ============================================================
// SEARCH CONTENT
// SEARCH CONTENT (keyword-based)
// ============================================================
router.get('/search', async function(req, res) {
try {
@ -247,8 +248,122 @@ router.get('/search', async function(req, res) {
[pattern, pattern, pattern]
);
res.json({ success: true, content: content });
res.json({ success: true, content: content, method: 'keyword' });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// SEMANTIC SEARCH (vector-based)
// ============================================================
router.get('/search/semantic', async function(req, res) {
try {
if (!isEmbeddingsAvailable()) {
return res.status(400).json({ error: 'Semantic search not available. Embeddings not configured.' });
}
var q = (req.query.q || '').trim();
if (!q) return res.json({ success: true, content: [], method: 'semantic' });
var limit = Math.min(parseInt(req.query.limit) || 10, 50);
var threshold = parseFloat(req.query.threshold) || 0.5;
var results = await searchSimilar(q, {
limit: limit,
threshold: threshold,
contentType: req.query.contentType || null
});
res.json({
success: true,
content: results,
method: 'semantic',
query: q
});
} catch (err) {
console.error('[LearningHub] Semantic search error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// HYBRID SEARCH (keyword + semantic combined)
// ============================================================
router.get('/search/hybrid', async function(req, res) {
try {
var q = (req.query.q || '').trim();
if (!q) return res.json({ success: true, content: [], method: 'hybrid' });
var limit = Math.min(parseInt(req.query.limit) || 20, 50);
// 1. Get keyword matches
var pattern = '%' + q + '%';
var keywordResults = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
cat.name as category_name, cat.slug as category_slug,
(SELECT COUNT(*) FROM learning_questions lq WHERE lq.content_id = c.id) as question_count,
1.0 as score, 'keyword' as match_type
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
WHERE c.published = true AND (c.title ILIKE ? OR c.subject ILIKE ? OR c.body ILIKE ?)
LIMIT 15`,
[pattern, pattern, pattern]
);
// 2. Get semantic matches (if available)
var semanticResults = [];
if (isEmbeddingsAvailable()) {
try {
semanticResults = await searchSimilar(q, {
limit: 15,
threshold: 0.4
});
// Add match_type
semanticResults = semanticResults.map(function(r) {
return Object.assign(r, { score: r.similarity, match_type: 'semantic' });
});
} catch (err) {
console.error('[LearningHub] Semantic search in hybrid failed:', err.message);
}
}
// 3. Merge and deduplicate (favor semantic if both match)
var seen = {};
var combined = [];
// Add semantic results first (higher quality)
semanticResults.forEach(function(r) {
if (!seen[r.id]) {
seen[r.id] = true;
combined.push(r);
}
});
// Add keyword results if not already included
keywordResults.forEach(function(r) {
if (!seen[r.id]) {
seen[r.id] = true;
combined.push(r);
}
});
// Sort by score descending, limit results
combined.sort(function(a, b) { return (b.score || 0) - (a.score || 0); });
combined = combined.slice(0, limit);
res.json({
success: true,
content: combined,
method: 'hybrid',
query: q,
keywordCount: keywordResults.length,
semanticCount: semanticResults.length
});
} catch (err) {
console.error('[LearningHub] Hybrid search error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

256
src/utils/embeddings.js Normal file
View file

@ -0,0 +1,256 @@
// ============================================================
// EMBEDDINGS UTILITY — Generate & search with Vertex AI embeddings
// Supports: Vertex AI (direct), LiteLLM proxy, OpenAI fallback
// ============================================================
var axios = require('axios');
// Vertex AI embedding models (via LiteLLM or direct)
// gemini-embedding-001: 768 dims, multilingual + code, best quality
// text-embedding-005: 768 dims, English + code optimized
// text-multilingual-embedding-002: 768 dims, multilingual focus
var DEFAULT_MODEL = 'vertex_ai/text-embedding-005';
var DEFAULT_DIMS = 768;
/**
* Generate embedding for text using configured provider
* @param {string} text - Text to embed (max ~2000 tokens)
* @param {object} opts - Options: { model, dimensions }
* @returns {Promise<number[]>} - Embedding vector
*/
async function generateEmbedding(text, opts) {
opts = opts || {};
var model = opts.model || process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
var dimensions = opts.dimensions || parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS;
// Truncate text to ~2000 tokens (~8000 chars) to avoid API errors
var truncated = text.substring(0, 8000);
if (!truncated.trim()) {
throw new Error('Empty text provided for embedding');
}
// Try LiteLLM first if configured
if (process.env.LITELLM_API_BASE) {
return await generateEmbeddingLiteLLM(truncated, model, dimensions);
}
// Try Vertex AI direct if configured
if (process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.VERTEX_PROJECT) {
return await generateEmbeddingVertexDirect(truncated, model, dimensions);
}
// Fallback to OpenAI if configured
if (process.env.OPENAI_API_KEY) {
return await generateEmbeddingOpenAI(truncated, model, dimensions);
}
throw new Error('No embedding provider configured. Set LITELLM_API_BASE, VERTEX_PROJECT, or OPENAI_API_KEY');
}
/**
* Generate embedding via LiteLLM proxy
*/
async function generateEmbeddingLiteLLM(text, model, dimensions) {
try {
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) {
headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
}
var payload = {
model: model,
input: text
};
// Only include dimensions if model supports it (some models have fixed dims)
if (dimensions && model.includes('text-embedding-005')) {
payload.dimensions = dimensions;
}
var response = await axios.post(base + '/embeddings', payload, {
headers: headers,
timeout: 30000
});
if (!response.data || !response.data.data || !response.data.data[0]) {
throw new Error('Invalid response from LiteLLM embeddings API');
}
return response.data.data[0].embedding;
} catch (err) {
console.error('[Embeddings] LiteLLM error:', err.response?.data || err.message);
throw new Error('LiteLLM embedding failed: ' + (err.response?.data?.error || err.message));
}
}
/**
* Generate embedding via Vertex AI direct (using @google-cloud/vertexai)
*/
async function generateEmbeddingVertexDirect(text, model, dimensions) {
try {
var { VertexAI } = require('@google-cloud/vertexai');
var project = process.env.VERTEX_PROJECT || process.env.GOOGLE_CLOUD_PROJECT;
var location = process.env.VERTEX_LOCATION || 'us-central1';
if (!project) {
throw new Error('VERTEX_PROJECT or GOOGLE_CLOUD_PROJECT not set');
}
var vertexAI = new VertexAI({ project: project, location: location });
// Extract model name (strip vertex_ai/ prefix if present)
var modelName = model.replace(/^vertex_ai\//, '');
// For text-embedding-005, we can specify output dimensions
var request = {
instances: [{ content: text }]
};
if (dimensions && modelName.includes('text-embedding-005')) {
request.parameters = { outputDimensionality: dimensions };
}
// Use predictText API for embeddings
var predictionClient = vertexAI.preview.getPredictionServiceClient();
var endpoint = `projects/${project}/locations/${location}/publishers/google/models/${modelName}`;
var [response] = await predictionClient.predict({
endpoint: endpoint,
instances: [{ content: text }],
parameters: request.parameters || {}
});
if (!response || !response.predictions || !response.predictions[0]) {
throw new Error('Invalid response from Vertex AI');
}
var prediction = response.predictions[0];
return prediction.embeddings?.values || prediction.values || prediction;
} catch (err) {
console.error('[Embeddings] Vertex AI direct error:', err.message);
throw new Error('Vertex AI embedding failed: ' + err.message);
}
}
/**
* Generate embedding via OpenAI (fallback)
*/
async function generateEmbeddingOpenAI(text, model, dimensions) {
try {
var openai = require('openai');
var client = new openai.OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// Use OpenAI's text-embedding-3-small model (1536 dims by default)
var embModel = 'text-embedding-3-small';
var response = await client.embeddings.create({
model: embModel,
input: text,
dimensions: dimensions || 768 // OpenAI supports custom dimensions
});
return response.data[0].embedding;
} catch (err) {
console.error('[Embeddings] OpenAI error:', err.message);
throw new Error('OpenAI embedding failed: ' + err.message);
}
}
/**
* Search for similar content using cosine similarity
* @param {string} queryText - Search query
* @param {object} opts - Options: { limit, threshold, contentType }
* @returns {Promise<Array>} - Matching content with similarity scores
*/
async function searchSimilar(queryText, opts) {
opts = opts || {};
var limit = opts.limit || 10;
var threshold = opts.threshold || 0.5; // Cosine similarity threshold (0-1)
var db = require('../db/database');
// Generate embedding for query
var queryEmbedding = await generateEmbedding(queryText);
// Build WHERE clause for filtering
var whereClause = 'WHERE c.published = true AND c.embedding IS NOT NULL';
var params = [JSON.stringify(queryEmbedding), threshold, limit];
var paramIdx = 4;
if (opts.contentType) {
whereClause += ' AND c.content_type = $' + paramIdx;
params.push(opts.contentType);
paramIdx++;
}
if (opts.categoryId) {
whereClause += ' AND c.category_id = $' + paramIdx;
params.push(opts.categoryId);
}
// Query with cosine similarity using pgvector
// 1 - (a <=> b) converts distance to similarity (higher = more similar)
var sql = `
SELECT
c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
cat.name as category_name, cat.slug as category_slug,
1 - (c.embedding <=> $1::vector) as similarity,
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
${whereClause}
AND 1 - (c.embedding <=> $1::vector) >= $2
ORDER BY c.embedding <=> $1::vector
LIMIT $3
`;
var results = await db.all(sql, params);
return results;
}
/**
* Generate embedding for learning content (combines title + subject + body)
* @param {object} content - { title, subject, body }
* @returns {Promise<number[]>} - Embedding vector
*/
async function generateContentEmbedding(content) {
// Combine title, subject, and body (weighted toward title)
var text = [
content.title || '',
content.title || '', // Title twice for emphasis
content.subject || '',
stripHtml(content.body || '').substring(0, 6000)
].filter(Boolean).join('\n\n');
return await generateEmbedding(text);
}
/**
* Strip HTML tags from string
*/
function stripHtml(html) {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
/**
* Check if embeddings are available (provider configured)
*/
function isEmbeddingsAvailable() {
return !!(
process.env.LITELLM_API_BASE ||
process.env.VERTEX_PROJECT ||
process.env.GOOGLE_CLOUD_PROJECT ||
process.env.OPENAI_API_KEY
);
}
module.exports = {
generateEmbedding,
generateContentEmbedding,
searchSimilar,
isEmbeddingsAvailable,
DEFAULT_MODEL,
DEFAULT_DIMS
};