DocSummarizer Part 3 - Concepts avancésM SK2 Le MSC3 Je suis allé trop loin SMC4 plongée profonde
Introduction
Il s'agit de Partie 3 de la série DocSummarizer:
- Partie 1: Établissement d'un résumé de document avec RAG - L’architecture et la raison pour laquelle l’approche du pipeline bat les appels naïfs de LLM
- Partie 2: Utilisation de l'outil - Quick-guide de démarrage du CLI
- Partie 3: Concepts avancés (le présent articleM SK1 - Le SSK3Je suis allé trop loin" plongée technique de profondeur
- Partie 4: Établissement de pipelines RAG - Utilisez la bibliothèque NuGet pour construire vos propres applications RAG
C’est une partie de mon "TimeM SK1Outils en boîte "approche: donner à moi-même une fenêtre fixe pour construire quelque chose fonctionnelMSC4 Il forcère les décisions et produit le code de travail plutôt que des conceptions théoriques.
DocSummarizer a commencé comme une démonstration de Il faudrait Build document summarizers with LLMs - l'approche de pipeline que j'ai exposée dans la partie 1. La plupart des tutorials vous montrent comment insérer le texte dans un LLM et espérer la meilleure.
Mais comme je le fais toujours, J’ai commencé à me pencher sur l’espace du problèmeM SK1 Quatre jours plus tard, JeMSC3 j’ai approfondi la recherche . Comment fonctionnent les systèmes de classement de la production?
J’ai mis en œuvre des versions de ces approches. Ce qui commençait à "oùMSC2 est le bon modèleM SK3 est devenu un emplacement ONNX fonctionnant localement , recherche hybride combinant BMMST5 avec une extraction dense MST6 Relevance marginale maximale pour la diversitéMst7 et Fusion de rang réciproque pour combiner les signauxM st8
Faire mise en garde: Il s’agit du " J’ai allé trop loin" plongée profondeM SK3 Si vous voulez simplement utiliser l’outilMSC4 lire la partie 2. si vous voulez comprendre pourquoi il fonctionne et comment les pièces conviennent ensemble, conserver la lectureM SK1
Cet article porte sur:
- Embeddings de la Sentence: Comment les modèles de transformateurs (MiniLM/BGEM SK3GTEMSC4 transforment le texte en vecteurs
- Le temps de fonctionnement d'ONNX: Running ML models locally without Python or cloud APIs
- RAG (Retraite-Generation amélioréeM SK2: Résultat d'un LLM de base dans le matériau source
- Recherche hybride: Combinaison de la recherche sémantique et lexique avec le RRF
L'architecture à un coup d'œil
Avant de vous plonger dans les détails, ici' c'est comment les pièces se passent ensembleM SK2
flowchart TB
subgraph Input["Document Input"]
DOC[/"Document<br/>(PDF, MD, URL)"/]
end
subgraph Parse["Parsing Layer"]
DOCLING["Docling<br/>(PDF/DOCX)"]
MARKDIG["Markdig<br/>(Markdown)"]
end
subgraph Extract["Extraction Layer"]
CHUNK["Document Chunker"]
SEGMENT["Segment Extractor"]
end
subgraph Embed["Embedding Layer"]
ONNX["ONNX Runtime<br/>(Sentence Transformers)"]
OLLAMA_EMB["Ollama<br/>(Optional)"]
end
subgraph Store["Vector Storage"]
QDRANT["Qdrant<br/>(Vector DB)"]
MEMORY["In-Memory<br/>(Small Docs)"]
end
subgraph Retrieve["Retrieval Layer"]
DENSE["Dense Search<br/>(Semantic)"]
BM25["BM25<br/>(Lexical)"]
RRF["RRF Fusion"]
end
subgraph Synthesize["Synthesis Layer"]
OLLAMA["Ollama LLM<br/>(Local)"]
TEMPLATES["Summary Templates"]
end
subgraph Output["Output"]
SUMMARY[/"Summary with<br/>Citations [chunk-N]"/]
end
DOC --> DOCLING & MARKDIG
DOCLING & MARKDIG --> CHUNK & SEGMENT
CHUNK --> ONNX & OLLAMA_EMB
SEGMENT --> ONNX
ONNX & OLLAMA_EMB --> QDRANT & MEMORY
QDRANT & MEMORY --> DENSE
CHUNK --> BM25
DENSE & BM25 --> RRF
RRF --> OLLAMA
OLLAMA --> TEMPLATES
TEMPLATES --> SUMMARY
Comprendre les aménagements
Le problème: Comment trouver le contenu pertinent sans mots clés?
Lorsque vous résumez un manuel de page 500-,, vous devez trouver les sections pertinentes.
- Utilisateur demande "Comment réinstaller le périphérique?"
- Le manuel indique "Retablir les paramètres de l'usine..."
- La recherche de mots clés manque (pas de mots partagés)
Vous avez besoin recherche sémantique - correspondance par signification
Qu'est-ce que les embeddings?
Les embeddings résolvent ce problème en transformant le texte en vecteurs denses.
Voici ' est l’intuition : imaginez un espace 384- dimensionnel où chaque morceau de texte a une position . Les textes ayant des sens similaires se regroupent ensemble M.
graph LR
subgraph "Embedding Space (simplified to 2D)"
A["🚗 car"]
B["🚙 automobile"]
C["🏎️ vehicle"]
D["🍎 apple"]
E["🍊 orange"]
F["🍌 fruit"]
end
A -.->|"close"| B
B -.->|"close"| C
A -.->|"close"| C
D -.->|"close"| E
E -.->|"close"| F
D -.->|"close"| F
A -.-|"far"| D
Pourquoi les transformateurs de Sentence (Non BERT bruts)?
Problème: J’avais besoin d’embeddings qui fonctionnent en fonction de la similitude sémantique . Le BERT brut a été conçu pour les tâches de classification
Solution: Usage transformateurs de sentences Les modèles - ont été spécialement formés sur des tâches de similitude en utilisant l’apprentissage contrastif. Architecture BERT mais fine-ajusté différemmentM SK1
Modèles comme all-MiniLM-L6-v2 et bge-small-en-v1.5 ont été formés sur des milliards de paires de texte comme
- "Comment rétablir le périphériqueM SK1 ↔ "Retablir les paramètres de l'usine" |( similaireMSC6
- "Comment rétablir le périphériqueM SK1 ↔ | | "Spécifications du produit
La formation les enseigne: significations similaires M SK1 vecteurs proches ( similitude cosine élevée).
Connexion: Si vous voulez comprendre comment fonctionnent les modèles de transformateurs à un niveau plus profond - incluant des mécanismes d'attentionM SK2 codeur- architecture de décoderMSC4 et pourquoi les embeddings fonctionnent - voir mon article sur Comment fonctionne la traduction en machine neuronale. Il couvre les mêmes concepts de transformateurs du point de vue de la traduction
Mise en œuvre: Nous prenons la couche de sortie du modèle ' et appliquons Tableau 1 - moyenne des emplacements de tous les tokens pour obtenir un seul vecteur pour l'ensemble du texte
flowchart LR
subgraph Input
TEXT["The quick brown fox"]
end
subgraph Tokenization
CLS["[CLS]"]
T1["the"]
T2["quick"]
T3["brown"]
T4["fox"]
SEP["[SEP]"]
end
subgraph "BERT Encoder"
direction TB
L1["Layer 1: Self-Attention"]
L2["Layer 2: Self-Attention"]
L3["..."]
L6["Layer 6: Self-Attention"]
end
subgraph Output
E1["E[CLS]"]
E2["E[the]"]
E3["E[quick]"]
E4["E[brown]"]
E5["E[fox]"]
E6["E[SEP]"]
end
subgraph Pooling
MEAN["Mean Pool<br/>(with attention mask)"]
VEC["384-dim Vector"]
end
TEXT --> CLS & T1 & T2 & T3 & T4 & SEP
CLS & T1 & T2 & T3 & T4 & SEP --> L1
L1 --> L2 --> L3 --> L6
L6 --> E1 & E2 & E3 & E4 & E5 & E6
E1 & E2 & E3 & E4 & E5 & E6 --> MEAN
MEAN --> VEC
ONNX: Modèles ML exploités localement
Le problème: Python Dependency Hell
J'ai voulu des embeddings à "just work" quand quelqu'un exploite l'outilM SK2 L'approche standardMSC3
- Installer les transformateurs Python + PyTorch +
- Télécharger les modèles manuellement
- Hope version conflicts don't break everything
Ce n'est pas bon. Les utilisateurs veulent docsummarizer -f doc.pdf, n’est pas un guide de mise en place des étapes 30-
Pourquoi ONNX?
ONNX (Open Neural Network Exchange) est un format ouvert pour les modèles M.L. inference du temps d'exécution sans Python.
Ce que je reçois avec ONNX Runtime:
- Nulle dépendance extérieure - Pas de pilotes PythonM SK1 PyTorch, CUDA
- Modèles de téléchargement automatiques- - Démarrer les téléchargements à partir de HuggingFace (~23-34MB), puis cacher
- Pure .NET - Fonctionne partout .NET fonctionne
- Inférence du CPU - Aucun GPU n'est requis, fonctionne sur du matériel peu coûteux
Commercialisation-offM SK1 légèrement plus lente que GPU PyTorch, mais beaucoup plus rapide qu'en demandant aux utilisateurs d'installer Python
Le registre type
DocSummarizer comprend plusieurs modèles embedding, chacun avec un commerce différentM SK1offs:
| Modèle MSC1 Dimensions SMC2 Tokens maximaux MSC3 Taille SMC4 Quantité MSC5 SMC6 Case d'utilisation SMC7 Besoins d'instruction ESC8 | |||
|---|---|---|---|
AllMiniLmL6V2 |
384 ≥ | ≤256 | ~23 MB MSC6 Défault MSC7 Fast msc8 Bonne qualité msc9 Non SMC10 |
BgeSmallEnV15 |
384 ≥ | ≤512 | ~34 MB MSC6 Besoins pour les documents techniques MSC7 Oui SMC8 |
GteSmall |
384 ≥ | ≤512 | ~34 MB MSC6 Objectif général MSC7 N° |
MultiQaMiniLm |
384 ≥ | ≤512 | ~23 MB MSC6 Optimisé pour Q MSC7 A SMC8 Non SMC9 |
Remarque: Toutes les données du registre indiquent WordPiece-exportations compatibles d'ONNX (utilisation vocab.txt). BPE/Les modèles unigrammes n'ont pas encore été supportés
Format d'instruction BGE: Certains modèles, tels que le BGE, requièrent des préfixes pour obtenir une performance optimaleM SK3 Le format exact dépend du modèle
// Query embedding (what the user asks)
var queryText = "Represent this sentence for searching relevant passages: " + userQuery;
var queryEmbedding = await EmbedAsync(queryText);
// Passage embedding (document chunks)
// Some BGE variants prefix passages, others don't - check model documentation
var passageEmbedding = await EmbedAsync(chunkText);
Le registre suit les modèles qui ont besoin d'instructions par RequiresInstruction et QueryInstruction champs. Always benchmark retrieval quality when working with instruction
Voici comment fonctionne le registre du modèle'
public static class OnnxModelRegistry
{
public static EmbeddingModelInfo GetEmbeddingModel(OnnxEmbeddingModel model, bool quantized = true)
{
return model switch
{
OnnxEmbeddingModel.AllMiniLmL6V2 => new EmbeddingModelInfo
{
Name = "all-MiniLM-L6-v2",
HuggingFaceRepo = "Xenova/all-MiniLM-L6-v2",
ModelFile = quantized ? "onnx/model_quantized.onnx" : "onnx/model.onnx",
VocabFile = "vocab.txt",
EmbeddingDimension = 384,
MaxSequenceLength = 256,
SizeBytes = quantized ? 23_000_000 : 90_000_000,
RequiresInstruction = false
},
// ... other models
};
}
}
Tokenisation: WordPiece vs BPE
Différents modèles utilisent différents tokenizers. Le all-MiniLM-L6-v2 Le modèle utilise la tokenisation WordPiece (comme BERTM SK1 qui sépare les mots inconnus en sous- mots de tokens. D'autres modèles peuvent utiliser BPE (ByteMSC4Pair EncodingMska5 ou Unigram tokenizersM Ska6
Importance: Les témoins de modèle doivent correspondre au témoin de formation. Notre registre indique quels témoines chaque modèle doit utiliserM SK2 Mise en œuvre à l'heure actuelle: WordPiece (via vocab.txt). BPE/Support à l'unigramme via tokenizer.json est prévue mais n’est pas encore mise en œuvre - rester aux modèles WordPiece dans le registre pour l’instant
public class BertTokenizer
{
private readonly Dictionary<string, int> _vocab;
private const int ClsTokenId = 101; // [CLS] - start of sequence
private const int SepTokenId = 102; // [SEP] - end of sequence
private const int PadTokenId = 0; // [PAD] - padding
private const int UnkTokenId = 100; // [UNK] - unknown token
public BertTokenizer(string vocabPath)
{
// Load vocabulary: word -> token ID
_vocab = File.ReadAllLines(vocabPath)
.Select((word, index) => (word, index))
.ToDictionary(x => x.word, x => x.index);
}
public (long[] InputIds, long[] AttentionMask, long[] TokenTypeIds)
Encode(string text, int maxLength)
{
// Split text into words, then apply WordPiece to each word
var words = text.ToLowerInvariant()
.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
var tokens = words.SelectMany(WordPieceTokenize).ToList();
// Truncate to fit [CLS] and [SEP] tokens
if (tokens.Count > maxLength - 2)
tokens = tokens.Take(maxLength - 2).ToList();
// Build input: [CLS] + tokens + [SEP] + [PAD]...
var inputIds = new List<long> { ClsTokenId };
inputIds.AddRange(tokens.Select(t => (long)GetTokenId(t)));
inputIds.Add(SepTokenId);
// Pad to maxLength
var padCount = maxLength - inputIds.Count;
inputIds.AddRange(Enumerable.Repeat((long)PadTokenId, padCount));
// Attention mask: 1 for real tokens, 0 for padding
var attentionMask = inputIds.Select(id => id != PadTokenId ? 1L : 0L).ToArray();
// Token type IDs: all zeros for single sentence
var tokenTypeIds = new long[maxLength];
return (inputIds.ToArray(), attentionMask, tokenTypeIds);
}
private IEnumerable<string> WordPieceTokenize(string word)
{
// If the whole word is in vocabulary, return it
if (_vocab.ContainsKey(word))
{
yield return word;
yield break;
}
// Otherwise, split into subwords with "##" prefix
int start = 0;
while (start < word.Length)
{
int end = word.Length;
string? curSubstr = null;
while (start < end)
{
var substr = word[start..end];
if (start > 0) substr = "##" + substr; // Continuation marker
if (_vocab.ContainsKey(substr))
{
curSubstr = substr;
break;
}
end--;
}
if (curSubstr == null)
{
yield return "[UNK]";
yield break;
}
yield return curSubstr;
start = end;
}
}
}
Exemple de tokenisation:
| Input | Tokens |
|---|---|
"embedding" |
["em", "##bed", "##ding"] |
"DocSummarizer" |
["doc", "##su", "##mm", "##ari", "##zer"] |
"the quick brown" |
["the", "quick", "brown"] |
Répartition moyenne avec masque d'attention
Après que BERT traite les tokens, on obtient un état caché pour chaque token.
private static float[] MeanPool(Tensor<float> hiddenStates, long[] attentionMask, int hiddenSize)
{
// Assumes last_hidden_state shape: [batch=1, seq_len, hidden_size]
// Note: Many sentence-transformer models export a pooled output directly,
// but we use mean pooling for consistency across all ONNX exports.
var result = new float[hiddenSize];
var dims = hiddenStates.Dimensions.ToArray();
var seqLen = (int)dims[1];
// Count real tokens (not padding)
float maskSum = attentionMask.Count(x => x == 1);
if (maskSum == 0) maskSum = 1; // Avoid division by zero
// Average each dimension, weighted by attention mask
for (int h = 0; h < hiddenSize; h++)
{
float sum = 0;
for (int s = 0; s < seqLen; s++)
{
if (attentionMask[s] == 1)
sum += hiddenStates[0, s, h];
}
result[h] = sum / maskSum;
}
// L2 normalize for cosine similarity
float norm = MathF.Sqrt(result.Sum(x => x * x));
if (norm > 0)
{
for (int i = 0; i < result.Length; i++)
result[i] /= norm;
}
return result;
}
La conduite d'intégration complète
Voici'le flux complet du texte à l’incorporation:
public class OnnxEmbeddingService : IEmbeddingService, IDisposable
{
private InferenceSession? _session;
private BertTokenizer? _tokenizer;
public async Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
{
await InitializeAsync(ct); // Downloads model if needed
// Prepend instruction for models that need it (like BGE)
if (_modelInfo.RequiresInstruction)
text = _modelInfo.QueryInstruction + text;
// Tokenize
var (inputIds, attentionMask, tokenTypeIds) =
_tokenizer.Encode(text, _maxSequenceLength);
// Create ONNX tensors
var inputIdsTensor = new DenseTensor<long>(inputIds, new[] { 1, inputIds.Length });
var attentionMaskTensor = new DenseTensor<long>(attentionMask, new[] { 1, attentionMask.Length });
var tokenTypeIdsTensor = new DenseTensor<long>(tokenTypeIds, new[] { 1, tokenTypeIds.Length });
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("input_ids", inputIdsTensor),
NamedOnnxValue.CreateFromTensor("attention_mask", attentionMaskTensor),
NamedOnnxValue.CreateFromTensor("token_type_ids", tokenTypeIdsTensor)
};
// Run inference
using var results = _session.Run(inputs);
// Get hidden states output
var output = results.First(r => r.Name == "last_hidden_state");
var outputTensor = output.AsTensor<float>();
// Mean pooling with attention mask
return MeanPool(outputTensor, attentionMask, _modelInfo.EmbeddingDimension);
}
}
RAG: Récupération-Generation augmentée
Le problème: Les LLMs ne peuvent't lire les documents de page M SK2
L’approche naïve échoue:
var text = File.ReadAllText("500-page-manual.txt"); // 2MB of text
var summary = await llm.GenerateAsync($"Summarize: {text}"); // ❌ Doesn't fit in context
Même avec les fenêtres contextuelles de 128K ,, vous ne pouvez pas simplement jeter des documents énormes dans l’interface.
- Rétroaction: Seules les premières pages 100 correspondent à la taille de la page
- La hallucination: LLM invente "factsM SK2 pour combler les lacunes
- Coût: Traitement 2MB des coûts de texte $$$ par demande
- Qualité: Les LLM sont confondus avec un contexte massif.
La solution: RAG (RetrievalM SK2Augmented Generation)
Au lieu de tout envoyer, envoyer uniquement ce qui est pertinent.
- Amélioration de la qualité des services: Diviser le document en segments
- Intégration: Convertir les segments en vecteurs (représentation sémantiqueM SK2
- Recouvrement: Trouver les segments 10-20 les plus pertinents pour la demande
- Synthèse: LLM résume seulement ces segments
Pourquoi cela fonctionne: Le LLM voit 10KB de contenu hautement pertinent au lieu de 2MB de texte presque irrélevé.
flowchart LR
subgraph "Without RAG"
DOC1[/"500-page PDF"/]
LLM1["LLM<br/>(32K context)"]
OUT1["❌ Truncated or<br/>Hallucinated"]
end
subgraph "With RAG"
DOC2[/"500-page PDF"/]
CHUNKS["100 Chunks"]
VDB["Vector DB"]
QUERY["Query"]
TOP["Top 10 Chunks"]
LLM2["LLM"]
OUT2["✅ Grounded<br/>Summary"]
end
DOC1 --> LLM1 --> OUT1
DOC2 --> CHUNKS --> VDB
QUERY --> VDB --> TOP --> LLM2 --> OUT2
Stratégies d'élaboration de documents
DocSummarizer appuie plusieurs stratégies de regroupement fondées sur la structure du document:
public class DocumentChunker
{
public List<DocumentChunk> ChunkByHeadings(string markdown, int maxHeadingLevel = 2)
{
var chunks = new List<DocumentChunk>();
var lines = markdown.Split('\n');
var currentChunk = new StringBuilder();
var currentHeading = "";
var headingLevel = 0;
var order = 0;
foreach (var line in lines)
{
// Detect heading (# to ######)
var headingMatch = Regex.Match(line, @"^(#{1,6})\s+(.+)$");
if (headingMatch.Success &&
headingMatch.Groups[1].Length <= maxHeadingLevel)
{
// Flush current chunk
if (currentChunk.Length > 0)
{
chunks.Add(new DocumentChunk(
Order: order++,
Heading: currentHeading,
HeadingLevel: headingLevel,
Content: currentChunk.ToString().Trim(),
Hash: ComputeHash(currentChunk.ToString())
));
}
// Start new chunk
currentHeading = headingMatch.Groups[2].Value;
headingLevel = headingMatch.Groups[1].Length;
currentChunk.Clear();
}
else
{
currentChunk.AppendLine(line);
}
}
// Don't forget the last chunk
if (currentChunk.Length > 0)
{
chunks.Add(new DocumentChunk(
Order: order,
Heading: currentHeading,
HeadingLevel: headingLevel,
Content: currentChunk.ToString().Trim(),
Hash: ComputeHash(currentChunk.ToString())
));
}
return chunks;
}
}
Extraction de segments pour la recherche fine-Grained
Pour les documents plus longs, DocSummarizer extrait des segments individuels (énoncésM SK2 éléments de listeMSC3 blocs de code ) avec cote de salienceMNK5
public class SegmentExtractor
{
public async Task<ExtractionResult> ExtractAsync(string docId, string markdown)
{
// 1. Parse into typed segments
var segments = ParseToSegments(docId, markdown);
// 2. Generate embeddings
await GenerateEmbeddingsAsync(segments);
// 3. Calculate document centroid (average embedding)
var centroid = CalculateCentroid(segments);
// 4. Score by salience using MMR (Maximal Marginal Relevance)
ComputeSalienceScores(segments, centroid);
return new ExtractionResult
{
AllSegments = segments,
TopBySalience = segments.OrderByDescending(s => s.SalienceScore).Take(50).ToList(),
Centroid = centroid
};
}
}
Le problème: Les répliques de la recherche sémantique
Sans MMR, récupération pour " Comment fonctionne le cachement?
- " Vue d'ensemble de la saisie de donnéesM SK1 (0.95 similitude)
- "Introduction au caching" (0.94 similitudeM SK3
- "Qu'est-ce qu'on cache?
- "Details sur la mise en œuvre du cacheM SK1 (0.85 similitude)
Les résultats les plus élevés 3 disent tous la même chose. IM SK2m jeter de l’écran contextuel sur la répétitionMSC3
La solution: Importance marginale maximale M SK1MMR)
Soldes de MMR la pertinence (similarité à la demande) avec diversité (dissimilarité par rapport à ce qui est déjà faitM SK1articles sélectionnés ).
Formulaire: $$MMR = \lambda \cdot SMK4texteM SK5sim}(sMSC7query) ≥- (1 | | 11 | 12 | 14 | 15 | max_{sM SK1 \dans le cas choisi} ≥\texteMska5simM Ska6s~Mska7 sMka8
Ce qu'il fait: Sanctionner les candidats semblables à ceux qui ont déjà fait l’objet d’une pénalité-segments choisisM SK1 Cela empêche que le résumé ne soit une version de ce même paragraphe 5
flowchart TB
subgraph "MMR Selection"
S1["Segment 1<br/>Score: 0.95"]
S2["Segment 2<br/>Score: 0.90"]
S3["Segment 3<br/>Score: 0.88"]
S4["Segment 4<br/>Score: 0.85"]
end
subgraph "Selected"
SEL1["✓ Seg 1<br/>(highest)"]
SEL2["✓ Seg 3<br/>(most diverse)"]
SEL3["✓ Seg 4"]
end
S1 -->|"Select"| SEL1
S2 -->|"Skip - too similar to Seg 1"| X["❌"]
S3 -->|"Select"| SEL2
S4 -->|"Select"| SEL3
La formule:
$$MMR = \lambda \cdot sim (s, centroid MSк6 ♫ ♫ - | ♫ (1 \ - | \Lambda\ ) ♫_{sM SK1 \dans le cas choisi} simMSC4s≥, sMST6
private List<Segment> SelectSentencesMMR(
List<Segment> segments,
float[] centroid,
int targetCount)
{
var selected = new List<Segment>();
var candidates = new HashSet<Segment>(segments.Where(s => s.Embedding != null));
// Pre-calculate centroid similarities
foreach (var segment in candidates)
{
segment.Score = CosineSimilarity(segment.Embedding!, centroid)
* segment.PositionWeight;
}
while (selected.Count < targetCount && candidates.Count > 0)
{
Segment? best = null;
double bestScore = double.MinValue;
foreach (var candidate in candidates)
{
// Relevance: similarity to centroid
var relevance = candidate.Score;
// Diversity: max similarity to already selected
double maxSimToSelected = 0;
foreach (var sel in selected)
{
var sim = CosineSimilarity(candidate.Embedding!, sel.Embedding!);
maxSimToSelected = Math.Max(maxSimToSelected, sim);
}
// MMR score: balance relevance and diversity
var mmrScore = _config.Lambda * relevance
- (1 - _config.Lambda) * maxSimToSelected;
if (mmrScore > bestScore)
{
bestScore = mmrScore;
best = candidate;
}
}
if (best != null)
{
selected.Add(best);
candidates.Remove(best);
}
}
return selected;
}
Recherche hybride avec RRF
Le problème: La recherche sémantique manque des matches exacts
J’ai rencontré ce problème lors de l’essai:
Demande de renseignements: "Qu'est le point final de l'API pour l'authentification?
La recherche sémantique est retournée:
- "Aperçu du flux d'inscription des utilisateurs" (haute similitudeM SK3
- "Pratiques exemplaires en matière de sécuritéM SK1 (haute similitude)
- " Gestion des crisesM SK1 (haute similitude)
Ce qu'il a manqué: Le point final réel de l'API emprisonné dans des exemples de code POST /api/v1/auth/login
Pourquoi?: Les modèles d'embedding sont formés sur la langue naturelle , non code/URLsM SK3expressions exactesMSC4 Point final POST /api/v1/auth/login n’apparaît pas semantiquement "point final d’authentification"
La solution: Recherche hybride M SK1Sémantique + Lexique)
Combiner deux méthodes de recherche avec des forces complémentaires:
| Type de recherche | ||
|---|---|---|
| Densité (Embedding) | Compréhension sémantiqueM SK1 Synonymes | Peut manquer des parallèles exacts |
| Pièces de rechange (BMM SK1 | Correspondance exacte des mots clés, termes rares | Aucune compréhension sémantique |
La recherche hybride combine les deux à l'aide de la réciproque fusion des rangs (RRFM SK1
flowchart TB
QUERY["Query: 'authentication security'"]
subgraph Dense["Dense Search (Semantic)"]
D1["1. OAuth 2.0 implementation"]
D2["2. User login flow"]
D3["3. Password hashing"]
end
subgraph Sparse["BM25 Search (Lexical)"]
S1["1. Authentication middleware"]
S2["2. Security headers"]
S3["3. OAuth 2.0 implementation"]
end
subgraph RRF["RRF Fusion (Illustrative)"]
R1["OAuth 2.0 implementation<br/>RRF = 1/(60+1) + 1/(60+3) ≈ 0.032"]
R2["Authentication middleware<br/>RRF = (not in dense) + 1/(60+1) ≈ 0.016"]
R3["User login flow<br/>RRF = 1/(60+2) + (not in BM25) ≈ 0.016"]
end
QUERY --> Dense & Sparse
Dense --> RRF
Sparse --> RRF
Remarque: Les cotes de RRF indiquées sont illustrables . La constante kM SK2 est standard; le classement réel dépend du nombre total de candidatsMSC4
Mise en œuvre du RRF
public static class HybridRRF
{
/// <summary>
/// Reciprocal Rank Fusion: combine multiple rankings into one.
///
/// Formula: RRF(d) = Σ 1/(k + rank_i(d))
///
/// Where k = 60 (standard constant to prevent division by small numbers)
/// </summary>
public static List<Segment> Fuse(
List<Segment> segments,
string query,
BM25Scorer bm25,
int k = 60,
int topK = 20)
{
// Rank by dense similarity
var byDense = segments
.Where(s => s.Embedding != null)
.OrderByDescending(s => s.QuerySimilarity)
.ToList();
// Rank by BM25 (scorer is built over the same ordered segment list)
var bm25Scores = segments
.Select((s, i) => (segment: s, score: bm25.Score(i, query)))
.OrderByDescending(x => x.score)
.Select(x => x.segment)
.ToList();
// Rank by salience (pre-computed importance)
var bySalience = segments
.OrderByDescending(s => s.SalienceScore)
.ToList();
// Compute RRF scores
var rrfScores = new Dictionary<Segment, double>();
void AddRRFScore(List<Segment> ranking)
{
for (int i = 0; i < ranking.Count; i++)
{
var segment = ranking[i];
var rrfContribution = 1.0 / (k + i + 1); // 1-based rank
if (!rrfScores.TryAdd(segment, rrfContribution))
rrfScores[segment] += rrfContribution;
}
}
AddRRFScore(byDense);
AddRRFScore(bm25Scores);
AddRRFScore(bySalience);
// Return top-K by fused score
return rrfScores
.OrderByDescending(kv => kv.Value)
.Take(topK)
.Select(kv => kv.Key)
.ToList();
}
}
BM25: La main-d'oeuvre de récupération des pièces détachées
BM25 M SK1Meilleure correspondance 25) est l’algorithme classique de recherche d’information. Il combine la fréquence des termesMSC4 la fréquence inverse du documentMST5 et la normalisation de la longueur du document
public class BM25Scorer
{
private const double K1 = 1.5; // Term frequency saturation
private const double B = 0.75; // Length normalization factor
public double Score(int docIndex, string query)
{
var queryTerms = Tokenize(query);
var docTermFreq = _docTermFreqs[docIndex];
var docLength = _docLengths[docIndex];
double score = 0;
foreach (var term in queryTerms.Distinct())
{
if (!docTermFreq.TryGetValue(term, out var tf)) continue;
if (!_docFreqs.TryGetValue(term, out var df)) continue;
// IDF with smoothing
var idf = Math.Log((_corpusSize - df + 0.5) / (df + 0.5) + 1);
// BM25 TF component with length normalization
var tfNorm = (tf * (K1 + 1)) /
(tf + K1 * (1 - B + B * docLength / _avgDocLength));
score += idf * tfNorm;
}
return score;
}
}
TF-IDF pour la centralité du contenu
Le problème: Comment différencier le contenu de base de Trivia?
Lorsque je résume un roman, j’ai obtenu des résultats comme :
"Le protagoniste portait un manteau bleu . Watson a noté que le temps était léger M SK2 L’étude comportait des meubles de chênes ..."
Ces extraits sont exacts, mais ils coloration (sceneM SK1détails de configuration), non points de diagramme de base.
Défi: Comment établir la différence entre
- Contenu de base: apparaît tout au long du document
- Détails de soutien: apparaît dans certaines sections
- Couleur: RareM SK1 détails spécifiques ( ce que quelqu'un portait, description des meublesMSC4
La solution: TF-IDF pour la classification de la centralité
TF-IDF M SK1Fréquence du terme -Frequence inverse des documents)estimations le rôle central d'un terme dans un document, n'est pas sa valeur réelleM SK1
Logique:
- DF élevé (>50% de blocs): "Sherlock", ≥"WatsonM SK4 ≤"murderMSC6 → Contenu de base
- DF moyen (20-50%): "Baker StreetM SK2 ≥"investigation" ≤→ Détails de soutien
- DF faible (<20%): "vêtement bleu", ≥"ameublement en acierM SK4 ≤→ couleur incidentale
Il ne s’agit pas de la vérité (une revendication répétée peut être fausse , un fait rare peut être vrai centralité du document.
flowchart LR
subgraph "TF-IDF Classification"
CLAIM["Claim text"]
TERMS["Extract terms"]
TFIDF["Compute TF-IDF"]
CLASS["Classify"]
end
subgraph "Term Types"
COMMON["High DF (>50%)<br/>→ Core content"]
MODERATE["Medium DF (20-50%)<br/>→ Supporting detail"]
RARE["Low DF (<20%)<br/>→ Incidental colour"]
end
CLAIM --> TERMS --> TFIDF --> CLASS
CLASS --> COMMON & MODERATE & RARE
public class TextAnalysisService
{
private readonly Dictionary<string, int> _documentFrequency = new();
private int _totalDocuments;
public void BuildTfIdfIndex(IEnumerable<string> documents)
{
_documentFrequency.Clear();
_totalDocuments = 0;
foreach (var doc in documents)
{
_totalDocuments++;
var terms = Tokenize(doc).Distinct();
foreach (var term in terms)
{
_documentFrequency.TryGetValue(term, out var count);
_documentFrequency[term] = count + 1;
}
}
}
/// <summary>
/// Classify term centrality (not epistemic truth):
/// - High DF (>50%): appears across most chunks = core content
/// - Medium DF (20-50%): supporting detail
/// - Low DF (<20%): rare = likely incidental ("colour")
///
/// Note: This estimates centrality, not factuality. A repeated
/// claim can be false; a rare fact can be true.
/// </summary>
public ClaimType ClassifyTermImportance(string term)
{
var df = _documentFrequency.GetValueOrDefault(term.ToLowerInvariant(), 0);
if (_totalDocuments == 0 || df == 0)
return ClaimType.Colour;
var documentRatio = (double)df / _totalDocuments;
// High centrality = appears widely
if (documentRatio > 0.5)
return ClaimType.Core;
// Medium centrality = supporting themes
if (documentRatio > 0.2)
return ClaimType.Supporting;
// Low centrality = incidental detail
return ClaimType.Colour;
}
}
Le pipeline BertRag complet
DocSummarizer's pipeline de production M SK1BertRagSummarizer) combine tous ces concepts
public class BertRagSummarizer
{
/// <summary>
/// Full pipeline: Extract → Retrieve → Synthesize
///
/// Key properties:
/// - LLM only at synthesis (no LLM-in-the-loop evaluation)
/// - Deterministic extraction (reproducible, debuggable)
/// - Validated citations (every claim traceable to source segment)
/// - Scales to any document size
/// - Cost-optimal (cheap CPU work first, expensive LLM last)
/// </summary>
public async Task<DocumentSummary> SummarizeAsync(
string docId,
string markdown,
string? focusQuery = null)
{
// === Phase 1: Extract ===
// Parse document → segments with embeddings + salience scores
var extraction = await _extractor.ExtractAsync(docId, markdown);
// === Phase 2: Retrieve ===
// Hybrid search: Dense + BM25 + Salience via RRF
var retrieved = await RetrieveAsync(extraction, focusQuery);
// === Phase 3: Synthesize ===
// LLM generates fluent summary from retrieved segments
var summary = await SynthesizeAsync(docId, retrieved, extraction, focusQuery);
return summary;
}
}
Modes d'échec communs
Lors de la construction et de l’utilisation du DocSummarizer, IM SK1 j’ai relevé ces problèmes (et vous le ferez aussi):
Incompatibilité de l'identificateur → embeddings nonsens: Déchargement d'un vocab WordPiece pour un BPE-le modèle entraîné produit des vecteurs valides -apparaissants mais semantically sans significationM SK3 Vérifier toujours si le tokenizer correspond au modèle
Tendance dominante-biais topique dans le scoring de la zone centralisée: Utiliser un document centralisé de façon systématique pour descendre - classer les sujets minoritaires
BM25 remporte la recherche dense en termes rares: Si votre demande contient un jargon technique ou des noms de famille appropriés qui ne sont pas bien représentés dans le modèle d'incorporation ' les données sur la formation M SK3 l'appariement lexique
Déchets OCR dans les fichiers PDF scannés: Docling est bon , mais les erreurs d'OCR sont combinéesM SK2 Si vous voyez des nonsens dans les résumés, vérifiez la sortie de marquage à partir du Docling d'abord.
Les sommaires de couverture faibles- doivent tenir compte du langage: Si vous ' ne voyez que les phrases suivantes dans un document :
Hallucinations de citation: Small LLMs (1.5BM SK2B params) inventer parfois des citations plausiblesMSC4sounding citationsMST5 Nous validons en analysant le résultat pour
[chunk-N], la vérification de l’existence d’un N dans les tronçons de source , et le marquage ou la réparation des revendications qui citent les troncs manquants . Si vous voyez[chunk-999]pour un document 10-chunk, votre LLM se heurte à la tâche
Il ne s’agit pas de bugs, mais de tensions inhérentes à l’espace de conception. Les bons systèmes de production les reconnaissent et les atténuent.
Considérations pratiques
Respect de la couverture et des échantillonnages
Lors du traitement de documents très grands, DocSummarizer n’essaie pas' d’incorporer tout - il utilise la sémantique préM SK3 le filtrage pour sélectionner les segments représentatifsMSC4 Cela signifie que le résumé est basé sur un échantillon, et non sur le document complet.
Le système le gère de façon transparente:
// If coverage is low (<5%), prepend disclaimer and use cautious language
if (coverage < 0.05)
{
var disclaimer = $"WARNING: Summary (sampled ~{coverage:P1} of document)";
summary = $"{disclaimer}\n\n{CleanAndHedge(summary)}";
}
// Append coverage footer to every summary
var footer = $"\n\n---\nCoverage: {coverage:P1} ({scope})\nConfidence: {confidence}";
Importance: C'est un Résumé des éléments de preuve obtenus, n’est pas une garantie de la couverture complète des documents - Lorsque nous disons :
L’échantillonnage n’est pas random - it 's sémantique. Nous utilisons un regroupement multilatéral de points d’ancrage pour veiller à ce que les thèmes des minorités ne soient pas exclus '. Une combinaison aléatoire de points de constraint et de cases étroites pourrait manquer.
Échantillonnage adaptatif avec plusieurs ancres thématiques: Le filtre pré-- utilise des ancrages multiples (kM SK3 signifie-groupement de style d'un échantillon stratifiéMSC5 pour s'assurer que les thèmes minoritaires ne sont pas systématiquement exclusMNK6MMK7 Cela évite le biais du thème dominantMRK9 où un seul centroid descendMBK10se situe dans une rangée importanteM BK11maisM GK12contenu rare comme des contraintesMSBK13exceptions MSBK14 ou conclusionsSBK15
À partir de SegmentExtractor.cs:
// Multi-anchor approach prevents single-centroid bias
var topicAnchors = ComputeTopicAnchors(embeddedSample, k: 5);
// Score by max similarity to ANY anchor (catches minority topics)
var score = topicAnchors.Max(anchor => CosineSimilarity(segment.Embedding, anchor));
Il s’agit d’une recherche -informée ( évitant l’effondrement des rappels de demande uniques- mais pratique - il fonctionne en secondes sur le CPUM SK5
Pourquoi ne pas intégrer tout simplement?
Pour un document de page 500- segments (2,000+ embedding everything would work but isnM SK3t optimal:
- CoûtLes embeddings dominent le temps de fonctionnement.
- Qualité: Embedding everything increases noise in the retrieval pool . Vous avez encore besoin de classementM SK2 pourquoi intégrer des segments qui ne seront jamais classés hauts?
- Réalisabilité: Les contraintes en matière de mémoire et de latence sont importantesM SK1 Maintenir les vecteursdim dans la mémoire et le calcul 2,000 des similitudes cosine par demande est irréaliste lorsque vous n'avez besoin que du haut de la liste
L’échantillonnage multi-anchor vous permet d’obtenir le meilleur de la couverture thématique large de : et . avec une compétence facile à manipuler.
Traçage du contexte: Pourquoi la recherche n'est-elle pas de nature à
Ce que j’ai décrit ci-dessus n’est pas seulement ', mais aussi ", la récupération, " et -, c’est un modèle spécifique que je appele Traçage de contexte fuzzy restreint (CFCD). L’incertitudeM SK1
La plupart des résumés continuent d’ajouter le contexte. DocSummarizer écoule vers l'avant seulement ce qui survivt à la sélection déterministe, puis permet au modèle d'écrire fréquemment à l'intérieur de ces limites.
Voici' comment le pipeline DocSummarizer se déplace vers CFCD:
| Concept de CFCD | Mise en œuvre du DocSummarizer |
|---|---|
| Détection de la salience (fuzzyM SK1 | Embeddings, similarity centroidMska4 TFM Ska5IDF centrality mska6 |
| Promotion déterministe | MMRM SK1 BM25, Fusion RRFMSC3 haut de la pageMNK4Sélection MNK5 |
| Anchor ledger (en anglais seulement) | Le segment récupéré est constitué d'identifiants de citation |
| Génération restreinte | Prompt de synthèse limité par les preuves récupérées |
Pourquoi cela est important?: Le modèle ne détermine pas ce que le pipeline de récupération fait ' ce qui est pertinent
Dans la pratique, le "annuaire d'ancrage" ressemble à ce qui suit :
{
"coverage": "3.2% semantic sample",
"anchors": [
{ "id": "chunk-12", "text": "Reset requires holding button 10s", "salience": 0.92 },
{ "id": "chunk-45", "text": "Factory reset clears all settings", "salience": 0.88 }
],
"constraints": {
"terms": { "factory reset": "restore factory settings" },
"hedging": "sampled 3% - avoid definitive conclusions"
}
}
Ensuite, dans le prompt de synthèse:
- "Toutes les revendications doivent indiquer une pièce d’identité incluse.
- "Si la couverture est assurée < 5%, langue de couvertureM SK3
- "Usez ces termes de façon cohérente
C’est pourquoi:
- Les fenêtres de contexte plus longues sont un herring rouge - le problème n’est pas 't fitting more text, itM SK3s deciding what deserves to survive
- Résumé récursif : - il traite la sortie intermédiaire comme un texte
- Le LLM peut't "forgetM SK2 promouvoir les contraintes - ils 're structure, non prose il peut déborder de
La CFCD est la même division philosophique que Fuzziness restreinte, MoM Fuzzy restreint, et Sommaire de l'image - la probabilité proposeM SK1 le déterminisme persiste - appliqué tout au long du temps
Quantisation
Les modèles ONNX peuvent être quantifiés (précision réduite ) pour une taille plus petite et une inference plus rapideM SK2 Le commerce-off est une perte minimale de qualitéMSC4
| Modèle | Précision totale | Quantité | Différence en matià ̈re de qualité |
|---|---|---|---|
| tous-MiniLMM SK2LMST3vMSC4 | ≥90MB | ≤23MB | |
| bge -smallM SK2en-vMSC4 | 133MB |
Traitement par lots et compétition
Pour les grands documents, l'incorporation de lots est cruciale pour la performance InferenceSession peuvent généralement être partagés en toute sécurité entre les threads, mais le rendement dépend de la configuration de session:
public async Task<float[][]> EmbedBatchAsync(IEnumerable<string> texts, CancellationToken ct)
{
var textList = texts.ToList();
var results = new float[textList.Count][];
// InferenceSession is safe to share for inference in most cases
// Tune SessionOptions.IntraOpNumThreads and InterOpNumThreads for your workload
var maxParallel = Math.Min(Environment.ProcessorCount, 8);
await Parallel.ForEachAsync(
textList.Select((text, index) => (text, index)),
new ParallelOptions { MaxDegreeOfParallelism = maxParallel },
async (item, token) =>
{
results[item.index] = await EmbedSingleAsync(item.text, token);
});
return results;
}
Conseils sur la performance: Configurer SessionOptions lors de la création de la session:
var sessionOptions = new SessionOptions
{
IntraOpNumThreads = 4, // Threads within a single operation
InterOpNumThreads = 2 // Threads across operations
};
var session = new InferenceSession(modelPath, sessionOptions);
Gestion de la mémoire pour les grands documents
Documents très volumineux (novels,documents juridiquesM SK2 nécessitent une manipulation spéciale pour éviter les erreurs de saisie.
// For documents > MaxSegmentsToEmbed, use hierarchical extraction
if (segments.Count > _config.MaxSegmentsToEmbed)
{
// Process in batches, keeping only top-K per batch
// Then re-rank globally
return await ExtractHierarchicalAsync(segments);
}
Caractéristiques du rendement
Rendement réel-world sur une machine de développement typique M SK1Ryzen 5600X, MSC4GB RAMMSC5 aucun GPUMST6
| Opération | Débit d'alimentation MSC2 Notes MSC3 | |
|---|---|---|
| Intégration | ~150 segmentsM SK2sec | |
| Récupération dense | <10ms | La similitude des cosine sur les segments ≥500 |
| BM25 cote Indice inversé de mémoire | ||
| Fusion RRF | <2ms MSC2 Combiner SMC3 rangées SMC4 | |
| Page PDF ) | ~15-20s | Comprend le chunking, l'embeddingM SK4 la récupérationMSC5 la synthèse de LLM |
Environnement d'essai: Ryzen 5600X (6-core), M32GB RAMM SK5 pas GPUMSC6 Embedding utilise tous les types de supports ≥-MiniLMMska8LMka9vM Ska10 |Mska11quantiséM ska12 \Mska13 token maxMSKA14 ♫Mska15thread parallel batchingMskar16 Retrieval corpusMkar17 Mskar18 segmentsMKar19 Votre distance varie selon les différents modèlesMsar20 matérielM Kar21 et complexité des documentsMzar22
Le conducteur principal intègre le débit (choix de modèle +longueur du token + taille du lot ). La récupération et la fusion sont essentiellement gratuites SMC4 elles prennent des millisecondes SMC5 Ce qui renforce le principe S"LLM dernier S MC7 MSC8 fonctionnent sur un CPU bon marché ESC9embeddingS MC10 recupérationS EC11 première ESC12 les LLM coûteux ne fonctionnent que sur le contenu filtré S EC13
Étalonnage: L'extraction hiérarchique gère les documents de page 500+
Résumé
DocSummarizer démontre que les capacités de NLP sophistiquées ne nécessitent pas des API cloud ou des dépendances Python. Avec ONNX Runtime pour les embeddings et Ollama pour la génération, vous pouvez construire un pipeline RAG complet qui
- Fonctionne entièrement localement
- Produit des résumés cités, traçables
- Appuyer les documents de toute taille
- Travaille en mode offline
Les principales conclusions tirées de l’élaboration de cet outil:
- Les emplacements sont la base - Une bonne récupération dépend de bonnes embeddings
- La recherche hybride bat soit seule - Combiner sémantique et lexique pour la robustesse
- MMR empêche la répétition - La diversité est aussi importante que la pertinence
- Questions relatives à la structure - Compte tenu de la structure du document positions
- Les LLM devraient être le dernier. - Travaillez d’abord sur un CPU bon marché, Les LLM coûteux ne fonctionnent que sur des contenus filtrés
Autres lectures
Documents et références techniques
- Document BERT - Encoder original du transformateur
- Sentence-BERT - BERT pour les emplacements de phrases
- BM25 Expliqué - Algorithme de recherche classique
- Papier RRF - Fusion de rang réciproque
- Le temps de fonctionnement d'ONNX - Inférence ML de la plate-forme
Diversifications profondes connexes
- Comment fonctionne la traduction en machine neuronale - Couvre l’architecture des transformateursM SK1 les mécanismes d’attention , et les embeddings du point de vue de la traduction
Établissement de la série
Ceci conclut la série DocSummarizer. VoiciM SK1 comment les trois parties s’intègrent ensemble :
Partie 1 Il y a lieu de souligner que, dans le cadre du processus d’élaboration et de mise en oeuvre des programmes, il est important de veiller à ce qu’ils soient conformes aux normes établies par les États membres. pourquoi L’approche du pipeline remporte les appels naïfs de l’ALM.
Partie 2 est votre guide de démarrage rapide- . Installation, modesM SK3 modèlesMSC4 cas d'utilisation courantesMST5 Si vous voulez simplement utiliser l'outilMSL6 queMSR7 c'est tout ce dont vous avez besoinM SR8
Partie 3 (le présent article ) est la plongée profonde pour les gens qui veulent comprendre comment il fonctionne en fait: BERT vs transformateurs de sentences , pourquoi ONNX est important, tokenisation gotchasM SK3 commerce de recherche hybrideMSC4offsMST5 et ce qui se débrouille dans la productionMSP6
Si vous construisez votre propre pipeline, lisez les trois sections suivantes :
Connexion
- Partie 5 - DoomSummarizer: Recherche approfondie - Faire avancer ces concepts: 6-Vecteurs de profil d'entités avec RRF adaptatif du signalM SK3vectores de profil des entités avec découverte HNSWMSC4 Compression de TextRankMST5 et génération parallèle longue constrémentéeMSP6form
- Analyse CSV avec les LLM locaux
- Extraire et analyser le contenu Web avec les LLM