Le vector store est enfin eprouve a DEUX instances, sur un vrai Postgres

Le RAG n'avait tourne que sur une base a une seule instance -- le cas ou le
post-filtrage HNSW ne se voit pas. 14 tests Testcontainers sur l'image de
`Deployment/Dockerfile.postgres`, donc le meme PostGIS + pgvector que la
prod. Ils se sautent proprement (SkippableFact) sans demon Docker.

Ce que ca etablit :

- pgvector 0.8.6 : `SET hnsw.iterative_scan` est accepte. Ce SET est pose a
  chaque recherche par SearchAsync et n'existe qu'a partir de 0.8 -- sur une
  version anterieure, toute recherche echouait en production.
- Les extensions vector et postgis sont bien creees par les migrations.
- A deux instances, l'une saturant l'axe de la question a 30 contre 1,
  SearchAsync rend le bon nombre de resultats, tous de la bonne instance.
  Aucune fuite d'un client vers un autre.

⚠️ DEUX RESULTATS INATTENDUS, contraires a ce que le plan supposait.

1. Ce qui protege du post-filtrage n'est PAS le parcours iteratif, c'est
   l'index sur (InstanceId, ContentType). Le planificateur filtre par
   instance d'abord et trie exactement : l'index HNSW n'est jamais touche,
   donc il n'y a rien a post-filtrer. Verifie a 620 lignes (test) et a
   22 000 hors suite, avec 2 000 lignes pour l'instance cible. Un test fige
   ce plan -- si cet index disparaissait, la recherche se degraderait sans
   qu'aucune erreur ne le dise.

2. Et si on retire cet index, `hnsw.iterative_scan = relaxed_order` NE
   RATTRAPE RIEN : le parcours s'epuise apres ~335 lignes sans avoir atteint
   l'instance minoritaire, et rend zero. Le meme jeu de donnees avec l'index
   HNSW construit APRES l'insertion rend bien ses 20 lignes -- c'est donc la
   connectivite du graphe qui decide, et nos migrations creent l'index sur
   une table vide. Le SET de SearchAsync est donc une assurance qui ne couvre
   pas ce qu'on croyait ; on le garde (il est correct et sans cout), mais
   c'est l'index qui fait le travail.

Deux notes d'outillage :

- Testcontainers est epingle en 3.10.0 : la 4.x parle l'API Docker 1.44 et
  l'engine local plafonne a 1.43.
- L'image est construite par le CLI docker, pas par le constructeur d'images
  de Testcontainers : celui-ci relit le FROM pour pre-tirer l'image de base
  et ne sait pas parser `tag@sha256:`. Ce digest protege la base d'un
  changement de glibc sous ses index -- il ne se retire pas pour un test.

S'ajoutent 4 tests de GetSummary contre le vrai Postgres, qui levent le
caveat du commit precedent : le provider InMemory evalue tout cote client,
donc il ne disait rien de la traduction SQL. Les agregats du plan de base,
ceux du plan avance, le filtre appType et la fenetre vide traduisent tous.

dotnet test 200/200, aucun saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-12 15:34:56 +02:00
parent a3bf9523e3
commit 2b1b20cd49
5 changed files with 747 additions and 0 deletions

View File

