Source: backend/models/modelSemanticSearch.js

/**
 * @fileoverview Diese Datei enthält Funktionen zur Durchführung von semantischer Suche.
 * Sie ermöglicht das Generieren von Embeddings, das Durchführen von Datenbankabfragen 
 * und das Anwenden von Clustering-Boosts zur Verbesserung der Suchergebnisse.
 * 
 * @author Lennart, Miray
 * Die Funktionen wurden mit Unterstützung von KI-Tools angepasst und optimiert.
 * @module modelSemanticSearch
 */

const { generateEmbedding } = require('./modelEmbedding');
const { runClustering } = require('./modelClustering');
const db = require('../../ConnectPostgres');

/**
 * Erstellt eine Instanz für semantische Suche mit optionaler Caching- und Clustering-Optimierung.
 * 
 * @function semanticSearch
 * @param {Object} [options={}] - Konfigurationsoptionen für die Suche.
 * @param {boolean} [options.cacheEnabled=false] - Aktiviert das Caching für schnellere Suchanfragen.
 * @param {number} [options.maxCacheSize=1000] - Maximale Anzahl an Cache-Einträgen.
 * @param {number} [options.cacheExpiryMs=3600000] - Ablaufzeit des Caches in Millisekunden.
 * @param {boolean} [options.clusterBoostEnabled=true] - Aktiviert die Cluster-Optimierung für genauere Ergebnisse.
 * @returns {Object} Ein Objekt mit der Methode `executeSearch`.
 */
