manager-service/ManagerService/Services/VectorStoreService.cs
Thomas Fransolet 18e4240f0f RAG: pipeline d'ingestion, endpoints du guide IA, journalisation RGPD
Ingestion et indexation
- IIngestionService/IngestionService : chargement des collections filles par
  sous-type, un jeu de morceaux par langue, ChunkIndex continu.
- SectionIndexingInterceptor retenu comme unique déclencheur : les 5
  sous-contrôleurs totalisaient 30 SaveChanges et 0 Enqueue, donc ajouter des
  points d'intérêt à une carte ne réindexait rien.
- HTML retiré avant l'embedding et lignes trop longues recoupées : sans cela un
  article dépassait l'entrée max du modèle et emportait son lot de 50 morceaux.
- Gabarits de LanguageInit filtrés, DistinctBy(Text) avant le Take : ils
  occupaient les cinq premiers résultats d'une recherche en néerlandais.

Endpoints du guide IA
- GET /api/Ai/knowledge/{id} : agrégats sur ContentEmbedding, donc sur ce qui
  est réellement indexé — compter les sections publiées serait plus flatteur et faux.
- GET /api/Ai/insights/{id} : miroir de GuideIaInsights côté manager-app, c'est
  l'écran qui a fixé la forme pour que le job de thèmes la remplisse.

RGPD
- VisitorQuestion journalisée dans AiController.Chat. HasAnswer se déduit des
  sources du retrieval, pas du texte : un repli poli ressemble à une réponse.
  L'écriture n'échoue jamais la réponse au visiteur.
- VisitorQuestionPurgeService, 90 jours, actif sans condition de configuration :
  une durée écrite dans les CGU n'est pas un réglage commercial.

Corrections
- Updateinstance ne recopiait pas les quotas du nouveau plan.
- CheckQuota ne bloquait ni ne comptait à quota 0 — IA gratuite non comptée.
- StoragePath et SizeBytes renseignés à Create, types URL exclus.

dotnet build 0 erreur, dotnet test 130/130.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:48:10 +02:00

159 lines
7.3 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
private const double LanguageBonus = 0.05;
/// <summary>Privilégie la visite en cours à pertinence comparable.</summary>
private const double ConfigurationBonus = 0.02;
/// <summary>
/// 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.
/// </summary>
private const int OversampleFactor = 5;
private const int MinimumCandidates = 25;
/// <summary>
/// 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 <c>hnsw.iterative_scan</c>, sur le même test de charge.
/// </summary>
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<int?>("AI:SearchTopK") ?? DefaultTopK;
}
public async Task ReplaceAsync(string instanceId, string configurationId, string contentId,
ContentSourceType contentType, IReadOnlyList<ContentChunk> chunks,
CancellationToken cancellationToken = default)
{
var texts = (chunks ?? Array.Empty<ContentChunk>())
.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<List<VectorSearchResult>> SearchAsync(string instanceId, string currentConfigurationId,
string language, string query, int? topK = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return new List<VectorSearchResult>();
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);
}
}
}