@ -0,0 +1,197 @@
using Manager.DTOs;
using ManagerService.Controllers;
using ManagerService.Data;
using ManagerService.DTOs;
using ManagerService.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
namespace ManagerService.Tests.Controllers
{
/// <summary>
/// GetSummary agrège désormais en SQL. Les tests InMemory tiennent la sémantique mais
/// pas la traduction : le provider InMemory évalue tout côté client, donc une requête
/// intraduisible y passerait au vert et exploserait en production. Ceux-ci s'exécutent
/// contre un vrai PostgreSQL.
/// </summary>
[Collection(PostgresCollection.Name)]
public class StatsControllerPostgresTests
{
private readonly PostgresFixture _postgres;
public StatsControllerPostgresTests(PostgresFixture postgres)
{
_postgres = postgres;
}
private static StatsController BuildController(MyInfoMateDbContext db)
{
var controller = new StatsController(db);
FakeUser.SetUser(controller, FakeUser.Create(Permissions.SuperAdmin, "i1"));
return controller;
}
private static void Seed(MyInfoMateDbContext db, bool advanced)
{
db.Instances.Add(new Instance
{
Id = "i1",
Name = "Musée de test",
HasStats = true,
HasAdvancedStats = advanced,
StatsHistoryDays = MyInfoMateDbContext.StatsRetentionDays
});
var section = TestSection.Article("sect-a", "i1", "A", "c1");
section.Title = new List<TranslationDTO> { new TranslationDTO { language = "fr", value = "La cave" } };
db.Sections.Add(section);
var day = DateTime.UtcNow.AddDays(-2);
var events = new List<VisitEvent>
{
Ev("e1", VisitEventType.SectionView, "s1", day, sectionId: "sect-a", language: "fr", appType: AppType.Mobile),
Ev("e2", VisitEventType.SectionView, "s2", day.AddHours(1), sectionId: "sect-a", language: "nl", appType: AppType.Tablet),
Ev("e3", VisitEventType.SectionLeave, "s1", day.AddMinutes(5), sectionId: "sect-a", durationSeconds: 30),
// s2 est une session tablette de bout en bout : un appType qui change en
// cours de session n'existe pas, et l'y laisser fausserait la distribution.
Ev("e4", VisitEventType.SectionLeave, "s2", day.AddHours(1).AddMinutes(5), sectionId: "sect-a", durationSeconds: 10, appType: AppType.Tablet),
Ev("e5", VisitEventType.ArticleRead, "s1", day, sectionId: "sect-a"),
Ev("e6", VisitEventType.QrScan, "s1", day, metadata: "{\"valid\":true}"),
Ev("e7", VisitEventType.MapPoiTap, "s1", day, sectionId: "sect-a", metadata: "{\"geoPointId\":7,\"geoPointTitle\":\"Ruche\"}"),
Ev("e8", VisitEventType.AgendaEventTap, "s2", day, metadata: "{\"eventId\":\"ev1\",\"eventTitle\":\"Concert\"}", appType: AppType.Tablet),
Ev("e9", VisitEventType.GameComplete, "s1", day, metadata: "{\"gameType\":\"Puzzle\",\"durationSeconds\":60}"),
Ev("e10", VisitEventType.MenuItemTap, "s2", day, metadata: "{\"targetSectionId\":\"sect-a\",\"menuItemTitle\":\"Accueil\"}", appType: AppType.Tablet),
Ev("e11", VisitEventType.QuizComplete, "s1", day, sectionId: "sect-a", metadata: "{\"score\":4,\"totalQuestions\":5}")
};
db.VisitEvents.AddRange(events);
db.SaveChanges();
}
private static VisitEvent Ev(string id, VisitEventType type, string sessionId, DateTime timestamp,
string sectionId = null, string metadata = null, string language = null,
AppType appType = AppType.Mobile, int? durationSeconds = null) =>
new VisitEvent
{
Id = id,
InstanceId = "i1",
SessionId = sessionId,
EventType = type,
SectionId = sectionId,
Metadata = metadata,
Language = language,
AppType = appType,
DurationSeconds = durationSeconds,
Timestamp = timestamp
};
private static StatsSummaryDTO Summarize(MyInfoMateDbContext db)
{
var result = BuildController(db).GetSummary("i1", null, null, null);
// Le contrôleur enveloppe tout dans un try/catch qui rend 500 : une requête
// intraduisible ne remonterait pas en exception mais en corps de réponse.
// On lit donc le code avant de caster, sinon l'échec serait illisible.
var objectResult = Assert.IsAssignableFrom<ObjectResult>(result);
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
return Assert.IsType<StatsSummaryDTO>(objectResult.Value);
}
[SkippableFact]
public void GetSummary_TranslatesToSql_OnTheBasicPlan()
{
Skip.IfNot(_postgres.Available, _postgres.SkipReason);
using var db = _postgres.CreateMigratedContext("stats_basic");
Seed(db, advanced: false);
var summary = Summarize(db);
Assert.Equal(2, summary.TotalSessions);
Assert.Equal(20, summary.AvgVisitDurationSeconds); // (30 + 10) / 2 sessions
var section = Assert.Single(summary.TopSections);
Assert.Equal("La cave", section.SectionTitle);
Assert.Equal(2, section.Views);
Assert.Equal(20, section.AvgDurationSeconds);
var day = Assert.Single(summary.VisitsByDay);
Assert.Equal(2, day.Total);
Assert.Equal(1, day.Mobile);
Assert.Equal(1, day.Tablet);
Assert.Equal(1, summary.AppTypeDistribution["Mobile"]);
Assert.Equal(1, summary.AppTypeDistribution["Tablet"]);
// Plan sans stats avancées : rien de tout ça n'a été calculé.
Assert.Empty(summary.LanguageDistribution);
Assert.Empty(summary.TopPois);
Assert.Equal(0, summary.QrScans.TotalScans);
}
[SkippableFact]
public void GetSummary_TranslatesToSql_OnTheAdvancedPlan()
{
Skip.IfNot(_postgres.Available, _postgres.SkipReason);
using var db = _postgres.CreateMigratedContext("stats_advanced");
Seed(db, advanced: true);
var summary = Summarize(db);
Assert.Equal(1, summary.LanguageDistribution["fr"]);
Assert.Equal(1, summary.LanguageDistribution["nl"]);
Assert.Equal("Ruche", Assert.Single(summary.TopPois).Title);
Assert.Equal("Concert", Assert.Single(summary.TopAgendaEvents).EventTitle);
Assert.Equal("Puzzle", Assert.Single(summary.GameStats).GameType);
Assert.Equal("Accueil", Assert.Single(summary.TopMenuItems).MenuItemTitle);
Assert.Equal(1, Assert.Single(summary.TopArticles).Reads);
Assert.Equal(1, summary.QrScans.TotalScans);
var quiz = Assert.Single(summary.QuizStats);
Assert.Equal("La cave", quiz.SectionTitle);
Assert.Equal(4.0, quiz.AvgScore);
}
[SkippableFact]
public void GetSummary_TranslatesToSql_WithAnAppTypeFilter()
{
Skip.IfNot(_postgres.Available, _postgres.SkipReason);
using var db = _postgres.CreateMigratedContext("stats_filtered");
Seed(db, advanced: true);
var result = BuildController(db).GetSummary("i1", null, null, "Tablet");
var objectResult = Assert.IsAssignableFrom<ObjectResult>(result);
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
var summary = Assert.IsType<StatsSummaryDTO>(objectResult.Value);
Assert.Equal(1, summary.TotalSessions);
Assert.Equal(1, summary.AppTypeDistribution["Tablet"]);
}
[SkippableFact]
public void GetSummary_OnAnEmptyWindow_TranslatesWithoutFailing()
{
Skip.IfNot(_postgres.Available, _postgres.SkipReason);
using var db = _postgres.CreateMigratedContext("stats_empty");
Seed(db, advanced: true);
// Fenêtre vide : les agrégats portent sur zéro ligne. C'est là qu'une moyenne
// SQL rend NULL et qu'un cast maladroit lèverait.
var result = BuildController(db).GetSummary("i1", DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, null);
var objectResult = Assert.IsAssignableFrom<ObjectResult>(result);
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
var summary = Assert.IsType<StatsSummaryDTO>(objectResult.Value);
Assert.Equal(0, summary.TotalSessions);
Assert.Equal(0, summary.AvgVisitDurationSeconds);
Assert.Empty(summary.TopSections);
}
}
}

