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>
159 lines
7.3 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|