using ManagerService.Data; using ManagerService.Services; using ManagerService.Tests.Infrastructure; using Microsoft.EntityFrameworkCore; using Npgsql; using Pgvector; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Xunit; namespace ManagerService.Tests.Services { /// /// Le RAG n'avait tourné que sur une base à UNE instance — le cas où le /// post-filtrage HNSW ne se voit pas. Ces tests le mettent en défaut à deux. /// [Collection(PostgresCollection.Name)] public class VectorStoreServiceTests { /// /// Un rapport de 30 pour 1 entre les deux instances : assez déséquilibré pour que /// toutes les lignes de la minoritaire tombent derrière hnsw.ef_search /// (40 par défaut) si jamais l'index HNSW était parcouru avant le filtre. /// private const int NoisyRows = 600; private const int TargetRows = 20; private readonly PostgresFixture _postgres; public VectorStoreServiceTests(PostgresFixture postgres) { _postgres = postgres; } private VectorStoreService BuildService(MyInfoMateDbContext db) => new VectorStoreService(db, new FakeEmbeddingService(), FakeConfiguration.Create()); /// /// Deux instances : « noisy » sature l'axe 0 (celui de la question), « target » /// n'a que quelques lignes sur l'axe 5. Toutes les lignes de noisy passent donc /// devant toutes celles de target au classement brut. /// private static void SeedTwoInstances(MyInfoMateDbContext db) { var embeddings = new FakeEmbeddingService(); var rows = new List(); // Le suffixe rend chaque texte unique sans changer le vecteur : sinon la // déduplication par texte de SearchAsync réduirait 20 lignes à 9. for (var i = 0; i < NoisyRows; i++) rows.Add(Row(embeddings, "noisy", $"noisy-{i}", i, $"axis:0#{i % 9 + 1}-{i}")); for (var i = 0; i < TargetRows; i++) rows.Add(Row(embeddings, "target", $"target-{i}", i, $"axis:5#{i % 9 + 1}-{i}")); db.ContentEmbeddings.AddRange(rows); db.SaveChanges(); } private static ContentEmbedding Row(FakeEmbeddingService embeddings, string instanceId, string contentId, int chunkIndex, string text) => new ContentEmbedding { InstanceId = instanceId, ConfigurationId = null, ContentId = contentId, ContentType = ContentSourceType.Section, ChunkIndex = chunkIndex, Language = "fr", Text = text, Embedding = new Vector(embeddings.Embed(text)), UpdatedAt = DateTime.UtcNow }; [SkippableFact] public void Extensions_AreCreatedByMigrations() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_extensions"); using var connection = _postgres.OpenRawConnection("vs_extensions"); using var command = new NpgsqlCommand( "SELECT extname FROM pg_extension WHERE extname IN ('vector', 'postgis')", connection); using var reader = command.ExecuteReader(); var found = new List(); while (reader.Read()) found.Add(reader.GetString(0)); // Aucun CREATE EXTENSION n'est joué à la main : si les migrations ne les // déclarent pas, un environnement neuf part sans, et rien ne le dit avant // la première recherche. Assert.Contains("vector", found); Assert.Contains("postgis", found); } [SkippableFact] public void IterativeScan_IsSupportedByTheInstalledPgvector() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_iterative"); // SearchAsync pose ce paramètre à chaque recherche. Il n'existe qu'à partir de // pgvector 0.8 : sur une version antérieure, la recherche échouerait en // production sur « unrecognized configuration parameter ». var exception = Record.Exception(() => db.Database.ExecuteSqlRaw("SET hnsw.iterative_scan = relaxed_order")); Assert.Null(exception); } private const string FilteredNearestQuery = "SELECT 1 FROM \"ContentEmbeddings\" WHERE \"InstanceId\" = 'target' " + "ORDER BY \"Embedding\" <=> $1 LIMIT 25"; private static NpgsqlCommand CountFilteredNearest(NpgsqlConnection connection) { var command = new NpgsqlCommand($"SELECT count(*) FROM ({FilteredNearestQuery}) s", connection); // La question vise l'axe saturé par l'AUTRE instance : le pire cas, celui où // toutes les lignes de « target » sont derrière toutes celles de « noisy ». command.Parameters.Add(new NpgsqlParameter { Value = new Vector(new FakeEmbeddingService().Embed("axis:0")) }); return command; } /// /// Ce qui protège réellement du post-filtrage, ce n'est pas le parcours itératif : /// c'est l'index sur (InstanceId, ContentType). Le planificateur filtre par /// instance d'abord et trie exactement — l'index HNSW n'est pas touché, donc il n'y /// a rien à post-filtrer. Vérifié aussi à 22 000 lignes hors suite. /// [SkippableFact] public void InstanceIndex_KeepsThePlannerAwayFromHnsw() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_plan", forceIndexScan: true); SeedTwoInstances(db); using var connection = _postgres.OpenRawConnection("vs_plan"); using var command = new NpgsqlCommand($"EXPLAIN (COSTS OFF) {FilteredNearestQuery}", connection); command.Parameters.Add(new NpgsqlParameter { Value = new Vector(new FakeEmbeddingService().Embed("axis:0")) }); var plan = ""; using (var reader = command.ExecuteReader()) while (reader.Read()) plan += reader.GetString(0) + "\n"; Assert.Contains("IX_ContentEmbeddings_InstanceId_ContentType", plan); Assert.DoesNotContain("IX_ContentEmbeddings_Embedding", plan); } /// /// Et voici ce qui arriverait si cet index disparaissait : le WHERE s'applique /// APRÈS le parcours HNSW, les voisins ramenés appartiennent tous à « noisy », et /// le filtre les jette tous. Les 20 lignes du client existent, la recherche en /// rend zéro, et rien ne signale l'anomalie. /// /// /// ⚠️ Le second volet est le résultat inattendu de ce chantier : /// hnsw.iterative_scan = relaxed_order — le garde-fou que pose /// SearchAsyncne rattrape rien ici. Le parcours s'épuise après /// ~335 lignes (Rows Removed by Filter à l'EXPLAIN ANALYZE) sans avoir /// atteint l'instance minoritaire, parce que le graphe est construit au fil des /// insertions et non en bloc — la migration crée l'index avant qu'il n'y ait des /// lignes. Le même jeu de données, index HNSW construit après l'insertion, rend /// bien les 20 lignes : c'est donc la connectivité du graphe qui décide, pas le /// réglage. La protection réelle est l'index sur (InstanceId, ContentType), que le /// test précédent verrouille. Ce test-ci fige le comportement d'aujourd'hui pour /// qu'un changement de version de pgvector se voie. /// [SkippableFact] public void WithoutTheInstanceIndex_PostFilteringLosesEverything() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_postfilter", forceIndexScan: true); SeedTwoInstances(db); using var connection = _postgres.OpenRawConnection("vs_postfilter"); using (var drop = new NpgsqlCommand("DROP INDEX \"IX_ContentEmbeddings_InstanceId_ContentType\"", connection)) drop.ExecuteNonQuery(); using (var off = new NpgsqlCommand("SET hnsw.iterative_scan = off", connection)) off.ExecuteNonQuery(); using (var command = CountFilteredNearest(connection)) Assert.Equal(0, Convert.ToInt32(command.ExecuteScalar())); using (var on = new NpgsqlCommand("SET hnsw.iterative_scan = relaxed_order", connection)) on.ExecuteNonQuery(); using (var command = CountFilteredNearest(connection)) Assert.Equal(0, Convert.ToInt32(command.ExecuteScalar())); } [SkippableFact] public async Task Search_WithTwoInstances_StillReturnsTheMinorityInstance() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_postfilter_on", forceIndexScan: true); SeedTwoInstances(db); // Exactement le cas que le test précédent met en échec : la question vise // l'axe saturé par l'autre instance. C'est ce que SearchAsync doit rattraper. var results = await BuildService(db).SearchAsync("target", null, "fr", "axis:0"); Assert.Equal(5, results.Count); // Et jamais une ligne de l'autre client, quoi qu'en dise la distance. Assert.All(results, r => Assert.StartsWith("target-", r.ContentId)); } [SkippableFact] public async Task Search_HonoursTheConfiguredTopK() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_topk", forceIndexScan: true); SeedTwoInstances(db); var results = await BuildService(db).SearchAsync("target", null, "fr", "axis:0", topK: 12); Assert.Equal(12, results.Count); Assert.All(results, r => Assert.StartsWith("target-", r.ContentId)); } [SkippableFact] public async Task Search_DeduplicatesIdenticalTexts() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_dedup"); var embeddings = new FakeEmbeddingService(); // Le même paragraphe recopié sur plusieurs contenus — la description d'un lieu // reprise sur chacun de ses événements. for (var i = 0; i < 6; i++) db.ContentEmbeddings.Add(Row(embeddings, "solo", $"c-{i}", 0, "axis:3")); db.ContentEmbeddings.Add(Row(embeddings, "solo", "autre", 0, "axis:4")); db.SaveChanges(); var results = await BuildService(db).SearchAsync("solo", null, "fr", "axis:3"); Assert.Equal(2, results.Count); Assert.Equal(results.Select(r => r.Text).Distinct().Count(), results.Count); } [SkippableFact] public async Task ReplaceAsync_PurgesTheFormerChunks() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_replace"); var service = BuildService(db); await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[] { new ContentChunk("axis:1", 0, null, "fr"), new ContentChunk("axis:2", 1, null, "fr"), new ContentChunk("axis:3", 2, null, "fr") }); // Une version plus courte : sans purge, les morceaux 1 et 2 resteraient et // continueraient d'alimenter les réponses avec du contenu supprimé. await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[] { new ContentChunk("axis:1", 0, null, "fr") }); Assert.Equal(1, db.ContentEmbeddings.Count(e => e.ContentId == "sect-1")); } [SkippableFact] public async Task ReplaceAsync_EmptyChunkList_PurgesWithoutLeavingGhosts() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_purge"); var service = BuildService(db); await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[] { new ContentChunk("axis:1", 0, null, "fr") }); await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, Array.Empty()); Assert.Empty(db.ContentEmbeddings.Where(e => e.ContentId == "sect-1").ToList()); } [SkippableFact] public async Task ReplaceAsync_IsIdempotent_AgainstTheUniqueIndex() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("vs_idempotent"); var service = BuildService(db); var chunks = new[] { new ContentChunk("axis:1", 0, null, "fr"), new ContentChunk("axis:2", 1, null, "fr") }; // Hangfire rejoue les jobs : un second passage ne doit pas dupliquer les // morceaux, qui remonteraient ensuite deux fois dans chaque réponse. await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, chunks); await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, chunks); Assert.Equal(2, db.ContentEmbeddings.Count(e => e.ContentId == "sect-1")); } } }