Files
Momento/docs/story-chunk-embeddings.md
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
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
2026-06-28 07:32:57 +00:00

762 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
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.
# 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).