Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
762 lines
33 KiB
Markdown
762 lines
33 KiB
Markdown
# Story: Embeddings par Fragments — Indexation Sémantique Multi-Niveaux
|
||
|
||
> **Epic:** Fondation IA — Recherche & Insights Sémantiques
|
||
> **ID:** US-CHUNK-EMBEDDINGS
|
||
> **Priority:** Critical — Fondation pour Workstreams B (AI Overview), Memory Echo précis, Insights
|
||
> **Status:** ready-for-dev
|
||
> **Depends on:** pgvector existant (✅), `embedding.service.ts` (✅), `NoteEmbedding` (✅)
|
||
> **Blocks:** US-AI-OVERVIEW, US-CALENDAR (indépendant), qualité Memory Echo et Insights
|
||
> **Inspiration:** AppFlowy `flowy-ai/src/embeddings/` — chunking + dedup par hash de contenu
|
||
|
||
---
|
||
|
||
## Contexte
|
||
|
||
### Problème actuel
|
||
|
||
Memento stocke **un seul vecteur par note** (`NoteEmbedding` dans `prisma/schema.prisma:366-374`). L'embedding est généré par `EmbeddingService.generateNoteEmbedding()` (`lib/ai/services/embedding.service.ts:43-63`) qui :
|
||
|
||
1. Concatène titre + corps en plain text
|
||
2. Découpe en chunks de 6000 chars (`splitPlainTextForEmbeddingChunks`)
|
||
3. Embed chaque chunk via l'API
|
||
4. **Mean-pool** les vecteurs en un seul vecteur moyen
|
||
|
||
Cette approche a trois limites majeures :
|
||
|
||
| Limite | Impact |
|
||
|--------|--------|
|
||
| **Perte de précision** — une note de 5000 mots sur 10 sujets différents a un vecteur moyen flou | Memory Echo rate les connexions spécifiques à une section |
|
||
| **Re-embed complet à chaque modif** — corriger une typo re-embedde toute la note | Coût API × nombre d'éditions ; latence inutile |
|
||
| **Pas de snippets de match** — la recherche retourne un score global mais pas le passage précis | L'utilisateur ne sait pas *pourquoi* une note match |
|
||
|
||
### Solution proposée
|
||
|
||
Inspiré d'AppFlowy (`flowy-ai/src/embeddings/document_indexer.rs`, `scheduler.rs`) :
|
||
|
||
1. **Découper chaque note en fragments sémantiques** (~1000 chars, coupure aux limites de paragraphes)
|
||
2. **Embedder chaque fragment indépendamment** dans une nouvelle table `NoteEmbeddingChunk`
|
||
3. **Hasher le contenu de chaque fragment** (`sha256`) pour ne re-embed que les fragments modifiés
|
||
4. **Rechercher au niveau fragment** avec agrégation par note → snippets précis en résultat
|
||
5. **Garder `NoteEmbedding` existant** pour rétro-compat (recherche globale, clustering)
|
||
|
||
### Comparaison AppFlowy → Memento
|
||
|
||
| Aspect | AppFlowy (Rust) | Memento (TypeScript) |
|
||
|--------|-----------------|---------------------|
|
||
| Chunking | `text_splitter` crate, 1000 chars / 200 overlap | `lib/text/note-chunking.ts`, même valeurs |
|
||
| Hash | `xxhash64` (Rust) | `crypto.createHash('sha256')` (Node natif) |
|
||
| Vector DB | SQLite-vec (local) | pgvector (PostgreSQL, déjà en prod) |
|
||
| Scheduler | Tokio channels (generate → write) | `p-queue` ou Bull (Redis déjà présent) |
|
||
| Dedup | Compare `fragment_id` en DB | Identique — `@@unique([noteId, fragmentId])` |
|
||
| Embedding model | `nomic-embed-text` (Ollama local) | `text-embedding-3-small` (OpenAI, déjà configuré) |
|
||
|
||
---
|
||
|
||
## Migration Prisma requise
|
||
|
||
```prisma
|
||
model NoteEmbeddingChunk {
|
||
id String @id @default(cuid())
|
||
noteId String
|
||
fragmentId String // sha256(noteId + content) — stable pour le dedup
|
||
chunkIndex Int // ordre du fragment dans la note
|
||
content String // texte plain du fragment (~1000 chars)
|
||
charCount Int // longueur du fragment (audit / debug)
|
||
embedding Unsupported("vector(1536)")?
|
||
embeddingModel String? @default("text-embedding-3-small")
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([noteId, fragmentId])
|
||
@@index([noteId])
|
||
@@index([fragmentId])
|
||
}
|
||
```
|
||
|
||
> ⚠️ **Migration additive uniquement** — safe, pas de perte de données. La table `NoteEmbedding` existante est conservée intacte.
|
||
|
||
### Étapes de migration
|
||
|
||
1. **Dump DB obligatoire** : `bash /home/devparsa/dev/Memento/dump-db.sh` — vérifier ≥1Mo — « OUI » explicite utilisateur
|
||
2. `npx prisma migrate dev --name add_note_embedding_chunks`
|
||
3. Créer l'index HNSW sur la colonne `embedding` :
|
||
```sql
|
||
CREATE INDEX "NoteEmbeddingChunk_embedding_hnsw_idx"
|
||
ON "NoteEmbeddingChunk"
|
||
USING hnsw ("embedding" vector_cosine_ops)
|
||
WITH (m = 16, ef_construction = 64);
|
||
```
|
||
> L'index HNSW doit être créé manuellement (Prisma ne gère pas les index vectoriels). À mettre dans la migration SQL brute.
|
||
|
||
---
|
||
|
||
## User Stories
|
||
|
||
### US-CHUNK-1 : Chunking sémantique d'une note
|
||
|
||
**En tant que** système d'indexation,
|
||
**Je veux** découper le contenu d'une note en fragments sémantiques cohérents,
|
||
**Afin de** produire des embeddings précis au niveau section plutôt qu'au niveau note entière.
|
||
|
||
#### Critères d'acceptation :
|
||
|
||
- **Étant donné** une note avec un titre et un contenu HTML/plain
|
||
- **Quand** le service de chunking est appelé
|
||
- **Alors** le contenu est converti en plain text (via `stripHtmlToPlainText` existant)
|
||
- **Et** découpé aux limites de paragraphes (`\n\n`) en groupes de ~1000 caractères max
|
||
- **Et** chaque fragment chevauche le précédent de ~200 caractères (préserve le contexte aux frontières)
|
||
- **Et** chaque fragment reçoit un `fragmentId` = `sha256(noteId + "::" + fragmentContent)` (stable, déterministe)
|
||
- **Et** si un paragraphe dépasse 1000 chars, il est sous-découpé à la phrase la plus proche (regex sur `. ! ? ؟ !。`)
|
||
- **Et** si le contenu total fait moins de 1000 chars, un seul fragment est produit
|
||
- **Et** les fragments vides (< 10 chars après trim) sont filtrés
|
||
|
||
#### Paramètres de chunking :
|
||
|
||
```typescript
|
||
const CHUNK_TARGET_CHARS = 1000 // taille cible par fragment
|
||
const CHUNK_OVERLAP_CHARS = 200 // chevauchement entre fragments consécutifs
|
||
const MIN_FRAGMENT_CHARS = 10 // en dessous, le fragment est ignoré
|
||
const MAX_PARAGRAPH_SPLIT = 1500 // au-dessus, un paragraphe est sous-découpé
|
||
```
|
||
|
||
> Ces valeurs sont alignées sur AppFlowy (`document_indexer.rs:31`) et la recherche RAG standard (medium chunks 256-1024 tokens).
|
||
|
||
---
|
||
|
||
### US-CHUNK-2 : Indexation incrémentale avec dedup
|
||
|
||
**En tant que** système,
|
||
**Je veux** ne re-embed que les fragments qui ont réellement changé,
|
||
**Afin de** réduire le coût API et la latence lors des éditions mineures.
|
||
|
||
#### Critères d'acceptation :
|
||
|
||
- **Étant donné** une note qui a déjà des fragments indexés en DB
|
||
- **Quand** l'utilisateur modifie un paragraphe et sauvegarde
|
||
- **Alors** le service :
|
||
1. Récupère les `fragmentId`s existants : `SELECT fragmentId FROM "NoteEmbeddingChunk" WHERE noteId = $1`
|
||
2. Chunk le contenu actuel → produit de nouveaux fragments avec leurs `fragmentId`s
|
||
3. Compare les hash :
|
||
- **Fragments inchangés** (hash identique) → **skip**, pas d'embed
|
||
- **Fragments nouveaux** (hash absent en DB) → **embed et insère**
|
||
- **Fragments supprimés** (hash en DB mais absent des nouveaux) → **DELETE de la DB**
|
||
4. Embed uniquement les fragments nouveaux (batch de max 100 fragments par appel API)
|
||
5. Insère en DB via upsert transactionnel
|
||
- **Et** si l'utilisateur corrige une typo dans un paragraphe, seul ce fragment est re-embeddé (pas toute la note)
|
||
- **Et** si l'utilisateur ajoute un nouveau paragraphe à la fin, seul le nouveau fragment est embeddé
|
||
- **Et** les fragments supprimés sont nettoyés (pas d'orphelins)
|
||
|
||
#### Gestion de la concurrence :
|
||
|
||
- Utiliser un **verrou par noteId** (mutex en mémoire ou lock Redis `lock:chunk-index:{noteId}`)
|
||
- Si une indexation est déjà en cours pour cette note, la nouvelle requête est mise en file d'attente (ou débounce 3s puis prend le relais)
|
||
- Pas de race condition sur les upserts (`@@unique([noteId, fragmentId])` + `ON CONFLICT DO UPDATE`)
|
||
|
||
---
|
||
|
||
### US-CHUNK-3 : Pipeline d'indexation asynchrone
|
||
|
||
**En tant que** système,
|
||
**Je veux** que l'indexation des fragments se fasse en arrière-plan sans bloquer la sauvegarde,
|
||
**Afin de** préserver la fluidité de l'édition.
|
||
|
||
#### Critères d'acceptation :
|
||
|
||
- **Quand** une note est sauvegardée (débounce existant dans le flow de save)
|
||
- **Alors** l'indexation est déclenchée **de manière asynchrone** (fire-and-forget côté serveur)
|
||
- **Et** l'utilisateur ne perçoit aucune latence liée à l'embedding
|
||
- **Et** l'indexation utilise une **queue avec concurrence limitée** (max 4 embeddings simultanés) pour éviter le rate-limit API
|
||
- **Et** en cas d'erreur API (rate limit, timeout), le fragment est **réessayé** avec backoff exponentiel (3 tentatives max)
|
||
- **Et** en cas d'échec définitif, l'erreur est loggée mais ne bloque pas l'application
|
||
|
||
#### Implémentation de la queue :
|
||
|
||
Utiliser `p-queue` (léger, pas de dépendance Redis supplémentaire) ou Bull (Redis déjà présent à `memento-redis:6379`). Préférer **`p-queue`** pour simplicité — un singleton process-level :
|
||
|
||
```typescript
|
||
import PQueue from 'p-queue'
|
||
const chunkEmbeddingQueue = new PQueue({ concurrency: 4 })
|
||
```
|
||
|
||
> Si Memento passe multi-process (PM2 cluster), migrer vers Bull. Pour l'instant, single-process Next.js → `p-queue` suffit.
|
||
|
||
---
|
||
|
||
### US-CHUNK-4 : Recherche sémantique au niveau fragment
|
||
|
||
**En tant qu'** utilisateur,
|
||
**Je veux** que la recherche sémantique trouve des passages précis dans mes notes,
|
||
**Afin de** comprendre pourquoi une note correspond à ma recherche.
|
||
|
||
#### Critères d'acceptation :
|
||
|
||
- **Étant donné** que l'utilisateur saisit une requête dans la SearchModal
|
||
- **Quand** la recherche sémantique s'exécute
|
||
- **Alors** le système effectue **deux recherches en parallèle** :
|
||
1. **Recherche fragment-level** (nouvelle) : `pgvector` sur `NoteEmbeddingChunk.embedding`
|
||
2. **Recherche note-level** (existante) : `pgvector` sur `NoteEmbedding.embedding` + FTS `tsvector`
|
||
- **Et** les résultats sont fusionnés via RRF (Reciprocal Rank Fusion, déjà implémenté dans `semantic-search.service.ts`)
|
||
- **Et** chaque résultat inclut désormais un champ `matchedSnippets: string[]` (top 3 fragments par score, max 200 chars chacun, avec `...` aux coupures)
|
||
- **Et** les snippets sont affichés dans l'UI de recherche sous le titre de la note (mise en gras du terme si présent)
|
||
|
||
#### Requête SQL fragment-level :
|
||
|
||
```sql
|
||
-- Recherche vectorielle au niveau fragment, agrégée par note
|
||
WITH chunk_scores AS (
|
||
SELECT
|
||
c."noteId",
|
||
MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score,
|
||
ARRAY_AGG(c.content ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC) AS ranked_contents
|
||
FROM "NoteEmbeddingChunk" c
|
||
JOIN "Note" n ON n.id = c."noteId"
|
||
WHERE n."userId" = $2
|
||
AND c."embedding" IS NOT NULL
|
||
AND 1 - (c."embedding"::vector <=> $1::vector) >= $3 -- threshold
|
||
GROUP BY c."noteId"
|
||
LIMIT $4
|
||
)
|
||
SELECT
|
||
cs."noteId",
|
||
cs.best_score,
|
||
cs.ranked_contents[1:3] AS top_snippets -- top 3 fragments
|
||
FROM chunk_scores cs
|
||
ORDER BY cs.best_score DESC
|
||
```
|
||
|
||
> Le `[1:3]` est du slicing PostgreSQL natif sur les arrays. Chaque snippet est tronqué à 200 chars côté application.
|
||
|
||
---
|
||
|
||
### US-CHUNK-5 : Memory Echo précis au niveau fragment
|
||
|
||
**En tant qu'** utilisateur,
|
||
**Je veux** que Memory Echo détecte des connexions entre sections spécifiques de notes,
|
||
**Afin de** découvrir des résonances même entre notes majoritairement différentes.
|
||
|
||
#### Critères d'acceptation :
|
||
|
||
- **Étant donné** deux notes A et B indexées en fragments
|
||
- **Quand** Memory Echo calcule la similarité entre A et B
|
||
- **Alors** au lieu de comparer `embedding(A)` vs `embedding(B)` (note entière)
|
||
- **Le système compare** la **meilleure paire de fragments** : `MAX(similarity(fragmentAi, fragmentBj))` sur tous les cross-joins
|
||
- **Et** si la meilleure paire dépasse le seuil (`SEMANTIC_SIMILARITY_FLOOR`), une connexion est créée
|
||
- **Et** l'insight Memory Echo affiche le **snippet précis** qui a résonné (pas juste le score global)
|
||
- **Et** le filtre temporel (`MIN_DAYS_APART`) reste identique
|
||
|
||
#### Requête SQL (cross-join fragment) :
|
||
|
||
```sql
|
||
SELECT
|
||
a."noteId" AS note1_id,
|
||
b."noteId" AS note2_id,
|
||
MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) AS best_fragment_similarity,
|
||
-- Récupérer le contenu des fragments qui matchent le mieux
|
||
(
|
||
SELECT a2.content
|
||
FROM "NoteEmbeddingChunk" a2
|
||
WHERE a2."noteId" = a."noteId"
|
||
AND 1 - (a2."embedding"::vector <=> b."embedding"::vector) = MAX(1 - (a."embedding"::vector <=> b."embedding"::vector))
|
||
LIMIT 1
|
||
) AS note1_snippet,
|
||
(
|
||
SELECT b2.content
|
||
FROM "NoteEmbeddingChunk" b2
|
||
WHERE b2."noteId" = b."noteId"
|
||
AND 1 - (a2."embedding"::vector <=> b2."embedding"::vector) = MAX(...)
|
||
LIMIT 1
|
||
) AS note2_snippet
|
||
FROM "NoteEmbeddingChunk" a
|
||
JOIN "NoteEmbeddingChunk" b ON a."noteId" < b."noteId" -- évite les doublons et self-joins
|
||
JOIN "Note" na ON na.id = a."noteId"
|
||
JOIN "Note" nb ON nb.id = b."noteId"
|
||
WHERE na."userId" = $1
|
||
AND nb."userId" = $1
|
||
AND a."embedding" IS NOT NULL
|
||
AND b."embedding" IS NOT NULL
|
||
GROUP BY a."noteId", b."noteId"
|
||
HAVING MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) >= $2
|
||
```
|
||
|
||
> ⚠️ **Performance** : ce cross-join est coûteux. L'exécuter uniquement pour la note qui vient d'être indexée (pas sur toutes les paires à chaque fois). Pour la note N nouvellement indexée : `JOIN` ses fragments contre TOUS les autres fragments, `MAX()` par noteId cible, filtrer par seuil.
|
||
|
||
#### Flux Memory Echo mis à jour :
|
||
|
||
```
|
||
Note N sauvegardée → chunks indexés → pour chaque autre note M :
|
||
best_similarity = MAX over (fragment_N_i, fragment_M_j)
|
||
if best_similarity >= THRESHOLD and passesTimeDiversityFilter(N, M):
|
||
create MemoryEchoInsight with note1_snippet + note2_snippet
|
||
```
|
||
|
||
---
|
||
|
||
### US-CHUNK-6 : Migration rétroactive des notes existantes
|
||
|
||
**En tant qu'** admin/système,
|
||
**Je veux** indexer en fragments toutes les notes qui ont déjà un embedding global,
|
||
**Afin de** bénéficier de la précision fragment-level sur le corpus existant.
|
||
|
||
#### Critères d'acceptation :
|
||
|
||
- **Étant donné** que des notes ont un `NoteEmbedding` mais pas de `NoteEmbeddingChunk`
|
||
- **Quand** l'admin lance le script de migration
|
||
- **Alors** pour chaque note (batch de 50) :
|
||
1. Récupérer le plain text (titre + corps)
|
||
2. Chunker en fragments
|
||
3. Embedder chaque fragment (respect du rate-limit API)
|
||
4. Insérer en DB
|
||
- **Et** une barre de progression affiche : `1234 / 5678 notes (21%) — ETA: ~8 min`
|
||
- **Et** le script est **interruptible** (Ctrl+C safe — reprend là où il s'est arrêté via un checkpoint)
|
||
- **Et** le script est **idempotent** (les notes déjà chunkées sont skip)
|
||
- **Et** le script logge les erreurs par note mais continue
|
||
- **Et** le coût API estimé est affiché avant confirmation
|
||
|
||
#### Script :
|
||
|
||
```bash
|
||
# Lancement manuel (pas en CI)
|
||
npx tsx scripts/migrate-chunk-embeddings.ts --batch-size=50 --concurrency=4
|
||
```
|
||
|
||
#### Estimation de coût :
|
||
|
||
- ~1 note moyenne = ~3000 chars = ~4 fragments
|
||
- `text-embedding-3-small` : $0.02 / 1M tokens ≈ $0.02 / 750K chars
|
||
- 1000 notes × 4 fragments × 1000 chars = 4M chars ≈ $0.10
|
||
- **Négligeable** pour la plupart des corpus
|
||
|
||
---
|
||
|
||
### US-CHUNK-7 : Affichage des snippets dans la SearchModal
|
||
|
||
**En tant qu'** utilisateur,
|
||
**Je veux** voir les passages précis qui correspondent à ma recherche,
|
||
**Afin de** évaluer rapidement la pertinence d'un résultat.
|
||
|
||
#### Critères d'acceptation :
|
||
|
||
- **Étant donné** des résultats de recherche avec `matchedSnippets`
|
||
- **Quand** ils sont affichés dans la SearchModal
|
||
- **Alors** sous chaque titre de note, les top 1-2 snippets sont affichés (max 150 chars visibles, `...` aux coupures)
|
||
- **Et** le terme de recherche est **surligné en gras** dans les snippets
|
||
- **Et** les snippets sont en couleur secondaire (text-muted, plus petit que le titre)
|
||
- **Et** si aucun snippet fragment-level n'est disponible (note pas encore migrée), le comportement actuel est conservé (extrait du début de la note)
|
||
|
||
#### Design :
|
||
|
||
- Consulter le prototype `architectural-grid/` → `SearchModal` pour le design exact des snippets dans les résultats
|
||
- Style Google-like : snippet gris sous le titre, terme de recherche en gras
|
||
|
||
---
|
||
|
||
### US-CHUNK-8 : Nettoyage à la suppression de note
|
||
|
||
**En tant que** système,
|
||
**Je veux** que les fragments d'une note supprimée soient automatiquement supprimés,
|
||
**Afin de** ne pas laisser de vecteurs orphelins.
|
||
|
||
#### Critères d'acceptation :
|
||
|
||
- **Quand** une note est supprimée
|
||
- **Alors** tous ses `NoteEmbeddingChunk` sont supprimés en cascade (`onDelete: Cascade` dans le schéma Prisma)
|
||
- **Et** aucun vecteur orphelin ne subsiste
|
||
- **Et** les Memory Echo référençant cette note sont nettoyés (comportement existant)
|
||
|
||
> Le `onDelete: Cascade` sur la relation `note` dans le schéma gère cela automatiquement au niveau DB.
|
||
|
||
---
|
||
|
||
## Fichiers à créer / modifier
|
||
|
||
| Fichier | Action | Notes |
|
||
|---------|--------|-------|
|
||
| `prisma/schema.prisma` | Modifier | Ajouter modèle `NoteEmbeddingChunk` + relation inverse sur `Note` |
|
||
| `prisma/migrations/xxx_add_note_embedding_chunks/` | Créer | Migration + SQL brut pour index HNSW |
|
||
| `lib/text/note-chunking.ts` | **Créer** | Logique de chunking sémantique + hash |
|
||
| `lib/ai/services/chunk-indexing.service.ts` | **Créer** | Service d'indexation incrémentale (dedup, queue, retry) |
|
||
| `lib/ai/services/semantic-search.service.ts` | Modifier | Ajouter `vectorChunkSearch()` + fusion RRF avec existant + snippets |
|
||
| `lib/ai/services/memory-echo.service.ts` | Modifier | Similarité au niveau fragment + snippets dans insights |
|
||
| `lib/ai/services/embedding.service.ts` | Modifier | Ajouter `generateChunkEmbeddings(texts: string[])` (batch optimisé) |
|
||
| `app/api/notes/[id]/route.ts` (ou hook de save) | Modifier | Déclencher `chunkIndexingService.indexNote()` après save |
|
||
| `components/search/search-result-item.tsx` (ou équivalent) | Modifier | Afficher `matchedSnippets` |
|
||
| `scripts/migrate-chunk-embeddings.ts` | **Créer** | Script de migration rétroactive avec checkpoint |
|
||
| `locales/en.json` + `locales/fr.json` | Modifier | Clés `search.snippets.*` (minimal) |
|
||
|
||
---
|
||
|
||
## Architecture technique
|
||
|
||
### Pipeline d'indexation (flow complet)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Éditeur (TipTap) │
|
||
│ User tape → debounce 2s → POST /api/notes/[id] (save) │
|
||
└──────────────────────────┬──────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Note Save Handler (server-side) │
|
||
│ 1. Sauvegarde contenu en DB (existant) │
|
||
│ 2. Re-embed NoteEmbedding global (existant, conservé) │
|
||
│ 3. fire-and-forget → chunkIndexingService.indexNote(noteId) │
|
||
└──────────────────────────┬──────────────────────────────────────┘
|
||
│ (async, non-bloquant)
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ChunkIndexingService.indexNote(noteId) │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ 1. Récupérer note (title + content) depuis DB │ │
|
||
│ │ 2. plain = prepareNoteTextForEmbedding(title, content) │ │
|
||
│ │ 3. chunks = chunkNoteContent(noteId, plain) │ │
|
||
│ │ → [{fragmentId, content, chunkIndex}, ...] │ │
|
||
│ │ 4. existingIds = SELECT fragmentId WHERE noteId │ │
|
||
│ │ 5. newChunks = chunks WHERE fragmentId NOT IN existing │ │
|
||
│ │ 6. staleIds = existingIds NOT IN chunks.fragmentIds │ │
|
||
│ │ 7. DELETE WHERE noteId AND fragmentId IN staleIds │ │
|
||
│ │ 8. Queue: embed newChunks → INSERT │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
└──────────────────────────┬──────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ p-queue (concurrency: 4) │
|
||
│ Pour chaque nouveau fragment : │
|
||
│ embedding = await embeddingService.embedPlainText(content) │
|
||
│ INSERT INTO NoteEmbeddingChunk (fragmentId, content, │
|
||
│ embedding, ...) ON CONFLICT DO UPDATE │
|
||
│ Retry: 3 tentatives, backoff exponentiel (1s, 2s, 4s) │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Chunking — algorithme détaillé
|
||
|
||
```typescript
|
||
// lib/text/note-chunking.ts
|
||
|
||
import { createHash } from 'crypto'
|
||
|
||
const CHUNK_TARGET_CHARS = 1000
|
||
const CHUNK_OVERLAP_CHARS = 200
|
||
const MIN_FRAGMENT_CHARS = 10
|
||
const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
|
||
|
||
export interface NoteChunk {
|
||
fragmentId: string
|
||
content: string
|
||
chunkIndex: number
|
||
charCount: number
|
||
}
|
||
|
||
/**
|
||
* Découpe une note en fragments sémantiques.
|
||
* Port TS de flowy-ai/src/embeddings/document_indexer.rs
|
||
*/
|
||
export function chunkNoteContent(
|
||
noteId: string,
|
||
plainText: string,
|
||
): NoteChunk[] {
|
||
const normalized = plainText.trim()
|
||
if (normalized.length < MIN_FRAGMENT_CHARS) return []
|
||
|
||
// 1. Split par paragraphes (double newline)
|
||
const paragraphs = normalized
|
||
.split(/\n\s*\n/)
|
||
.map((p) => p.trim())
|
||
.filter((p) => p.length >= MIN_FRAGMENT_CHARS)
|
||
|
||
if (paragraphs.length === 0) return []
|
||
|
||
// 2. Sous-découper les paragraphes trop longs
|
||
const atomicParagraphs: string[] = []
|
||
for (const para of paragraphs) {
|
||
if (para.length > MAX_PARAGRAPH_BEFORE_SPLIT) {
|
||
atomicParagraphs.push(...splitLongParagraph(para, CHUNK_TARGET_CHARS))
|
||
} else {
|
||
atomicParagraphs.push(para)
|
||
}
|
||
}
|
||
|
||
// 3. Grouper les paragraphes en chunks de ~CHUNK_TARGET_CHARS
|
||
const groups = groupParagraphsByMaxContentLen(
|
||
atomicParagraphs,
|
||
CHUNK_TARGET_CHARS,
|
||
CHUNK_OVERLAP_CHARS,
|
||
)
|
||
|
||
// 4. Hash + construire les NoteChunk
|
||
const chunks: NoteChunk[] = []
|
||
for (let i = 0; i < groups.length; i++) {
|
||
const content = groups[i]
|
||
if (content.length < MIN_FRAGMENT_CHARS) continue
|
||
|
||
const fragmentId = hashFragment(noteId, content)
|
||
// Dédup : si le même fragmentId existe déjà (paragraphe répété), skip
|
||
if (chunks.some((c) => c.fragmentId === fragmentId)) continue
|
||
|
||
chunks.push({
|
||
fragmentId,
|
||
content,
|
||
chunkIndex: i,
|
||
charCount: content.length,
|
||
})
|
||
}
|
||
|
||
return chunks
|
||
}
|
||
|
||
function hashFragment(noteId: string, content: string): string {
|
||
return createHash('sha256')
|
||
.update(`${noteId}::${content}`)
|
||
.digest('hex')
|
||
.slice(0, 32) // 32 chars = 128 bits, largement suffisant pour le dedup
|
||
}
|
||
|
||
function splitLongParagraph(para: string, maxLen: number): string[] {
|
||
// Coupe aux fins de phrase (. ! ? ؟ !。) les plus proches de maxLen
|
||
const sentences = para.split(/(?<=[.!?؟!。])\s+/)
|
||
const chunks: string[] = []
|
||
let current = ''
|
||
|
||
for (const sentence of sentences) {
|
||
if ((current + ' ' + sentence).length > maxLen && current) {
|
||
chunks.push(current.trim())
|
||
current = sentence
|
||
} else {
|
||
current = current ? `${current} ${sentence}` : sentence
|
||
}
|
||
}
|
||
if (current.trim()) chunks.push(current.trim())
|
||
|
||
// Si une phrase seule dépasse maxLen, coupe dur au mot le plus proche
|
||
return chunks.flatMap((chunk) =>
|
||
chunk.length > maxLen * 1.5 ? hardSplitByWords(chunk, maxLen) : [chunk],
|
||
)
|
||
}
|
||
|
||
function hardSplitByWords(text: string, maxLen: string): string[] {
|
||
// Coupe au mot le plus proche de maxLen
|
||
// ...
|
||
}
|
||
|
||
/**
|
||
* Groupe les paragraphes en chunks de taille ~maxContentLen
|
||
* avec un overlap optionnel pour préserver le contexte.
|
||
* Port de group_paragraphs_by_max_content_len (document_indexer.rs:147)
|
||
*/
|
||
function groupParagraphsByMaxContentLen(
|
||
paragraphs: string[],
|
||
maxLen: number,
|
||
overlap: number,
|
||
): string[] {
|
||
if (paragraphs.length === 0) return []
|
||
if (overlap > maxLen) overlap = maxLen / 2
|
||
|
||
const result: string[] = []
|
||
let current = ''
|
||
|
||
for (const para of paragraphs) {
|
||
if (current.length + para.length > maxLen && current) {
|
||
result.push(current.trim())
|
||
// Préserve l'overlap : reprend les derniers `overlap` chars
|
||
const tail = current.slice(-overlap)
|
||
current = tail + para
|
||
} else {
|
||
current = current ? `${current}\n\n${para}` : para
|
||
}
|
||
}
|
||
if (current.trim()) result.push(current.trim())
|
||
|
||
return result
|
||
}
|
||
```
|
||
|
||
### Recherche fragment-level — intégration dans SemanticSearchService
|
||
|
||
```typescript
|
||
// Ajout à lib/ai/services/semantic-search.service.ts
|
||
|
||
export interface SearchResult {
|
||
noteId: string
|
||
title: string | null
|
||
content: string
|
||
score: number
|
||
matchType: 'exact' | 'related'
|
||
language?: string | null
|
||
matchedSnippets?: string[] // NOUVEAU — top fragments qui matchent
|
||
}
|
||
|
||
class SemanticSearchService {
|
||
// ... existant ...
|
||
|
||
/**
|
||
* NOUVEAU — Recherche vectorielle au niveau fragment.
|
||
* Retourne les notes triées par meilleure similarité de fragment.
|
||
*/
|
||
private async vectorChunkSearch(
|
||
queryEmbedding: number[],
|
||
userId: string,
|
||
threshold: number,
|
||
notebookId: string | null,
|
||
limit: number,
|
||
): Promise<SearchResult[]> {
|
||
const vecStr = `[${queryEmbedding.join(',')}]`
|
||
const params: any[] = [vecStr, userId, threshold, limit]
|
||
|
||
let notebookJoin = ''
|
||
if (notebookId) {
|
||
notebookJoin = `AND n."notebookId" = $${params.length + 1}`
|
||
params.push(notebookId)
|
||
}
|
||
|
||
const sql = `
|
||
WITH chunk_scores AS (
|
||
SELECT
|
||
c."noteId",
|
||
MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score,
|
||
ARRAY_AGG(
|
||
c.content
|
||
ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC
|
||
) AS ranked_contents
|
||
FROM "NoteEmbeddingChunk" c
|
||
JOIN "Note" n ON n.id = c."noteId"
|
||
WHERE n."userId" = $2
|
||
AND c."embedding" IS NOT NULL
|
||
AND 1 - (c."embedding"::vector <=> $1::vector) >= $3
|
||
${notebookJoin}
|
||
GROUP BY c."noteId"
|
||
LIMIT $4
|
||
)
|
||
SELECT
|
||
cs."noteId",
|
||
n.title,
|
||
cs.best_score,
|
||
cs.ranked_contents[1:3] AS top_snippets
|
||
FROM chunk_scores cs
|
||
JOIN "Note" n ON n.id = cs."noteId"
|
||
ORDER BY cs.best_score DESC
|
||
`
|
||
|
||
const rows = await prisma.$queryRawUnsafe(sql, ...params)
|
||
return rows.map((row: any) => ({
|
||
noteId: row.noteId,
|
||
title: row.title,
|
||
content: row.top_snippets?.[0] ?? '',
|
||
score: row.best_score,
|
||
matchType: 'related' as const,
|
||
matchedSnippets: (row.top_snippets ?? []).map((s: string) =>
|
||
s.length > 200 ? s.slice(0, 200) + '...' : s,
|
||
),
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* Recherche hybride mise à jour :
|
||
* FTS + vectorChunkSearch + vectorNoteSearch → RRF fusion
|
||
*/
|
||
async search(query: string, userId: string, opts: SearchOptions): Promise<SearchResult[]> {
|
||
// ... existant : FTS ...
|
||
|
||
// NOUVEAU : recherche fragment-level en parallèle de la recherche note-level
|
||
const [keywordResults, noteResults, chunkResults] = await Promise.all([
|
||
this.keywordSearch(query, userId, opts),
|
||
this.vectorSearch(queryEmbedding, userId, opts),
|
||
this.vectorChunkSearch(queryEmbedding, userId, opts.threshold, opts.notebookId, opts.limit),
|
||
])
|
||
|
||
// RRF sur les 3 sources
|
||
const fused = this.reciprocalRankFusion([
|
||
...keywordResults,
|
||
...noteResults,
|
||
...chunkResults,
|
||
])
|
||
|
||
// Merge snippets : si un résultat a des snippets des chunks, les propager
|
||
return this.mergeSnippets(fused, chunkResults)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Risques et mitigations
|
||
|
||
| Risque | Probabilité | Impact | Mitigation |
|
||
|--------|-------------|--------|------------|
|
||
| Cross-join fragment coûteux pour Memory Echo | Moyenne | Latence | N'exécuter que pour la note nouvellement indexée (pas sur tout le corpus à chaque fois) |
|
||
| Coût API embedding x4-5 (plus de fragments) | Faible | $$ | Dedup par hash → seule une fraction est re-embeddée après la migration initiale |
|
||
| Rate-limit API OpenAI | Moyenne | Blocage | Queue avec concurrence 4 + retry backoff exponentiel |
|
||
| Migration longue sur gros corpus | Faible | UX admin | Script batch avec checkpoint, interruptible, reprise auto |
|
||
| Index HNSW non créé (oubli migration) | Faible | Recherche lente | Vérifier l'existence de l'index au démarrage (`getDbDimension` déjà existe pour ça) |
|
||
| Fragments orphelins si note déplacée entre carnets | Faible | Bruit | `onDelete: Cascade` + nettoyage au move (déjà géré pour `NoteEmbedding`) |
|
||
|
||
---
|
||
|
||
## Métriques à tracker
|
||
|
||
| Métrique | Objectif | Mesure |
|
||
|----------|----------|--------|
|
||
| Précision Memory Echo | +30% connexions pertinentes | Taux de feedback positif sur les insights |
|
||
| Latence recherche sémantique | < 200ms (P95) | Telemetry sur `semantic-search.service.ts` |
|
||
| Coût API embedding / mois | Stable vs actuel | Compter les appels `embedPlainText` |
|
||
| Snippets affichés dans recherche | > 80% des résultats | Telemetry sur SearchModal |
|
||
| Fragments par note (moyenne) | 3-5 | Requête analytique sur `NoteEmbeddingChunk` |
|
||
|
||
---
|
||
|
||
## Clés i18n (minimal — la majorité est invisible côté UI)
|
||
|
||
```json
|
||
{
|
||
"search": {
|
||
"snippets": {
|
||
"matchedSection": "Section correspondante"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> La fonctionnalité est principalement backend — peu de libellés visibles. Les snippets sont du contenu de note (pas du texte à traduire).
|
||
|
||
---
|
||
|
||
## Ordre d'implémentation recommandé
|
||
|
||
1. **Migration Prisma** — modèle `NoteEmbeddingChunk` + index HNSW
|
||
2. **`lib/text/note-chunking.ts`** — logique de chunking pure (testable isolément)
|
||
3. **`lib/ai/services/chunk-indexing.service.ts`** — service d'indexation (dedup, queue)
|
||
4. **Intégration au hook de save** — déclenchement async
|
||
5. **Script de migration** — indexer le corpus existant
|
||
6. **Recherche fragment-level** — `vectorChunkSearch` + fusion RRF + snippets
|
||
7. **UI snippets dans SearchModal** — affichage des passages précis
|
||
8. **Memory Echo fragment-level** — similarité par meilleure paire de fragments
|
||
9. **Validation end-to-end** — créer une note longue, vérifier chunks en DB, rechercher, Memory Echo
|
||
|
||
---
|
||
|
||
## Définition de Done (DoD)
|
||
|
||
- [ ] Migration Prisma appliquée en dev, dump DB validé avant
|
||
- [ ] `chunkNoteContent()` produit des fragments cohérents (vérifié sur 5 notes de test : courte, longue, multilingue FR/FA, HTML clippé, vide)
|
||
- [ ] Indexation incrémentale : modifier une typo ne re-embed qu'un fragment (vérifié en DB)
|
||
- [ ] Recherche sémantique retourne des `matchedSnippets` pertinents
|
||
- [ ] SearchModal affiche les snippets avec le terme surligné
|
||
- [ ] Memory Echo détecte des connexions au niveau section
|
||
- [ ] Script de migration complète le corpus sans erreur
|
||
- [ ] Aucune régression sur la recherche existante (note-level toujours fonctionnelle)
|
||
- [ ] `NoteEmbedding` (global) toujours maintenu pour rétro-compat
|
||
- [ ] Pas de `revalidatePath` systématique (mutations optimistes)
|
||
- [ ] Pas de tests écrits sauf demande explicite (AGENTS.md)
|
||
|
||
---
|
||
|
||
## Notes
|
||
|
||
- **Conservation de `NoteEmbedding`** : la table existante reste le source of truth pour le clustering (`clustering.service.ts`) et la recherche globale. Les chunks sont une **couche additive** qui améliore la précision, pas un remplacement.
|
||
- **AppFlowy utilise `nomic-embed-text` (768 dims, local via Ollama)**. Memento utilise `text-embedding-3-small` (1536 dims, OpenAI). La dimension est différente — le schéma `NoteEmbeddingChunk` doit spécifier `vector(1536)`.
|
||
- **Performance pgvector** : l'index HNSW est crucial pour les requêtes fragment-level. Sans index, un scan séquentiel sur des milliers de fragments serait prohibitif. L'index doit être créé dans la migration SQL brute.
|
||
- **Persan / RTL** : le chunking par paragraphes fonctionne indépendamment de la langue. Le split par fin de phrase (`؟ !。`) couvre les scripts RTL et CJK. Vérifier que les embeddings `text-embedding-3-small` gèrent bien le persan (déjà validé pour la recherche existante).
|