Thomas Fransolet 2b1b20cd49 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>
2026-08-12 15:34:56 +02:00

169 lines
6.7 KiB
C#

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";
}
}