This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Sunday, 21 December 2025
Questo è Parte 3 della serie DocSummarizer:
Questo fa parte del mio "Time-Tools boxedM SK2 approccio : darmi una finestra fissa per costruire qualcosa di funzionaleMSC4 forza le decisioni e produce il codice di lavoro piuttosto che i disegni teorici
DocSummarizer è iniziato come una dimostrazione di come si Dovrebbe costruire sommatori di documenti con LLM - l'approccio del pipeline che ho descritto nella parte 1. La maggior parte dei tutorial vi mostra come gettare il testo in un LLM e sperare nel meglio. Volevo mostrarvi la giusta architetturaM SK3 chunkingMSC4 embeddingsMST5 retrievalM ST6 citation validationMst7
Ma come faccio sempre, Mi sono interessato all'area del problema. Quattro giorni dopoM SK2 Io ' ho approfondito la ricercaMSC4 Come si fa a produrreMST5 i sistemi di classe realmente gestiscono la sommificazioneM ST6 Cosa fa funzionare bene il rilevamentoM st7 Perché alcune inserzioni superano le altreMst8
Ho implementato versioni di questi approcci. Quello che cominciò come "quell'oggi 'è il modello giustoM SK3 è diventato un embedding ONNX che funziona localmenteMSC4 la ricerca ibrida combinando BMMST5 con la ricerca densaMSSK6 la rilevanza marginale maximale per la diversitàMSL7 e la fusione dei gradi reciproci per combinare i segnaliMSR8
Avvertimento equo: Questo è il " Sono andato troppo lontano" immergermi in profonditàM SK3 Se volete solo usare l'outilamentoMSC4 leggere la parte 2. se volete capire Perché? Funziona. Come? i pezzi vanno insieme, continuate a leggere.
Questo articolo riguarda:
Prima di approfondire i dettagli, qui's come le parti si adattano insiemeM 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
Quando si riassume un manuale di pagina 500-, bisogna trovare le sezioni rilevantiM SK2 La ricerca con la parola chiave tradizionale fallisce :
Avete bisogno. ricerca semantica - corrispondenza per significato, non solo paroleM SK2
Gli inseritori lo risolvono trasformando il testo in vettori densi (arrays of numbers) that capture semantic meaningM SK2 Similar meanings = similar vectorsMNK4 regardless of exact wordingMSC5
Qui' è l'intuizioneM SK1 immaginate uno spazio 384-dimensionale in cui ogni pezzo di testo ha una posizione . Texte con significati simili si raggruppano insiemeMSC4
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
Il problema.: Ho bisogno di inserzioni che funzionino per la semantice somiglianza. Il BERT grezzo è stato progettato per le compiti di classificazione , non per la ricerca di somiglianzeM SK3
Soluzione: Usare Transformatori di frase I modelli - sono specialmente addestrati a compiti di somiglianza usando l'apprendimento contrastivo. L'architettura BERT Ma bene-fondata diversamente.
modelli come questo. all-MiniLM-L6-v2 e bge-small-en-v1.5 Sono stati addestrati a miliardi di coppie di testo come :.
La formazione li insegna significazioni simili = vettori vicini (similarità di cosine elevate
Related: Se volete capire come funzionano i modelli di trasformatori a un livello più profondo - compresi i meccanismi dell'attenzione, encoderM SK3 architettura decoderiMSC4 e perché funzionano gli embeddings ♫- guarda il mio articolo su Come funziona la traduzione della macchina neurale. Riprende gli stessi concetti di trasformatori dal punto di vista della traduzione
Implementazione: Prendiamo la strata di output del modello ' e applichiamo. pooling medio - mediando l'inserzione di tutti i tocchi per ottenere un singolo vettore per tutto il testo
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
Volevo inserirmi in "poco funzionare" quando qualcuno gestisce l'outil . L'approccio standardM SK3
Questo fa schifo. Gli utenti vogliono docsummarizer -f doc.pdf, non è un manuale di montaggio 30-
ONNX (Open Neural Network Exchange) è un formato aperto per i modelli ML . La funzione killerM SK3 inferenza del runtime senza Python.
Cosa ottengo con ONNX Runtime:
Commercio-offM SK1 leggermente più lento di GPU PyTorch, ma molto più veloce che chiedere agli utenti di installare PythonMSC3
DocSummarizer include diversi modelli di inserzione, ciascuno con un commercio diversoM SK1offs:
| Modello | Dimensioni | Max Tokens | Su misura | Quantificato | МSK5 | Mistruazione | Use Case | Mezzo | Prevede istruzioni | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
AllMiniLmL6V2 |
|||||||||||||||
BgeSmallEnV15 |
|||||||||||||||
GteSmall |
|||||||||||||||
MultiQaMiniLm |
384 ≤ | ≥512 ± | ~23MB | Optimizzato per Q&A | No |
Nota.: Tutte le registrazioni indicano WordPiece-export ONNX compatibili (uso vocab.txt). BPE/I modelli unigrammi non sono ancora supportati
Formato dell'istruzione BGE: Alcuni modelli ( come BGE) richiedono prefixi per una performance ottimaleM SK3 Il formato exacto dipende dal modelloMSC4
// 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);
Il registro traccia quali modelli hanno bisogno di istruzioni tramite RequiresInstruction e QueryInstruction campi. Always benchmark retrieval quality when working with instruction
Ecco come funziona il registro del modello'
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
};
}
}
Diversi modelli usano diversi tokenizzatori. all-MiniLM-L6-v2 Il modello usa la tokenizzazione WordPiece (come BERT), che divide parole sconosciute in tokens di sotto parolaM SK2 Altri modelli possono usare BPE (ByteMSC4Pair EncodingMska5 o Unigram tokenizersMske6
Importante: I tokenizzatori del modello devono corrispondere al tokenizzatore di allenamentoM SK1 Il nostro registro traccia quale tokenizzazione sia necessaria per ogni modello. : WordPiece (via vocab.txt). BPE/Support per l'unigramma tramite tokenizer.json è pianificato ma non ancora implementato - rimanere con i modelli WordPiece nel registro per ora
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;
}
}
}
Esempio di tokenizzazione:
| Input | Tokens |
|---|---|
"embedding" |
["em", "##bed", "##ding"] |
"DocSummarizer" |
["doc", "##su", "##mm", "##ari", "##zer"] |
"the quick brown" |
["the", "quick", "brown"] |
Dopo che BERT processa i tocchi, otteniamo uno stato nascosto per ogni tocco. Medie pooling medio di questiM SK2 ma solo su dei tocchi reali (no paddingMSC4
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;
}
Qui' è il flusso completo dal testo all'inserzione:
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);
}
}
L'approccio naïvo fallisce:
var text = File.ReadAllText("500-page-manual.txt"); // 2MB of text
var summary = await llm.GenerateAsync($"Summarize: {text}"); // ❌ Doesn't fit in context
Anche con 128K context windows, non si possono trascinare enormi documenti in :.
Invece di mandare tutto, mandare solo quello che è rilevante'M SK2
Perché funziona: Il LLM vede 10KB di contenuto altamente rilevante invece che 2MB di testo più o meno irrilevante.
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
DocSummarizer supporta strategie di blocco multiple basate sulla struttura del 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;
}
}
Per i documenti più lunghi, DocSummarizer extrae segmenti individuali (sentenzeM SK2 elementi della listaMSC3 blocchi di codici ) con il punteggio di salienzaMNK5
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
};
}
}
Senza MMR, ricava per "Come funziona la cachingM SK2 ha risposto:
I risultati in alto 3 dicono tutti la stessa cosa. IM SK2m sprecare la finestra del contesto sulla ripetizioneMSC3
Balance del MMR Rilevante (similarità alla domanda) con diversità. (dissimilarità a quella già esistente-elementi selezionatiM SK2
Formula: $$MMR = \lambda S\cdot SSK4textM SK5sim}(sMSC7questionoMNK8 - |(1 | | - |_{sM SK1 \in seletto}
Cosa fa: Penalizza candidati simili a quelli già esistiti-sezioni selezionateM SK1 Questo impedisce che il sommesso sia 5 versione dello stesso paragrafo .
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 formula:
\(MMR = \lambda S\cdot simM SK4s, centroideMSC6 \- | | (1 |_{sM SK1 \in seletto} simMSC4s+, s+')\)
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;
}
Mi sono imbattuto in questo test.:
La domanda: "Qual è il punto di fine dell'API per l'autentizzazione?"
La ricerca semantica è tornata:
Quello che manca.: L'intero punto di fine dell'API nascosto in esempi di codice: POST /api/v1/auth/login
Perché?: I modelli di embedding sono addestrati sulla lingua naturale , non il codiceM SK2 le URL/ i termini precisiMSC4 Il punto finale POST /api/v1/auth/login non corrisponde semanticamente "estremità di autenticazione" - è una riferimento tecnica letteraleM SK5
Combinare due metodi di ricerca con forze complementari:
| Tipo di ricerca | Le forze | Le debolezze |
|---|---|---|
| Denso (Embedding) | comprensione semanticaM SK1 sinonimi | Può perdere le coincidenze precise, termini rari |
| Sparse (BMM SK1 | Correspondenza accurata delle parole chiave, termini rari | Nessuna comprensione semantica MSC3 |
La ricerca ibrida combina l'uso di una combinazione reciproca di classifica (RRF):
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
Nota.: I punteggi RRF mostrati sono illustrativiM SK1 La costante k =60 è standard; il ranking effettivo dipende dal gruppo completo di candidatiMSC4
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 M SK1Best Matching 25) è l'algoritmo classico di ricerca dell'informazione. Combina la frequenza del termineMSC4 la frequenza inversa del documentoMST5 e la normalizzazione della lunghezza del documentoMst6
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;
}
}
Quando riassumo un romanzo, ho ottenuto risultati come:
"Il protagonista indossava un cappotto blu . Watson ha notato che il tempo era leggero. L'esperimento aveva mobili di legno di cime
Queste sono estraziones accurate, ma quelle sono accurate. Colore (sceneM SK1detail di configurazione), non punti di plot core.
La sfida.: Come si fa a capire la differenza tra
TF-IDF M SK1Frequenza di termine - Frequenza inversa del documento) stime Quanto è centrale un termine per il documento?, non è il suo valore realeM SK1
Logica:
Non si tratta di verità. (un'affermazione ripetuta può essere falsa, un fatto raro può essere veroM SK2 Si tratta di La centralità del documento..
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;
}
}
DocSummarizer'prodotto di produzione M SK1BertRagSummarizer) combina tutti questi concetti
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;
}
}
Quando costruiamo e usiamo DocSummarizer, IoM SK1 ho avuto questi problemi (e anche voi lo avrete fatto ):
Non corrisponde al tokenizzatore → inserzioni senza senso: Lavorare un vocab WordPiece per un BPE-Il modello addestrato produce validiM SK2vectori che sembrano ma semanticamente senza significato .Verificare sempre se il tokenizzatore corrisponde al modelloMSC4il regime di allenamentoMNK5
Pregiudizio dominante-topico nel punteggio di una sola personaM SK1centroide: Utilizzando un documento centroide sistematicamente in bassoM SK1 classifica argomenti minoritari ( restrizioni M SK3 eccezioni, casi a bordoMSC5 MultiMST6 il riscontro con le ancorhe lo ripara ma aggiunge complessitàMSSK7
BM25 batte la ricerca densa su termini rari: Se la vostra domanda contiene un vocabolario tecnico o nomi connessi non bene-representato nel modello di inserzioneM SK2 i dati di allenamento , il corrispondenzamento lexico | ( | BM |25) | supererà la ricerca semantica | МSK6 | Questo è il motivo per cui la ricerca ibrida è importante
La spazzatura in formato OCR in PDF scansionati: Il Docling è buono, ma gli errori di OCR si compongonoM SK2 Se vedete il nonsenso nei sommimenti , controllate prima l'emissione di demarcazione dal Docling.
Low-summari di copertura devono proteggere il linguaggio: Se ' vedete solo 3% di un documento, frasi come M"ultimamenteM SK5 o m"in conclusioneMSC7 sono dishonesteMNK8 Il sistema deve dire R"in sezioni campionateMRK10 e evitare le fine definitiveMMK11
Allucinazione di citazioni: Small LLMs (1.5BM SK2B params) qualche volta inventare plausibiliMSC4sounding citationsMST5 Validiamo analizzando il risultato per [chunk-N], la verifica dell'esistenza di N nelle particelle della fonte, e la marcazione o la riparazione delle affermazioni che citano particelli mancanteM SK2 Se vedete [chunk-999] per un documento 10-chunk, il vostro LLM sta lottando con la missione
Questi non sono errori, ma tensioni intrinsequenti nell'area di progettazione. I buoni sistemi di produzione li riconoscono e li attenuano.
Quando si tratta di documenti molto grandi, DocSummarizer non prova a inserire tutto'non cerca di inserirlo M SK2 usa pre-semanticiMSC3filtrazione per selezionare segmenti rappresentativi . Questo significa che il riassunto è basato su un campione, non sul documento completo.
Il sistema lo gestisce in modo trasparente:
// 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}";
Importante: Questo è un riassunto delle prove raccolte, non è una garanzione della copertura completa del documento-When we say "sampled
L'improduzione non è casuale. - itM SK1s semantico.. Usiamo un multi-clustering -anchor clustering per assicurare che i temi minoritari non siano exclusiM SK2t excluded. Un caso casuale 3% potrebbe perdere tutte le restrizioni e i casi di bordoMSC5 Un semantico S3% cerca di catturare un segmento rappresentativo da ogni tema principaleMNK7 E' ancora una copertura parzialeMMK9 ma è una copertura intenzionalmente diversificata parzialmente.
Esemplare adattabile con più ancor di tema.: Il pre--filtro usa un'ancora multiple (k-meansM SK4classificazione stilistica di un campione stratificatoMSC5 per assicurare che i temi minoritari non siano exclusi sistematicamenteMNK6MMK7 Questo impedisce la "biezza dominante del temaMRK9 dove una singola centroide si abbassaMBK10sesponde a una classifica importanteMGK11maMDK12contento raro come restrizioniMKB13esposizioniMKK14 o conclusioniMZK15
Da. 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));
Questa è una ricerca-informata (avviare un singolo erroreM SK2rimorchiare il richiamo della domandaMSC3 ma pratica - funziona in secondi sul CPUMST5
Perché non inserire tutto?
Per un documento di pagina 500- segmenti (2,000+ M SK2 inserire tutto funzionerebbe, ma non è sufficiente.
La multi-sampling -anchor ti dà il meglio di entrambi gli ambiti : con un calcolo ridutabile
Quello che ho descritto prima è ' non è solo ", ma anche "retrieval", "M SK3", -, ma è un modello specifico che io chiamo. Dragging Contexto Fuzzy Constrained (CFCD). L'intuizione:
La maggior parte dei somministratori continua a aggiungere un contesto. DocSummarizer spinge avanti. Solo quello che sopravvive alla selezione deterministica, poi permette al modello di scrivere fluidamente all'interno di quei confini.
Ecco come il pipeline DocSummarizer mappare al CFCD:
| Concepto di CFCD | Implementazione del DocSummarizer |
|---|---|
| Detezione della saliva (fuzzyM SK1 | Embeddings, Centroid similarityMska4 TFM Ska5IDF centrality Ska6 |
| Promozione deterministica | MMRM SK1 BM25, Fusione RRFMSC3 sopraMST4 Selezione K MST5 |
| Anchor ledger | Il segmento recuperato con ID di citazione |
| Generazione limitata | Prompto di sintesi limitato dalle prove recuperate |
Perché questo è importante?: Il modello non decide ' non decide cosa sia rilevante | ' | - | il tubo di ricava |. | Il modello genera fluidamente solo all'interno dei confini che abbiamo stabilito | L'ancora fa il sollevamento pesante | E questo è il motivo per cui i piccoli modelli locali funzionano | l'ancraggio fa la sollevazione pesante
In pratica, il ledger "anchor1 sembra questo.
{
"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"
}
}
Poi nella sequenza di sintesi :
Ecco perché:
La CFCD è la stessa divisione filosofica di Fuzzinesse limitata, MoM Fuzzy Constretto, e Summarizatore dell'immagine - la probabilità proponeM SK1 il determinismo persiste - applicato lungo il tempo/ l'asse della memoriaMSC4
I modelli ONNX possono essere quantificati ( precisione ridotta) per una dimensione più piccola e un'inferenza più veloceM SK2 Il commercio - è una perdita di qualità minimaMSC4
| Modello | Presizione completa | Quantizzato | Differenza di qualità | ||||
|---|---|---|---|---|---|---|---|
| tuttiM SK1MiniLM-LMST3vMSSK4 | \90MB | ||||||
| bge -smallM SK2en-vMSC4 | 133MB |
Per i grandi documenti, l'inserzione di lotti è cruciale per la performance InferenceSession in genere può essere condiviso in sicurezza attraverso i thread, ma il risultato dipende dalla configurazione della sessione:
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;
}
Guidanza sulla performance: Configure SessionOptions quando si crea la sessione:
var sessionOptions = new SessionOptions
{
IntraOpNumThreads = 4, // Threads within a single operation
InterOpNumThreads = 2 // Threads across operations
};
var session = new InferenceSession(modelPath, sessionOptions);
Documenti molto grandi (novels,documenti legaliM SK2 richiedono manodopera speciale per evitare l'uso OOM
// 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);
}
Rendimento reale-world su una tipica macchina per sviluppatori M SK1Ryzen 5600X, 32GB RAMMSC5 nessun GPUMST6
| Operazione | Produtto | Note M |
|---|---|---|
| Incoraggiamento | ~150 segmenti/sec | dimensione di lotto SSM4 tuttiM SK5MiniLMMST6LMSS7vMSSS8 quantificata SMSS9 |
| Ricerca densa | ||
| BM25 punteggi | ||
| Fusione RRF | ||
| Conclusione-to-estremità (25-strona PDFM SK3 |
L'ambiente di prova: Ryzen 5600X (6-core), M32GB RAMM SK5 nessun GPUMSC6 L'Embedding usa tuttoMSSK7MiniLMMST8LMSV9vMS2MSL11quantificatoMSR12 MSR13 token maxMRS14MRSS15thread parallel batching\MRS16\Retrieval corpus\MSR17\MRR18\segmenti\MRRS19\ La vostra velocità varierà con i diversi modelli\MRSS20\hardware\MSSR21\ e complessità del documento\MRSA22\
Il guidatore principale sta inserendo il throughput. ( scelta di modello + lunghezza del tocco + dimensione del lotto ). La ricerca e la fusione sono sostanzialmente gratuite S- ci vogliono millisecondi MESK5 Questo rafforza il principio M"LLM ultima R" I: funzionano in CPU a basso costo P(inserzione A, ricerca E) prima U, costoso LLM funziona solo su contenuti filtrati H.
Scalare: L'estrazione gerarchica gestisce i documenti di pagina 500+(novelsM SK3struzioni manualiMSC4 processando in partite e mantenendo solo la parte superioreMska5K per partita nella memoriaMske6
DocSummarizer dimostra che sofisticate capacità NLP non richiedono API cloud o dipendenze Python.
Le idee chiave per costruire questo strumento:
Questo conclude la serie DocSummarizer. Qui'è come le tre parti si adattano insiemeM SK2
Parte 1 spiega. Perché? L'approccio del pipeline supera le chiamate naive di LLM. Copri gli schemi architettonici (chunkingM SK2 riduzione gerarchica , validazione delle citazioniMSC4 che fanno funzionare bene ogni sintetizzatore di documentiMSL5 L'outil è evoluto dal momento in cui la parte CMS6 fu scrittaMSM7 ma i principi rimangono validi
Parte 2 è il vostro manuale di partenza.
Parte 3 (Questo articolo) è la immersione profonda per chi vuole capire Come? Funziona davvero: BERT contro i trasformatori di frase, perché ONNX è importanteM SK2 tokenizzazione acquisita , il commercio di ricerca ibridaMSC4 offsMST5 e cosa si rompe nella produzioneMst6
Se 'ste costruendo la vostra pipeline , leggete tutti i tre. se ' usate solo l'outilettoM SK4 leggetevi la parte M2 e forse scacciate la parte
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.