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);
}
}
}