diff --git a/backend/src/knowledge/services/semantic-orchestrator.service.ts b/backend/src/knowledge/services/semantic-orchestrator.service.ts index d2e5e55..af5894c 100644 --- a/backend/src/knowledge/services/semantic-orchestrator.service.ts +++ b/backend/src/knowledge/services/semantic-orchestrator.service.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { TenantDatabaseService } from '../../tenant/tenant-database.service'; import { MeilisearchService } from '../../search/meilisearch.service'; +import { getCentralPrisma } from '../../prisma/central-prisma.service'; +import { OpenAIConfig } from '../../voice/interfaces/integration-config.interface'; import { DefaultSemanticProjectionAdapter, SemanticProjectionAdapter, @@ -12,6 +14,9 @@ import { SemanticLinkService } from './semantic-link.service'; export class SemanticOrchestratorService { private readonly logger = new Logger(SemanticOrchestratorService.name); private readonly adapters: SemanticProjectionAdapter[] = [new DefaultSemanticProjectionAdapter()]; + private readonly defaultEmbeddingModel = + process.env.OPENAI_EMBEDDING_MODEL || 'text-embedding-3-small'; + private readonly semanticEmbedderName = 'semantic-openai'; constructor( private readonly tenantDbService: TenantDatabaseService, @@ -61,8 +66,9 @@ export class SemanticOrchestratorService { const chunks = this.chunkerService.chunkText(projection.narrative, comments); await this.replaceChunks(knex, documentId, chunks); - await this.indexChunks(resolvedTenantId, projection, chunks); - await this.generateSuggestions(resolvedTenantId, projection, chunks, userId, trigger); + const openAiConfig = await this.getOpenAiConfig(resolvedTenantId); + await this.indexChunks(resolvedTenantId, projection, chunks, openAiConfig); + await this.generateSuggestions(resolvedTenantId, projection, chunks, openAiConfig, userId, trigger); return { documentId, chunkCount: chunks.length }; } @@ -139,12 +145,25 @@ export class SemanticOrchestratorService { ); } - private async indexChunks(tenantId: string, projection: any, chunks: any[]) { + private async indexChunks( + tenantId: string, + projection: any, + chunks: any[], + openAiConfig: OpenAIConfig | null, + ) { if (!this.meilisearchService.isEnabled()) { return; } const indexName = this.meilisearchService.buildSemanticChunkIndexName(tenantId); + if (openAiConfig?.apiKey) { + await this.meilisearchService.ensureOpenAiEmbedder(indexName, { + embedderName: this.semanticEmbedderName, + apiKey: openAiConfig.apiKey, + model: openAiConfig.embeddingModel || this.defaultEmbeddingModel, + documentTemplate: '{{doc.title}}\n{{doc.text}}', + }); + } await this.meilisearchService.upsertDocuments(indexName, chunks.map((chunk) => ({ id: `${projection.entityType}:${projection.entityId}:${chunk.chunkIndex}`, entityType: projection.entityType, @@ -160,6 +179,7 @@ export class SemanticOrchestratorService { tenantId: string, projection: any, chunks: any[], + openAiConfig: OpenAIConfig | null, userId?: string, trigger: string = 'semantic_refresh', ) { @@ -169,7 +189,12 @@ export class SemanticOrchestratorService { const indexName = this.meilisearchService.buildSemanticChunkIndexName(tenantId); const queryText = chunks.slice(0, 3).map((chunk) => chunk.text).join(' ').slice(0, 1200); - const search = await this.meilisearchService.searchIndex(indexName, queryText, 20); + const search = await this.meilisearchService.searchIndex( + indexName, + queryText, + 20, + openAiConfig?.apiKey ? { embedder: this.semanticEmbedderName } : undefined, + ); const grouped = new Map(); for (const hit of search.hits || []) { @@ -222,4 +247,38 @@ export class SemanticOrchestratorService { return `${objectDefinition.apiName.toLowerCase()}s`; } + + private async getOpenAiConfig(tenantId: string): Promise { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const centralPrisma = getCentralPrisma(); + const tenant = await centralPrisma.tenant.findUnique({ + where: { id: resolvedTenantId }, + select: { integrationsConfig: true }, + }); + + let config = tenant?.integrationsConfig + ? typeof tenant.integrationsConfig === 'string' + ? this.tenantDbService.decryptIntegrationsConfig(tenant.integrationsConfig) + : tenant.integrationsConfig + : null; + + if (!config?.openai && process.env.OPENAI_API_KEY) { + config = { + ...(config || {}), + openai: { + apiKey: process.env.OPENAI_API_KEY, + embeddingModel: this.defaultEmbeddingModel, + }, + }; + } + + if (config?.openai?.apiKey) { + return { + apiKey: config.openai.apiKey, + embeddingModel: config.openai.embeddingModel || this.defaultEmbeddingModel, + }; + } + + return null; + } } diff --git a/backend/src/search/meilisearch.service.ts b/backend/src/search/meilisearch.service.ts index e8f2bbb..5549366 100644 --- a/backend/src/search/meilisearch.service.ts +++ b/backend/src/search/meilisearch.service.ts @@ -8,9 +8,22 @@ type MeiliConfig = { indexPrefix: string; }; +type HybridSearchOptions = { + embedder: string; + semanticRatio?: number; +}; + +type OpenAiEmbedderConfig = { + embedderName: string; + apiKey: string; + model: string; + documentTemplate: string; +}; + @Injectable() export class MeilisearchService { private readonly logger = new Logger(MeilisearchService.name); + private readonly embedderCache = new Map(); isEnabled(): boolean { return Boolean(this.getConfig()); @@ -183,6 +196,7 @@ export class MeilisearchService { indexName: string, query: string, limit = 20, + hybrid?: HybridSearchOptions, ): Promise<{ hits: any[]; total: number }> { const config = this.getConfig(); if (!config) return { hits: [], total: 0 }; @@ -192,7 +206,11 @@ export class MeilisearchService { const response = await this.requestJson( 'POST', url, - { q: query, limit }, + { + q: query, + limit, + ...(hybrid ? { hybrid } : {}), + }, this.buildHeaders(config), ); @@ -250,7 +268,7 @@ export class MeilisearchService { } private requestJson( - method: 'POST' | 'DELETE', + method: 'POST' | 'DELETE' | 'PATCH', url: string, payload: any, headers: Record, @@ -293,4 +311,49 @@ export class MeilisearchService { request.end(); }); } + + async ensureOpenAiEmbedder( + indexName: string, + config: OpenAiEmbedderConfig, + ): Promise { + const meiliConfig = this.getConfig(); + if (!meiliConfig || !config?.apiKey) return; + + const signature = JSON.stringify({ + embedderName: config.embedderName, + model: config.model, + documentTemplate: config.documentTemplate, + apiKey: config.apiKey, + }); + const cacheKey = `${indexName}:${config.embedderName}`; + if (this.embedderCache.get(cacheKey) === signature) { + return; + } + + const url = `${meiliConfig.host}/indexes/${encodeURIComponent(indexName)}/settings/embedders`; + try { + const response = await this.requestJson( + 'PATCH', + url, + { + [config.embedderName]: { + source: 'openAi', + model: config.model, + apiKey: config.apiKey, + documentTemplate: config.documentTemplate, + }, + }, + this.buildHeaders(meiliConfig), + ); + if (!this.isSuccessStatus(response.status)) { + this.logger.warn( + `Meilisearch embedder update failed for index ${indexName}: ${response.status}`, + ); + return; + } + this.embedderCache.set(cacheKey, signature); + } catch (error) { + this.logger.warn(`Meilisearch embedder update failed: ${error.message}`); + } + } } diff --git a/backend/src/voice/interfaces/integration-config.interface.ts b/backend/src/voice/interfaces/integration-config.interface.ts index 9cee167..41cddae 100644 --- a/backend/src/voice/interfaces/integration-config.interface.ts +++ b/backend/src/voice/interfaces/integration-config.interface.ts @@ -11,6 +11,7 @@ export interface OpenAIConfig { apiKey: string; assistantId?: string; model?: string; + embeddingModel?: string; voice?: string; }