function semanticSearch(options = {}) {
    const {
        cacheEnabled = false,
        maxCacheSize = 1000,
        cacheExpiryMs = 1000 * 60 * 60,
        clusterBoostEnabled = true
    } = options;

    const cache = new Map();
    const cacheTimestamps = new Map();

    /**
 * Führt eine semantische Suche für eine gegebene Abfrage aus.
 * 
 * @async
 * @function executeSearch
 * @memberof semanticSearch
 * @param {string} query - Die Suchanfrage.
 * @param {Object} [options={}] - Zusätzliche Suchoptionen.
 * @param {number} [options.limit=10] - Maximale Anzahl zurückgegebener Ergebnisse.
 * @param {Object} [options.filters={}] - Filterbedingungen für die Abfrage.
 * @param {boolean} [options.useCache=true] - Verwendet den Cache, falls aktiviert.
 * @param {Object} options.req - Das Request-Objekt, um den Benutzer zu identifizieren.
 * @returns {Promise<Array<Object>>} Eine Liste relevanter Dokumente mit Ähnlichkeitswerten.
 * @throws {Error} Falls der Benutzer nicht authentifiziert ist oder ein Fehler bei der Suche auftritt.
 * @example
 * const results = await searchInstance.executeSearch("Deep Learning", { limit: 5, req });
 * console.log(results);
 */
    async function executeSearch(query, options = {}) {
        const {
            limit = 10,
            filters = {},
            useCache = cacheEnabled,
            req
        } = options;

        if (!req?.session?.userId) {
            throw new Error("User is not authenticated");
        }

        const userId = req.session.userId;

        if (useCache) {
            const cachedResult = getFromCache(`${userId}:${query}`);
            if (cachedResult) return cachedResult;
        }

        try {
            const queryEmbedding = await generateEmbedding(query);
            let results = await dbQuery(queryEmbedding, limit, filters, userId);

            if (clusterBoostEnabled && results.length > 0) {
                console.log('Starting clustering enhancement...');
                results = await applyClusterBoost(results, queryEmbedding, userId);
                console.log('Clustering enhancement completed successfully');
            }

            if (useCache) {
                addToCache(`${userId}:${query}`, results);
            }

            return results;

        } catch (error) {
            console.error('Error in executeSearch:', error);
            throw error;
        }
    }

    /**
 * Optimiert die Suchergebnisse durch Clustering-Analyse, um relevantere Dokumente hervorzuheben.
 * 
 * @async
 * @function applyClusterBoost
 * @memberof semanticSearch
 * @param {Array<Object>} results - Die ursprünglichen Suchergebnisse.
 * @param {Array<number>} queryEmbedding - Das Embedding der Suchanfrage.
 * @param {number} userId - Die Benutzer-ID.
 * @returns {Promise<Array<Object>>} Die optimierten Suchergebnisse mit Cluster-Boosting.
 */
    async function applyClusterBoost(results, queryEmbedding, userId) {
        try {
            const embeddings = [queryEmbedding, ...results.map(r => r.embedding)];
            const config = {
                minClusterSize: 2,
                minSamples: 2,
                clusterSelectionMethod: 'eom',
                clusterSelectionEpsilon: 0.15
            };

            const clusterResults = await runClustering(embeddings, config, userId);
            const clusterLabels = clusterResults.labels;
            const queryCluster = clusterLabels[0];

            results = results.map((result, index) => {
                const documentCluster = clusterLabels[index + 1];
                let boostAmount = 0;

                if (documentCluster === queryCluster && documentCluster !== -1) {
                    boostAmount = 10;
                }

                delete result.embedding;

                return {
                    ...result,
                    distance: Math.min(100, result.distance + boostAmount)
                };
            });

            return results.sort((a, b) => b.distance - a.distance);

        } catch (error) {
            console.error('Error in cluster boosting:', error);
            results.forEach(r => delete r.embedding);
            return results;
        }
    }

    /**
 * Führt eine SQL-Abfrage für die semantische Suche in der Datenbank durch.
 * 
 * @async
 * @function dbQuery
 * @memberof semanticSearch
 * @param {Array<number>} queryEmbedding - Das Embedding der Suchanfrage.
 * @param {number} limit - Maximale Anzahl zurückgegebener Ergebnisse.
 * @param {Object} filters - Filteroptionen für die Datenbankabfrage.
 * @param {number} userId - Die Benutzer-ID für die Abfrage.
 * @returns {Promise<Array<Object>>} Eine Liste der Suchergebnisse mit Ähnlichkeitswerten.
 */
    async function dbQuery(queryEmbedding, limit, filters, userId) {
        const filterConditions = buildFilterConditions(filters);
        const vectorString = '[' + queryEmbedding.join(',') + ']';
        const whereClause = `WHERE user_id = $3 ${filterConditions ? `AND ${filterConditions}` : ''}`;

        const expandedLimit = Math.min(limit * 3, 30);

        const query = `
            WITH similarity_scores AS (
                SELECT 
                    file_id, 
                    file_name, 
                    file_type,
                    embedding,
                    (1 - (embedding <=> $1::vector)) AS cosine_similarity,
                    1 - (embedding <-> $1::vector) / NULLIF(MAX(embedding <-> $1::vector) OVER (), 1) AS normalized_euclidean_similarity,
                    1 / (1 + EXP(-(embedding <#> $1::vector))) AS sigmoid_inner_product
                FROM main.files
                ${whereClause}
            )
            SELECT 
                file_id,
                file_name,
                file_type,
                embedding,
                (
                    (0.6 * cosine_similarity + 
                    0.25 * normalized_euclidean_similarity +
                    0.15 * sigmoid_inner_product) * 100
                ) AS similarity_score
            FROM similarity_scores
            ORDER BY similarity_score DESC
            LIMIT $2
        `;

        try {
            const result = await db.query(query, [vectorString, expandedLimit, userId]);

            return result.rows.map(row => ({
                id: row.file_id,
                name: row.file_name,
                type: row.file_type,
                embedding: row.embedding,
                distance: row.similarity_score
            }));

        } catch (error) {
            console.error('Database query error:', error);
            throw error;
        }
    }

    /**
 * Fügt ein Ergebnis zur Cache-Speicherung hinzu, wobei der älteste Eintrag entfernt wird, wenn das Limit erreicht ist.
 * 
 * @function addToCache
 * @memberof semanticSearch
 * @param {string} key - Der Schlüssel für das Cache-Element.
 * @param {Array<Object>} results - Die Suchergebnisse, die gespeichert werden sollen.
 */
    function addToCache(key, results) {
        if (cache.size >= maxCacheSize) {
            const oldestKey = cache.keys().next().value;
            cache.delete(oldestKey);
            cacheTimestamps.delete(oldestKey);
        }
        cache.set(key, results);
        cacheTimestamps.set(key, Date.now());
    }

    /**
 * Ruft Ergebnisse aus dem Cache ab, falls sie noch gültig sind.
 * 
 * @function getFromCache
 * @memberof semanticSearch
 * @param {string} key - Der Schlüssel für das Cache-Element.
 * @returns {Array<Object>|null} Die gespeicherten Ergebnisse oder `null`, falls sie abgelaufen sind.
 */
    function getFromCache(key) {
        const timestamp = cacheTimestamps.get(key);
        if (!timestamp) return null;
        if (Date.now() - timestamp > cacheExpiryMs) {
            cache.delete(key);
            cacheTimestamps.delete(key);
            return null;
        }
        return cache.get(key);
    }

    /**
 * Erstellt eine SQL-Filterbedingung basierend auf übergebenen Filtern.
 * 
 * @function buildFilterConditions
 * @memberof semanticSearch
 * @param {Object} filters - Das Objekt mit den Filterbedingungen.
 * @returns {string} Eine SQL-Filterklausel zur Verwendung in der Datenbankabfrage.
 */
    function buildFilterConditions(filters) {
        return Object.entries(filters)
            .map(([key, value]) => `${key} = '${value}'`)
            .join(' AND ');
    }

    return {
        executeSearch
    };
}

module.exports = semanticSearch;