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
/// SearchAsync — ne 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"));
}
}
}