using ManagerService.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Pgvector; using Pgvector.EntityFrameworkCore; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace ManagerService.Services { public class VectorStoreService : IVectorStoreService { /// /// Départage deux traductions du même contenu, qui sortent à des distances quasi /// identiques. Volontairement petit : un contenu réellement plus pertinent dans une /// autre langue doit rester devant. /// private const double LanguageBonus = 0.05; /// Privilégie la visite en cours à pertinence comparable. private const double ConfigurationBonus = 0.02; /// /// On reclasse en mémoire, donc il faut plus de candidats que de résultats voulus, /// sinon les bonus ne peuvent que réordonner ce que la distance brute avait déjà retenu. /// private const int OversampleFactor = 5; private const int MinimumCandidates = 25; /// /// Suffisant pour les questions d'un visiteur, qui portent sur un contenu précis. /// À monter (`AI:SearchTopK`) si les réponses s'avèrent tronquées sur du volume réel — /// se règle en même temps que hnsw.iterative_scan, sur le même test de charge. /// private const int DefaultTopK = 5; private readonly MyInfoMateDbContext _db; private readonly IEmbeddingService _embeddingService; private readonly int _defaultTopK; public VectorStoreService(MyInfoMateDbContext db, IEmbeddingService embeddingService, IConfiguration configuration) { _db = db; _embeddingService = embeddingService; _defaultTopK = configuration.GetValue("AI:SearchTopK") ?? DefaultTopK; } public async Task ReplaceAsync(string instanceId, string configurationId, string contentId, ContentSourceType contentType, IReadOnlyList chunks, CancellationToken cancellationToken = default) { var texts = (chunks ?? Array.Empty()) .Where(c => !string.IsNullOrWhiteSpace(c.Text)) .ToList(); // L'embedding est un appel réseau : le sortir de la transaction évite de tenir // un verrou sur les lignes pendant plusieurs secondes, et un échec d'API laisse // alors l'index précédent intact plutôt qu'un contenu vidé. var vectors = await _embeddingService.EmbedBatchAsync( texts.Select(c => c.Text).ToList(), cancellationToken); await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); await _db.ContentEmbeddings .Where(e => e.ContentType == contentType && e.ContentId == contentId) .ExecuteDeleteAsync(cancellationToken); var now = DateTime.UtcNow; for (var i = 0; i < texts.Count; i++) { _db.ContentEmbeddings.Add(new ContentEmbedding { InstanceId = instanceId, ConfigurationId = configurationId, ContentId = contentId, ContentType = contentType, ChunkIndex = texts[i].ChunkIndex, PageNumber = texts[i].PageNumber, Language = texts[i].Language, Text = texts[i].Text, Embedding = new Vector(vectors[i]), UpdatedAt = now }); } await _db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } public async Task> SearchAsync(string instanceId, string currentConfigurationId, string language, string query, int? topK = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) return new List(); var resultCount = topK ?? _defaultTopK; var queryVector = new Vector(await _embeddingService.EmbedAsync(query, cancellationToken)); var candidateCount = Math.Max(resultCount * OversampleFactor, MinimumCandidates); // pgvector applique le WHERE *après* le parcours de l'index HNSW : avec plusieurs // instances dans la table, le filtre jette l'essentiel des candidats et la requête // rend moins de lignes qu'il n'en existe. Le parcours itératif (pgvector 0.8+) // relance le parcours jusqu'à en avoir assez après filtrage. await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); await _db.Database.ExecuteSqlRawAsync("SET LOCAL hnsw.iterative_scan = relaxed_order", cancellationToken); var candidates = await _db.ContentEmbeddings .Where(e => e.InstanceId == instanceId) .OrderBy(e => e.Embedding.CosineDistance(queryVector)) .Take(candidateCount) .Select(e => new { e.ContentId, e.ContentType, e.Text, e.PageNumber, e.Language, e.ConfigurationId, Distance = e.Embedding.CosineDistance(queryVector) }) .ToListAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return candidates .Select(c => new VectorSearchResult( c.ContentId, c.ContentType, c.Text, c.PageNumber, c.Language, 1 - c.Distance + (string.Equals(c.Language, language, StringComparison.OrdinalIgnoreCase) ? LanguageBonus : 0) + (c.ConfigurationId != null && c.ConfigurationId == currentConfigurationId ? ConfigurationBonus : 0))) .OrderByDescending(r => r.Score) // Le même texte revient à l'identique quand il est répété dans le contenu — la // description d'un lieu recopiée sur chacun de ses événements, par exemple. // Mesuré sur l'instance de démo : trois des cinq résultats étaient le même // paragraphe, au même score. Autant de place perdue dans le contexte du modèle. .DistinctBy(r => r.Text) .Take(resultCount) .ToList(); } public async Task DeleteAsync(string contentId, ContentSourceType contentType, CancellationToken cancellationToken = default) { await _db.ContentEmbeddings .Where(e => e.ContentType == contentType && e.ContentId == contentId) .ExecuteDeleteAsync(cancellationToken); } } }