DOCS/v2/rag-pgvector-integration-plan.md
Thomas Fransolet a5a8ecdb20 Documentation interne MyInfoMate / Unov
Import initial de la documentation : statut, roadmap, plans V1/V2,
specs verticales (creche, sport), audits securite, plan de test,
analyse concurrentielle et maquettes de design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:17:01 +02:00

416 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# RAG + pgvector — Plan d'intégration backend .NET
> **Contexte** : Delta technique #1 issu de `myinfomate-ai-persona-analysis.md`.
> Stack retenue : **pgvector** (extension PostgreSQL) + **Google text-embedding-004** + **Hangfire** (background jobs).
---
## Décisions d'architecture
| Décision | Choix | Raison |
|---|---|---|
| Vector store | pgvector | Zéro infra ajoutée, EF Core natif, suffisant pour des volumes musée (< 100k vecteurs/venue) |
| Embedding model | Google, 768 dims | Même API key que Gemini, cohérent avec la stack actuelle |
| Ingestion | Hangfire background job | Hangfire déjà en place, le save CMS ne bloque pas |
| Extraction documents | PdfPig + fallback OCR Gemini | Pas de binaire natif dans l'image, ~0,03 $ par PDF de 100 pages scanné |
> ✅ **Modèle tranché le 2026-08-07 : `gemini-embedding-001`, tronqué à 768 dimensions.**
> Raisons : même clé API que Gemini (aucun fournisseur en plus), **multilingue** — décisif avec 10 langues dont CN/AR/UK, là où les modèles plus légers s'effondrent —, et troncature MRL qui laisse une porte de sortie vers 1536 si la qualité l'impose.
>
> ⚠️ **Après troncature, re-normaliser le vecteur en L2**, sinon les distances cosine sont fausses.
> ⚠️ Vérifier l'identifiant exact et les dimensions disponibles dans la doc Google au moment d'implémenter — c'est la seule décision irréversible du lot.
---
## 1. Infrastructure pgvector
### NuGet à ajouter
- `Pgvector` types de base
- `Pgvector.EntityFrameworkCore` intégration EF Core
### Migration EF Core — dual-persona (même migration)
Voir `tts-pregenerated-plan.md` pour le détail complet. Remplacer le champ `PersonaPrompt` unique par une liste `Personas` (max 2 en plan de base). L'endpoint `/ask` reçoit le `wakewordId` pour savoir quel persona utiliser :
```
POST /venues/{venueId}/ask
Body: { "question": "...", "wakewordId": "viva" }
```
### Migration EF Core — pgvector
Activer l'extension pgvector :
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### Entité
> **1 contenu ≠ 1 vecteur.** Une première version de ce plan supposait un vecteur par contenu. Ça ne tient pas dès qu'on ingère des documents : un PDF de 100 pages produit ~150 chunks, un article long en produit plusieurs. Le modèle est donc **1 contenu = N chunks**, et `ContentId` n'est plus une clé unique.
```csharp
public class ContentEmbedding
{
public int Id { get; set; }
public int VenueId { get; set; }
public string ContentId { get; set; } // Section.Id ou Resource.Id
public string ContentType { get; set; } // "section" | "resource"
public int ChunkIndex { get; set; } // ordre du chunk dans le contenu source
public int? PageNumber { get; set; } // null hors documents paginés — sert à citer la source
public string Language { get; set; } // langue du chunk (FR, NL, EN...)
public string Text { get; set; } // texte source embedé
public Vector Embedding { get; set; } // 768 dims
public DateTime UpdatedAt { get; set; }
}
```
### Index dans la migration
```csharp
entity.HasIndex(e => e.Embedding)
.HasMethod("hnsw")
.HasOperators("vector_cosine_ops");
entity.HasIndex(e => new { e.VenueId, e.ContentType });
entity.HasIndex(e => new { e.ContentType, e.ContentId, e.ChunkIndex }).IsUnique();
```
L'index unique sur `(ContentType, ContentId, ChunkIndex)` n'est pas décoratif : Hangfire peut rejouer un job (retry, redémarrage du conteneur en plein traitement). Sans lui, un double passage duplique silencieusement tous les chunks d'un document, qui remontent ensuite deux fois dans les résultats. Avec, la deuxième exécution échoue bruyamment.
### Stratégie d'upsert : delete-then-insert, dans une transaction
Un `UPDATE` chunk par chunk ne marche pas, parce que le **nombre de chunks change** entre deux versions d'un document. Si un PDF de 100 pages est réuploadé en version 40 pages, un update laisse 60 chunks fantômes qui continueront d'alimenter les réponses du guide avec du contenu supprimé.
```
BEGIN
DELETE FROM "ContentEmbeddings" WHERE "ContentType" = @type AND "ContentId" = @id
INSERT ... (tous les nouveaux chunks)
COMMIT
```
### Filtrage par venue et index HNSW — piège à connaître
pgvector applique le `WHERE "VenueId" = @venueId` **après** le parcours de l'index HNSW (post-filtering). Concrètement : l'index remonte ses meilleurs candidats tous venues confondues, puis le filtre en jette la majeure partie et on peut se retrouver avec 1 résultat au lieu des 5 demandés, alors que la base en contient largement assez.
Avec 10+ venues dans la même table, le problème est réel. Deux parades :
- monter `hnsw.ef_search` (défaut 40 100-200) pour élargir le parcours
- utiliser les **iterative index scans** de pgvector 0.8+ (`hnsw.iterative_scan = relaxed_order`), qui relancent le parcours jusqu'à obtenir assez de résultats après filtrage c'est la vraie solution
À valider par un test avec du volume représentatif avant la mise en prod, pas au doigt mouillé.
> ✅ **Livré** : `SET LOCAL hnsw.iterative_scan = relaxed_order` dans `SearchAsync`, plus un sur-échantillonnage (`topK × 5`, minimum 25 candidats) parce que le reclassement par bonus se fait en mémoire.
### `topK` — réglable sans redéploiement
`AI:SearchTopK` dans `appsettings.json`, **5 par défaut**. Le paramètre `topK` de `SearchAsync` reste surchargeable par appelant (`null` = valeur de config).
5 suffit à l'usage réel : les questions d'un visiteur portent sur un contenu précis c'est quoi ce tableau ? »), pas sur un inventaire. Monter la valeur si des réponses s'avèrent tronquées sur du volume réel ça se règle au même moment que `hnsw.iterative_scan`, sur le même test de charge.
**Ce n'est pas le remède aux questions d'agrégation** combien d'expos ? », « tous les events du weekend »). La recherche par similarité ne compte pas, elle ressemble : à 50 chunks la réponse serait toujours fausse. Ces questions passent par les outils déterministes d'`AssistantService` voir §4.
### Multilingue — la recherche ne filtre PAS par langue
> ⚠️ **Correction du 2026-08-07.** Une version précédente de ce plan proposait de filtrer la recherche sur la langue du visiteur. **C'est faux et ça casse un cas réel** : un client uploade une brochure en français uniquement, un visiteur néerlandophone pose sa question — avec un filtre par langue, on ne trouve jamais rien.
Le modèle d'embedding retenu est multilingue : il place « château fort » et « kasteel » quasiment au même endroit dans l'espace vectoriel. Une question en NL retrouve donc un chunk en FR. C'est de la **recherche cross-lingue**, native au modèle.
| Étape | Règle |
|---|---|
| Indexation | Chaque contenu dans **sa** langue, champ `Language` renseigné |
| Recherche | **Aucun filtre de langue** on balaie tout le venue |
| Classement | **Bonus de score** aux chunks dans la langue du visiteur : la version NL passe devant si elle existe, sinon on prend la FR |
| Génération | **Toujours dans la langue du visiteur** instruction explicite dans le prompt, quelle que soit la langue des chunks |
La traduction se fait donc **implicitement à la rédaction**, pas en appel séparé : aucun coût ni latence supplémentaires.
> Le contenu CMS existe en `List<TranslationDTO>` (réellement traduit), les documents uploadés n'existent souvent qu'en une seule langue. Les deux cas passent par le même mécanisme — c'est le bonus de score qui privilégie la traduction quand elle existe.
### Ce qui est stocké, et comment on retrouve l'information
Rappel utile : **le vecteur n'est qu'une coordonnée de recherche, il n'est pas réversible.** On ne reconstruit rien à partir de lui.
```
contenu → chunks → { Text, Embedding, Language, PageNumber, ContentId } ← ligne complète
question → vecteur → voisins les plus proches → on lit les LIGNES
→ Gemini reçoit le Text + la question + le persona
```
C'est `ContentEmbedding.Text` qui porte l'information réelle et qui est injecté dans le prompt. L'embedding ne sert qu'à trouver la bonne ligne.
---
## 2. Interfaces
> ⚠️ **Corrigé le 2026-08-09 d'après le code réel.** Ce plan parlait de `int venueId` : ça n'existe pas.
> `Instance.Id`, `Configuration.Id`, `Section.Id` et `Resource.Id` sont tous des **`string`** (héritage ObjectId Mongo).
> `ContentEmbedding` porte donc `string InstanceId` (filtre dur) et `string? ConfigurationId` (nullable — `Resource` n'en a pas ; sert de bonus de score, jamais de filtre).
```csharp
// ✅ Livré le 2026-08-09 — Services/IEmbeddingService.cs + GoogleEmbeddingService.cs
public interface IEmbeddingService
{
int Dimensions { get; }
Task<float[]> EmbedAsync(string text, CancellationToken ct = default);
Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default);
}
public interface IVectorStoreService
{
Task ReplaceAsync(string instanceId, string configurationId, string contentId,
ContentSourceType contentType, IReadOnlyList<ContentChunk> chunks);
// currentConfigurationId ne filtre pas : il sert à bonifier le score des chunks
// de la visite en cours. Le filtre dur porte sur instanceId seul.
Task<List<VectorSearchResult>> SearchAsync(string instanceId, string currentConfigurationId,
string query, int topK = 5);
Task DeleteAsync(string contentId, ContentSourceType contentType);
}
public record ContentChunk(string Text, int ChunkIndex, int? PageNumber, string Language);
public record VectorSearchResult(
string ContentId, string ContentType, string Text,
int? PageNumber, string Language, double Score);
```
- `EmbeddingService` : appelle l'API d'embedding Google via `HttpClient` ou `Microsoft.Extensions.AI` (déjà référencé). **Prévoir le batch** : un PDF de 100 pages fait ~150 chunks, soit 150 allers-retours HTTP séquentiels si on n'embed qu'un texte à la fois.
- `VectorStoreService` : écrit/lit `ContentEmbedding` via EF Core, recherche cosine avec `EF.Functions.CosineDistance`. `UpsertAsync` est renommé `ReplaceAsync` le nom dit la sémantique réelle (voir stratégie delete-then-insert ci-dessus).
### Extraction : une abstraction par format
Le pipeline se scinde en deux moitiés : **extraire du texte** (dépend du format) puis **chunker + embedder + stocker** (identique pour tout). Normaliser vers `ExtractedPage` le plus tôt possible rend l'ajout d'un format quasi gratuit.
```csharp
public interface IDocumentTextExtractor
{
bool CanHandle(ResourceType type, string fileName);
Task<IReadOnlyList<ExtractedPage>> ExtractAsync(Stream stream, CancellationToken ct);
}
public record ExtractedPage(int PageNumber, string Text, bool NeedsOcr);
```
`NeedsOcr = true` quand l'extracteur rend une page (quasi) vide c'est le signal qui déclenche le fallback Gemini sur cette page uniquement.
---
## 3. Pipeline d'ingestion Hangfire
Dans `SectionController` et `ResourceController`, après chaque `SaveChanges()` :
```csharp
// Create ou update — on passe un ID, jamais un payload
_backgroundJobClient.Enqueue<IIngestionService>(
s => s.IngestSectionAsync(section.Id));
// Delete
_backgroundJobClient.Enqueue<IVectorStoreService>(
s => s.DeleteAsync(section.Id.ToString(), "section"));
```
**Passer l'ID, pas le texte.** Hangfire sérialise les arguments de job en JSON dans sa table Postgres : envoyer le contenu extrait en argument stocke le document entier dans la file d'attente, à chaque retry. Le job recharge l'entité lui-même.
`GetEmbeddableText()` = méthode sur l'entité qui concatène titre + description + champs pertinents selon le type de section. À définir par type (SectionMap, SectionEvent, etc.).
### Queue dédiée et bridée
L'ingestion de documents n'a rien à faire dans la file générale : `AddHangfireServer()` lance par défaut `min(nbCPU × 5, 20)` workers, et 20 extractions de PDF simultanées saturent la RAM du conteneur (Hangfire tourne in-process avec l'API).
```csharp
services.AddHangfireServer(options => {
options.Queues = new[] { "ingestion" };
options.WorkerCount = 2;
});
```
Sur le job : `[Queue("ingestion")]` + `[AutomaticRetry(Attempts = 2)]`. Le défaut de Hangfire est à **10 tentatives**, ce qui sur un document illisible facture 10 passes d'OCR Gemini.
### ⚠️ N'indexer que les instances qui ont droit à l'IA (ajouté le 2026-08-10)
**Rien de tout ça n'était écrit nulle part** ni ici, ni dans `guide-ia-screen-plan.md`, qui ne traite que le quota *au runtime* (`AiController`).
`plan-starter` a `AiTokensPerMonth = 0` (seed dans `MyInfoMateDbContext`). Sans garde, un client Starter qui édite ses sections déclenche un job d'embedding **à chaque save**, facturé à Unov, pour un guide auquel il n'a pas droit. L'indexation est silencieuse : personne ne s'en aperçoit avant la facture Google.
| Cas | Règle |
|---|---|
| **Enqueue** | Garde `AiTokensPerMonth > 0` avant chaque `Enqueue`. Pas de nouvelle colonne l'info est déjà sur `Instance` |
| **Upgrade** | Job de **backfill** : le client passe Starter Essentiel avec des centaines de sections jamais indexées. Sans rattrapage il paie et son guide ne connaît rien le ticket support garanti |
| **Downgrade** | **Garder** les embeddings, bloquer seulement `/ask`. Quelques milliers de vecteurs ne pèsent rien, et un ré-upgrade redevient instantané au lieu de relancer un backfill complet |
Le même verrou vaut pour l'UI : la case `IncludeInAiKnowledge` et le bloc « sources de connaissance » n'ont pas à s'afficher sur un plan sans IA.
---
## 3 bis. Formats de documents supportés
Tout converge vers le même pipeline dès que le texte est extrait. Seul l'extracteur change.
| Format | `ResourceType` | Extraction | Indexable | Remarque |
|---|---|---|---|---|
| **PDF** | `PDF` *(existe)* | PdfPig (`UglyToad.PdfPig`, Apache 2.0) fallback OCR Gemini | oui | **Ne pas utiliser iText7** : AGPL, incompatible SaaS propriétaire. Ne pas utiliser `page.Text` (mélange les colonnes) `ContentOrderTextExtractor` |
| **Image** (jpg/png/webp) | `Image` *(existe)* | Gemini vision directement, pas d'étape locale | oui | Panneau photographié, affiche, scan. `PageNumber = null` |
| **Word** (.docx) | `Word` **à ajouter** | `DocumentFormat.OpenXml` (MIT, Microsoft) | oui | Lit le XML, extrait les paragraphes. Aucune dépendance native |
| **PowerPoint** (.pptx) | `PowerPoint` **à ajouter** | `DocumentFormat.OpenXml` | oui | Même package, autre namespace. 1 slide = 1 « page » |
| **Texte brut** (.txt / .md) | `Text` **à ajouter** | lecture directe | oui | Aucune extraction, va droit au chunking |
| **Word/PPT legacy** (.doc / .ppt) | | | | **Refusés à l'upload** voir ci-dessous |
### Legacy Office : refusé à l'upload (décidé 2026-08-07)
`.doc` et `.ppt` sont des formats binaires pré-2007 qu'`OpenXml` ne sait pas lire. Les rendre indexables imposerait LibreOffice headless (`soffice --convert-to pdf`) dans l'image Docker : ~500 Mo d'image en plus et un sous-process à superviser, pour une poignée de fichiers.
**Décision : rejet à l'upload**, pas d'acceptation partielle. Un fichier stocké mais jamais indexé consomme du quota, encombre la médiathèque et génère un ticket support pourquoi mon document n'est pas pris en compte ? »). Autant refuser tôt, avec un message qui dit quoi faire :
> *« Format .doc non supporté. Ouvrez le fichier dans Word et enregistrez-le en .docx, ou exportez-le en PDF. »*
La conversion prend dix secondes au client et lui donne un fichier exploitable, plutôt qu'un document mort dans sa médiathèque. Le refus se fait à deux endroits : filtre du file picker côté manager-app (UX) **et** validation serveur (autorité).
### Modifications du modèle `Resource`
Rien de tout ça n'existe aujourd'hui `Resource` n'a ni nom de fichier, ni flag IA, ni statut d'indexation.
```csharp
public enum ResourceType
{
Image, Video, ImageUrl, VideoUrl, Audio, PDF, JSON, JSONUrl, // existants — NE PAS RÉORDONNER
Word, // 8
PowerPoint, // 9
Text // 10
}
```
> **Les valeurs sont persistées en `int` par EF Core.** Les nouvelles valeurs doivent être **ajoutées à la fin**. Insérer `Word` au milieu décalerait tous les types existants en base : les PDF deviendraient des JSON.
```csharp
// Nouvelles colonnes sur Resource
public string FileName { get; set; } // "brochure-2026.pdf" — nécessaire pour citer la source
public bool IncludeInAiKnowledge { get; set; } // défaut FALSE
public AiIndexStatus AiIndexStatus { get; set; } // NotIndexed | Pending | Indexed | Failed
public string AiIndexMessage { get; set; } // raison de l'échec, affichée telle quelle au client
public DateTime? AiIndexedAt { get; set; }
public int AiChunkCount { get; set; }
```
`FileName` manque aujourd'hui : `Label` est un libellé libre et `Url` est une URL Firebase à jeton, ni l'un ni l'autre ne donne le vrai nom de fichier. Sans lui, impossible de choisir l'extracteur sur l'extension, ni d'afficher « brochure-2026.pdf, p. 12 » dans les sources d'une réponse.
### `IncludeInAiKnowledge` — le point produit le plus important du chantier
**Défaut : `false`. Aucune exception.**
Un client va uploader son plan tarifaire interne, le planning de ses équipes, un dossier de subvention, un rapport d'incident. Si l'indexation est implicite, le guide les récitera au premier visiteur qui pose la bonne question et ce sera un incident de confidentialité chez le client, causé par notre défaut.
Côté manager-app, la case doit être libellée en termes d'effet, pas de mécanique : **« Le guide IA pourra citer ce document aux visiteurs »** pas « indexer dans le RAG ». Cocher la case déclenche le job ; la décocher purge les embeddings.
Le statut d'indexation doit être visible sur la ressource : le client uploade 200 pages, teste le guide 10 secondes après, ne trouve rien et conclut que c'est cassé. `Pending` / `Indexed (152 extraits)` / `Failed + raison`.
### Chaîne d'upload à modifier
Ajouter des types ne se limite pas à l'enum backend :
1. **`ResourceType`** + migration EF (colonnes ci-dessus)
2. **`ResourceDTO`** : exposer `fileName`, `includeInAiKnowledge`, `aiIndexStatus`, `aiIndexMessage`
3. **Validation à l'upload** : extension **et** type MIME. Aujourd'hui `ResourceController` fait confiance au champ `type` envoyé par le client sans vérifier le contenu réel du fichier
4. **manager-app** : liste `allowedExtensions` du file picker, icônes par type, case à cocher + badge de statut
5. **`manager_api_new`** : client généré à **éditer à la main** (pas de regénération cf. convention du projet)
> **Sécurité — .docx/.pptx sont des archives ZIP.** Un fichier de 2 Mo peut se décompresser en plusieurs Go (zip bomb). Plafonner la taille décompressée lue par `OpenXml` et le nombre d'entrées de l'archive, sinon un upload suffit à faire tomber le conteneur qui sert aussi l'API.
### Quota, stockage et compression → `media-storage-plan.md`
Ces sujets ont été sortis dans un doc dédié parce qu'ils sont **prérequis à la release de migration**, alors que le RAG vient après :
- `StorageQuotaBytes` n'est appliqué nulle part sur le chemin réel (`Create`) à corriger **avant** d'ouvrir les uploads documentaires, qui sont les fichiers les plus lourds du catalogue
- colonne `StoragePath` + `IStorageService` (les URL absolues Firebase sont aujourd'hui dispersées jusque dans le JSONB des sections)
- backfill des `SizeBytes` existants + inventaire des blobs orphelins, pendant la migration
- compression des images côté client (2560 px / q82, ~12× de gain)
**Dépendance directe** : l'ingestion documentaire suppose que `StoragePath` existe (le job télécharge le fichier depuis la clé, pas depuis une URL à jeton) et que le quota est appliqué.
**Sur les images** : ne pas indexer toutes les images d'une instance. La plupart sont des photos d'œuvres sans texte, et chacune coûterait un appel OCR pour ne rien produire. `IncludeInAiKnowledge` à `false` par défaut règle ça aussi.
> **Piste V2** — pour les images, l'OCR n'est pas le plus intéressant : demander à Gemini une *description* de l'image plutôt qu'une transcription rend le fonds iconographique cherchable en langage naturel (« montre-moi les tableaux avec des chevaux »). Même pipeline, autre prompt. À ne pas mélanger avec le V1.
---
## 4. Le RAG est un **outil de plus**, pas un endpoint concurrent
> ⚠️ **Corrigé le 2026-08-10 d'après le code réel.** Ce plan décrivait un endpoint `/ask` autonome — « embed la question → top-5 → prompt Gemini » — sans jamais mentionner `AssistantService`. Implémenté tel quel, on obtient **deux assistants concurrents** : l'un qui sait interroger l'agenda mais ignore le contenu, l'autre l'inverse.
`AssistantService.ChatAsync` fait déjà du **function calling** (`Microsoft.Extensions.AI`, `AIFunctionFactory.Create`), avec quatre outils métier `GetSectionDetail`, `GetUpcomingEvents` (paramètres `dateFrom`/`dateTo`), `GetMapPoints`, `GetItemDetails` plus `show_cards` et `navigate_to_section` côté UI. Le système calcule le weekend courant côté serveur et le passe en clair au modèle, et les prompts (4 variantes : instance/configuration × vocal/texte) portent des règles strictes déjà débuggées.
**Le RAG s'y branche comme un cinquième outil**, il ne remplace rien :
```csharp
AIFunctionFactory.Create(
async (string question) =>
{
var hits = await _vectorStore.SearchAsync(
request.InstanceId, request.ConfigurationId, request.Language, question);
if (hits.Count == 0) return "Aucun contenu ne traite de ce sujet.";
return string.Join("\n---\n", hits.Select(h => h.Text));
},
"SearchKnowledge",
"Cherche dans le contenu du lieu (descriptions, articles, documents) ce qui traite d'un sujet. " +
"À utiliser pour toute question de fond sur les œuvres, l'histoire ou les thématiques."
),
```
Le modèle arbitre alors seul : question factuelle datée `GetUpcomingEvents`, question narrative `SearchKnowledge`. C'est la répartition correcte **le SQL pour les faits, le RAG pour le fond**.
### À faire dans la même passe
- injecter `IVectorStoreService` dans `AssistantService`
- ajouter l'outil aux **quatre** prompts, pas au seul scope configuration
- une règle explicite : *« question de fond sur le contenu → appelle `SearchKnowledge`. Si l'outil ne rend rien, dis que l'information n'est pas disponible — n'invente pas. »* Sans elle, Gemini répondra depuis ses connaissances générales, et une réponse inventée sur un horaire est pire qu'un « je ne sais pas »
- le retour de l'outil ne porte pas les sources jusqu'à `AiChatResponse` : prévoir la remontée de `ContentId` + `PageNumber` (voir ci-dessous), le modèle ne doit pas les recopier lui-même la règle « ne mentionne jamais les identifiants techniques » existe déjà
Les chunks portent `ContentId` + `PageNumber`, ce qui permet de citer précisément brochure.pdf, p. 12 ») et de renvoyer vers la ressource ou la section d'origine. C'est ce qui rend les réponses vérifiables par le client quand il conteste ce que dit le guide.
---
## Ordre d'implémentation
Découpage par **coût de rattrapage**, pas par ordre logique :
> **Ce qui est gelé dans le schéma part avec la migration. Ce qui est du comportement attend.**
>
> Le risque n'a jamais été la table — c'est que le RAG est une *fonctionnalité* (intégration d'API externe, pipeline, endpoint, UI) qui repousserait la release si on la prenait en bloc. Une table vide ne dérange personne.
### Maintenant — avec la migration Postgres (quelques heures, essentiellement du schéma)
0. **Image Docker Postgres + pgvector** `postgis/postgis:16-3.4` n'embarque pas l'extension. À faire **tant que la base est vide** : sinon c'est une fenêtre de maintenance sur la base de prod pour quatre lignes de Dockerfile.
1. **Migration EF Core** : extension pgvector + entité `ContentEmbedding` (avec `ChunkIndex` / `PageNumber` / `Language`) + index HNSW + index unique
- grouper avec la colonne `PersonaConfig` sur la table venue (delta #2, trivial)
2. **Colonnes `Resource`** : nouveaux `ResourceType` (**ajoutés en fin d'enum**), `FileName`, `IncludeInAiKnowledge`, `AiIndexStatus`, `AiIndexMessage`, `AiChunkCount` plus `StoragePath` et `SizeBytes` (voir `media-storage-plan.md`)
3. **`IEmbeddingService`** + implémentation Google (batch dès le départ)
> **Pourquoi `IEmbeddingService` est dans ce lot alors que c'est du code, pas du schéma** : le modèle d'embedding détermine la **dimension du vecteur**, et cette dimension est coulée dans la migration (`vector(768)`). La décision est donc du schéma. Autant écrire le service maintenant (~50 lignes, un appel HTTP) et **vérifier que la dimension fonctionne vraiment avant de la geler** — sinon on la valide le jour où on découvre qu'elle est fausse, et c'est un re-embed complet plus une migration de colonne.
### Ensuite — incrément indépendant, à froid
4. **`IVectorStoreService`** + implémentation pgvector via EF Core (delete-then-insert transactionnel)
5. **Hangfire jobs sur le contenu CMS** branchés sur les saves existants dans `SectionController`, queue dédiée
6. **Endpoint `/ask`** dans un nouveau `AskController` (ou étendu dans `AssistantService`)
> À ce point le RAG est **fonctionnel sur le contenu CMS seul** — et c'est déjà la meilleure matière : les descriptions de POI et textes de sections sont structurés, multilingues et écrits *pour* des visiteurs. Les documents sont un complément, pas la source principale.
### Puis — ingestion documentaire
7. **Quota + `IStorageService`** (`media-storage-plan.md`) **prérequis** : le job télécharge depuis `StoragePath`, et les documents sont les fichiers les plus lourds du catalogue
8. **`IDocumentTextExtractor` + PDF** : PdfPig, chunking, statut d'indexation, validation extension+MIME, UI manager-app, client `manager_api_new` édité à la main
9. **Fallback OCR Gemini** sur les pages vides (API Gemini native en HTTP la couche de compatibilité OpenAI gère mal l'input document)
10. **Autres formats** : docx / pptx via `DocumentFormat.OpenXml` (avec garde anti-zip-bomb), txt / md en lecture directe, puis images
---
## Dépendances avec les autres deltas
| Delta | Lien |
|---|---|
| #2 Champ persona config | À faire dans la même migration que pgvector |
| #3 Endpoint `/ask` | Dépend de #1 (vector store) + #2 (persona) |
| #4 TTS pré-généré | Indépendant, peut se brancher sur le même trigger Hangfire au save POI |
| #5 Canal Flutter Ray-Ban | Consomme `/ask` dépend de #3 |
| Médias & stockage | `media-storage-plan.md` `StoragePath` et le quota sont prérequis à l'ingestion documentaire |