perf: memo GridCard, fuse save fns, fix slash tab active color
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-06-14 14:06:05 +00:00
parent a8785ed4f1
commit a623454347
120 changed files with 12301 additions and 785 deletions

View File

@@ -0,0 +1,761 @@
# 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
Momento 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 → Momento
| Aspect | AppFlowy (Rust) | Momento (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/Momento/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 Momento 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)**. Momento 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).