View File

@ -0,0 +1,68 @@
using ManagerService.Data;
using ManagerService.Services;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace ManagerService.Tests.Infrastructure
{
/// <summary>
/// Embeddings déterministes, sans appel réseau. Le texte pilote directement la
/// direction du vecteur, ce qui permet d'écrire « ce morceau est proche de cette
/// question » comme un fait vérifiable plutôt que comme une supposition.
/// </summary>
/// <remarks>
/// Convention : un texte préfixé <c>axis:N</c> produit le vecteur unitaire de l'axe N,
/// éventuellement mêlé d'un peu de bruit stable (<c>axis:N#seed</c>). Deux textes sur
/// des axes différents sont donc quasi orthogonaux — distance cosinus proche de 1.
/// Ce qui suit les chiffres du seed est ignoré : cela permet d'écrire des textes
/// distincts qui partagent un même vecteur, sans quoi la déduplication par texte de
/// <c>SearchAsync</c> écraserait les lignes et fausserait tout comptage de résultats.
/// </remarks>
public class FakeEmbeddingService : IEmbeddingService
{
public int Dimensions => ContentEmbedding.Dimensions;
public Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken = default) =>
Task.FromResult(Embed(text));
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<float[]>>(texts.Select(Embed).ToList());
public float[] Embed(string text)
{
var vector = new float[Dimensions];
var axis = 0;
var seed = 0;
if (text != null && text.StartsWith("axis:"))
{
var payload = text.Substring("axis:".Length);
var hash = payload.IndexOf('#');
if (hash >= 0)
{
var digits = new string(payload.Substring(hash + 1).TakeWhile(char.IsDigit).ToArray());
int.TryParse(digits, out seed);
payload = payload.Substring(0, hash);
}
int.TryParse(payload, out axis);
}
else
{
axis = Math.Abs((text ?? "").GetHashCode()) % Dimensions;
}
vector[axis % Dimensions] = 1f;
// Bruit stable : deux morceaux du même axe ne sont pas strictement identiques,
// sinon HNSW n'aurait aucun ordre à produire entre eux.
if (seed != 0)
vector[(axis + 1 + (seed % 16)) % Dimensions] = 0.01f * (seed % 10);
return vector;
}
}
}

View File

@ -0,0 +1,168 @@
using DotNet.Testcontainers.Builders;
using ManagerService.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Testcontainers.PostgreSql;
using Xunit;
namespace ManagerService.Tests.Infrastructure
{
/// <summary>
/// Un vrai PostgreSQL, construit depuis <c>Deployment/Dockerfile.postgres</c> —
/// donc le même PostGIS + pgvector que la production, à l'image épinglée près.
/// </summary>
/// <remarks>
/// Le vector store ne peut pas être éprouvé sur EF InMemory : le type <c>vector</c>,
/// l'index HNSW et le post-filtrage n'y existent pas, et le modèle exclut d'ailleurs
/// explicitement <c>ContentEmbedding</c> hors Npgsql.
///
/// Sans démon Docker, la fixture ne lève pas : <see cref="Available"/> passe à false
/// et les tests se sautent. La suite reste verte sur une machine sans Docker, comme
/// <c>MigrationDryRunTests</c> le fait déjà sans Mongo.
/// </remarks>
public class PostgresFixture : IAsyncLifetime
{
private const string ImageTag = "myinfomate-postgres-tests:latest";
private PostgreSqlContainer _container;
public bool Available { get; private set; }
public string SkipReason { get; private set; }
public string ConnectionString { get; private set; }
public async Task InitializeAsync()
{
try
{
BuildImage();
_container = new PostgreSqlBuilder()
.WithImage(ImageTag)
.WithDatabase("my_info_mate")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
await _container.StartAsync();
ConnectionString = _container.GetConnectionString();
Available = true;
}
catch (Exception ex)
{
SkipReason = $"Docker indisponible : {ex.Message}";
Available = false;
}
}
/// <summary>
/// Construit l'image via le CLI docker plutôt que par le constructeur d'images de
/// Testcontainers : celui-ci relit le <c>FROM</c> pour pré-tirer l'image de base et
/// ne sait pas parser la forme <c>tag@sha256:…</c>. Or ce digest est justement ce
/// qui protège la base d'un changement de glibc sous ses index — il n'est pas
/// négociable pour arranger un test.
/// </summary>
private static void BuildImage()
{
var dockerfileDirectory = Path.Combine(
CommonDirectoryPath.GetSolutionDirectory().DirectoryPath, "ManagerService", "Deployment");
var process = Process.Start(new ProcessStartInfo("docker")
{
ArgumentList = { "build", "-f", "Dockerfile.postgres", "-t", ImageTag, "." },
WorkingDirectory = dockerfileDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true
});
process.WaitForExit((int)TimeSpan.FromMinutes(10).TotalMilliseconds);
if (process.ExitCode != 0)
throw new InvalidOperationException(
$"docker build a échoué :\n{process.StandardError.ReadToEnd()}");
}
public async Task DisposeAsync()
{
if (_container != null)
await _container.DisposeAsync();
}
/// <summary>
/// Contexte sur une base neuve, migrations appliquées. Les extensions PostGIS et
/// pgvector sont créées par les migrations, pas par l'image : un CREATE EXTENSION
/// manuel masquerait justement l'oubli qu'on veut voir.
/// </summary>
public MyInfoMateDbContext CreateContext(string databaseName)
{
var builder = new NpgsqlConnectionStringBuilder(ConnectionString) { Database = databaseName };
var dataSourceBuilder = new NpgsqlDataSourceBuilder(builder.ConnectionString);
dataSourceBuilder.UseNetTopologySuite();
dataSourceBuilder.UseVector();
dataSourceBuilder.EnableDynamicJson();
var options = new DbContextOptionsBuilder<MyInfoMateDbContext>()
.UseNpgsql(dataSourceBuilder.Build(), o => o.UseNetTopologySuite().UseVector())
.Options;
return new MyInfoMateDbContext(options, new HttpContextAccessor());
}
/// <summary>Crée une base vide puis y applique les migrations.</summary>
/// <param name="forceIndexScan">
/// Coupe le parcours séquentiel pour la base entière. Sur les volumes d'un test,
/// un seq scan reste moins cher qu'un index HNSW, et le post-filtrage — ce qu'on
/// vient justement éprouver — resterait invisible. Le réglage est posé sur la base
/// et non sur la session : un SET par ExecuteSqlRaw serait effacé au retour de la
/// connexion au pool, et le test passerait au vert sans rien avoir prouvé.
/// </param>
public MyInfoMateDbContext CreateMigratedContext(string databaseName, bool forceIndexScan = false)
{
NpgsqlConnection.ClearAllPools();
using (var admin = new NpgsqlConnection(ConnectionString))
{
admin.Open();
using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{databaseName}\"", admin);
drop.ExecuteNonQuery();
using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin);
create.ExecuteNonQuery();
if (forceIndexScan)
{
using var tune = new NpgsqlCommand($"ALTER DATABASE \"{databaseName}\" SET enable_seqscan = off", admin);
tune.ExecuteNonQuery();
}
}
var db = CreateContext(databaseName);
db.Database.Migrate();
return db;
}
/// <summary>Connexion brute sur une base du conteneur, hors EF.</summary>
public NpgsqlConnection OpenRawConnection(string databaseName)
{
var builder = new NpgsqlConnectionStringBuilder(ConnectionString) { Database = databaseName };
var dataSourceBuilder = new NpgsqlDataSourceBuilder(builder.ConnectionString);
dataSourceBuilder.UseVector();
var connection = dataSourceBuilder.Build().OpenConnection();
return connection;
}
}
[CollectionDefinition(Name)]
public class PostgresCollection : ICollectionFixture<PostgresFixture>
{
public const string Name = "postgres";
}
}

View File

@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.10.0" />
<PackageReference Include="xunit" Version="2.9.0" /> <PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7"> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -24,6 +25,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.3" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.3" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.3" />
<PackageReference Include="MongoDB.Driver" Version="2.19.0" /> <PackageReference Include="MongoDB.Driver" Version="2.19.0" />
<PackageReference Include="Xunit.SkippableFact" Version="1.5.61" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -0,0 +1,312 @@
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
{
/// <summary>
/// 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.
/// </summary>
[Collection(PostgresCollection.Name)]
public class VectorStoreServiceTests
{
/// <summary>
/// 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 <c>hnsw.ef_search</c>
/// (40 par défaut) si jamais l'index HNSW était parcouru avant le filtre.
/// </summary>
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());
/// <summary>
/// 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.
/// </summary>
private static void SeedTwoInstances(MyInfoMateDbContext db)
{
var embeddings = new FakeEmbeddingService();
var rows = new List<ContentEmbedding>();
// 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<string>();
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;
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// ⚠️ Le second volet est le résultat inattendu de ce chantier :
/// <c>hnsw.iterative_scan = relaxed_order</c> — le garde-fou que pose
/// <c>SearchAsync</c> — <b>ne rattrape rien ici</b>. Le parcours s'épuise après
/// ~335 lignes (<c>Rows Removed by Filter</c> à 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.
/// </remarks>
[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<ContentChunk>());
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"));
}
}
}