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
Dit is Deel 3 van de DocSummarizer-serie:
Dit is een deel van mijn "Time-Boxed Tools " benaderingM SK3 geef mezelf een vaste venster om iets functioneels te bouwenMSC4 Het dwingt tot beslissingen en produceert werkcode in plaats van theoretische ontwerpen.
DocSummarizer begon als een demonstratie van hoe je zou moeten Build document summarizers with LLMs - de pijplijn-aanpak die ik in Part uitgelegd heb 1. De meeste tutorialen laten zien hoe je tekst in een LLM schuift en hoopt op het beste. Ik wilde de juiste architectuur tonenMSC3 chunkingM SK4embeddingsMST5 retrievalM ST6 citation validationMst7
Maar zoals ik altijd doe, raakte ik geïnteresseerd in de probleemruimte. Vier dagen laterM SK2 ging ik in het onderzoek duikenMSC4 Hoe verwerken kwaliteitssystemen eigenlijk samenvattingen?
Ik implementeerde versies van deze benaderingen. Wat begon als "HierM SK2 het juiste patroon was " werd ONNX-embeddings die lokaal draaidenMSC4 hybride zoekopdrachten combineren BMMske5 met dichte opsporingM Ske6 Maksimum marginale relevantie voor diversiteitMsche7 en Reciprocale Rank Fusion om signalen te combinerenM Sche8
Gewone waarschuwing: Dit is de " Ik ging te ver" diep duikenM SK3 Als je het gereedschap alleen maar wilt gebruikenMSC4 lees deel 2. als je het wil begrijpen Waarom? Het werkt. Hoe? De onderdelen passen samen, blijven lezen.
Dit artikel gaat over:
Voordat we in het detail duiken, hier ' is hoe de stukken samenpassen:
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
Als je een 500-bladhandleiding samenvat, moet je relevante delen vinden.
Je hebt het nodig. Semantische zoekopdracht - matching by meaning, not just wordsM SK2
Embeddings lossen dit op door tekst te veranderen in dichte vektoren ( reeksen van getallen) die semantische betekenis vastleggenM SK2 Vergelijkbare betekenissen = vergelijkbare vektorsMska4 ongeacht de exacte woordenschatMske5
Hier,', is de intuïtie, :, stel je een dimensionale ruimte voor waar elk stuk tekst een positie heeft.
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
Probleem: Ik had ingebouwingen nodig die werkten voor semantische gelijkenissen . Rou BERT was ontworpen voor classificatietaakjes, geen gelijkenigtezoekingM SK3
Oplossing: Gebruik zintransformatoren - modellen die specifiek getraind zijn op gelijkenissentaakjes met behulp van contrastief leren. BERT architectuur Maar goed-anders afgestemd.
Modellen als: all-MiniLM-L6-v2 en bge-small-en-v1.5 Ze trainden zich op miljarden tekstpars zoals :.
De training leert ze:: gelijkaardige betekenissen = nauwe vektoren (hoge kosinusvergelijkingM SK3
Verwant: Als je wilt begrijpen hoe transformatormodellen werken op een dieper niveau - inclusief aandachtsmechanismenM SK2 encoder-decoderarchitektuurMSC4 en waaromembeddings werken - kijk mijn artikel op Hoe neural Machine Translation werkt. Het omvat dezelfde transformatorconcepten vanuit een vertalingsperspectief.
Implementatie: We nemen de outputlaag van het model' en passen ze toe. Medium pooling - met een gemiddelde inbouw van alle tekens om één enkele vektor voor de hele tekst te krijgen
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
Ik wilde embeddings in "just work" wanneer iemand het gereedschap bestuurtM SK2 De standaard aanpak :
Dit sucks. Gebruikers willen docsummarizer -f doc.pdf, geen 30-stap-opstellingsgids.
ONNX (Open Neural Network Exchange) is een open formaat voor ML-modellen . De killer featureM SK3 looptijdveronderstelling zonder Python.
Wat krijg ik met ONNX Runtime:
Trade-off: Een beetje langzamer dan GPU PyTorch , maar veel sneller dan gebruikers vragen om Python te installerenM SK3
DocSummarizer bevat meerdere ingebouwde modellen, elk met een andere handelM SK1offs:
| Modell | Afmetingen | Max tekens | Grootte | Kwantitaal | МSK5 | Mijzer | Gebruik case | Behoeft instructie | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
AllMiniLmL6V2 |
384 ♫ ♫ | ♫ | ||||||||||||||
BgeSmallEnV15 |
384 ♫ ♫ | |||||||||||||||
GteSmall |
384 ≥ | ≤512 | ~34 MB | Gemeenschappelijk doel | Nee | |||||||||||
MultiQaMiniLm |
384 ♫ ♫ | ♫ |
Nota: Alle inskrywings van het registru wijzen op WordPiece-compatible ONNX-exporten (gebruik vocab.txt). BPE/Unigrammodellen worden nog niet ondersteundM SK2
BGE-instructieformat: Sommige modellen, zoals BGE), hebben voor een optimale prestatie prefixen nodig . Het exacte formaat hangt af van het modelM SK4
// 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);
Het register volgt welke modellen instructies nodig hebben via RequiresInstruction en QueryInstruction velden. Als je met instructies werkt, moet je altijd de kwaliteit van het terughalen van de referentie vinden-gebaseerde modellenM SK2
Hier is hoe het modelregister werkt'
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
};
}
}
Verschillende modellen gebruiken verschillende tokenizers. De all-MiniLM-L6-v2 Het model gebruikt WordPiece-tokenisatie ( zoals BERT), die onbekende woorden in subwoordtokens splitstMSC2 Andere modellen kunnen BPE gebruiken (ByteM SK4Pair EncodingMska5 of Unigram tokenizersMske6
Belangrijk: Modell-tokenizers moeten overeenkomen met de trainingstokenizerM SK1 Ons registreer volgt welke tokenizer elk model nodig heeft. Huidig geïnstalleerd: WordPiece (via vocab.txt). BPE/Unigram-onderstel via tokenizer.json is gepland maar nog niet geïnstalleerd - blijf bij de WordPiece-modellen in het register staan voor nu.
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;
}
}
}
Voorbeeld tokenisatie:
| Invoer | |
|---|---|
"embedding" |
["em", "##bed", "##ding"] |
"DocSummarizer" |
["doc", "##su", "##mm", "##ari", "##zer"] |
"the quick brown" |
["the", "quick", "brown"] |
Nadat BERT de tekens verwerkt, krijgen we een verborgen staat voor elk teken.
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;
}
Hier' is de volledige stroom van tekst naar inbeding:
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);
}
}
De naïeve aanpak mislukt:
var text = File.ReadAllText("500-page-manual.txt"); // 2MB of text
var summary = await llm.GenerateAsync($"Summarize: {text}"); // ❌ Doesn't fit in context
Zelfs met 128K context-windows, kun je niet zomaar enorme documenten in de : dumpen.
In plaats van alles te sturen, stuur alleen wat relevant is.
Waarom dit werkt: De LLM ziet 10KB met zeer relevante inhoud in plaats van 2MB met vooral onrelevante tekst..
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 ondersteun multiple chunking strategieën gebaseerd op documentstructuur:
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;
}
}
Voor langere documenten, DocSummarizer extraheert individuele segmenten (woorden , lijst itemsM SK3 codeblocksMSC4 met saliëntie scoringMスク5
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
};
}
}
Zonder MMR, onttrek voor "Hoe werkt het opslagwerk?" is teruggestuurdM SK3
De bovenste 3-resultaten zeggen allemaal hetzelfde.
MMR-bilances relevantie (similariteit tot de vraag) met diversiteit. (dissimilariteit tot al-uitgewählte items).
Formule: $$MMR = \lambda \cdot \text(sMSC7vraagM SK8 ♫ ♫ - | ♫_{s' \in Gekose}
Wat het doet: Bestrijdt kandidaten die vergelijkbaar zijn met eerdere segmenten.
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
De formule:
$$MMR = \lambda \cdot sim (s, centroide ) ♫ ♫ - ♫_{s' \in Gekose} sim
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;
}
Ik kwam dit tegen tijdens de test.:
Navraag: "Wat is de API-endpunt voor authenticatie?"
Semantische zoekopdracht kwam terug:
Wat het mislukte: De actuale API-endpoint begraven in code voorbeelden: POST /api/v1/auth/login
Waarom?: Inbeddingsmodellen worden op natuurlijke taal getraind, geen code / URL's / exacte termen M SK4 Het eindpunt POST /api/v1/auth/login doesn't semantisch overeenstemt met "authentication endpointM SK2 - het is een letterlijke technische referentie
Combineer twee extractiemethoden met komplementaire sterktes:
| Soektype | sterktes | zwaktes M |
|---|---|---|
| Digt (Embedding) | Semantische begrip, Synonymen | Er kunnen exacte gelijkenissen ontbrekenM SK3 zeldzame termen |
| Sparse (BM25) | Extreem passende sleutelwoorden, zeldzame termen | Geen semantische begrip |
Hybride zoekopdracht combineert beide met Reciprocal Rank Fusion (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: De RRF-scores zijn illustrerend . De constante k=60 is standaardM SK3 de werkelijke rangorde hangt af van het volledige kandidatenstelMSC4
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) is de klassieke informatieherhaling-algoritme. Het combineert term frequentieMSC4 omgekeerde document frequentie~, en documentlengtenormalisatie~:
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;
}
}
Als ik een roman samenvat, krijg ik resultaten als:
"De hoofdpersoon droeg een blauwe jas . Watson merkte dat het weer mild was.
Dit zijn nauwkeurige extracties, maar ze zijn ook nauwkespelijk. Kleur (scene Kernbodempunten.
De uitdaging: Hoe kun je het verschil zien tussen
TF-IDF M SK1Term-Frequentie - Omgekeerde documentfrequentie) schattingen Hoe belangrijk een term is voor het document., niet zijn waarheidswaarde.
Logic:
Dit gaat niet over waarheid. (een herhaalde claim kan vals zijn. centraliteit aan het 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;
}
}
DocSummarizer's productie pijpleiding (BertRagSummarizer) combineert al deze concepten
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;
}
}
Als je DocSummarizer bouwt en gebruikt, heb ik deze problemen gehad.
Tokenizer mismatch → nonsensembeddings: Laaien van een WordPiece vocab voor een BPE-geschoold model produceert geldigeM SK2aangeziene, maar semantisch zinloos vektoren . Bevestig altijd dat de tokenizer met het model overeenkomtMSC4s trainingsregimeMスク5
Dominerende-topische vooroordelen in een enkele-centroïde scoring: Met één document centroïd systematisch naar beneden te gebruiken- rangschikt minderheidsthema's ( beperkingen , uitzonderingen
BM25 verslaat dichte zoekopdracht op zeldzame termen: Als je zoekopdracht technische jargon of goede naamwoorden niet goed bevat -representeerd in het inbedingsmodel's traininggegevensM SK3 lexische matching (BMMSC5 zal meer succesvol zijn dan semantische zoekop zoekMska6 Daarom is hybride zoek belangrijkMske7
OCR afval in gescand PDF's: Docling is goed, maar OCR-fouten zijn samengevoegd . Als je onzin in samenvattingen zietM SK3 kijk dan eerst naar de afvaluitvoer van Docling.
Low-coverage samenvattingen moeten taal beschermen.: Als je' alleen 3% van een document ziet, dan zijn zinnen als "in het eindeM SK5 of "in de conclusieMSC7 onjuist.
Citation-hallucinatie: Kleine LLM's (1.5B-3B paramsM SK3 soms vind je plausibele uitvindingenMSC4geluidelijke citatiesMスク5 We bevestigen het door de output te parsen voor [chunk-N], het verifiëren dat N bestaat in de bronstukjes , en het afwijzen of repareren van claims die ontbrekende stukken citeren . Als je ziet [chunk-999] voor een 10-chunk document, je LLM worstelt met de taak
Dit zijn geen bugs, maar inherente spanningen in de ontwerpruimte. Goede productiesystemen herkennen en verlichten ze.
Bij het verwerken van zeer grote documenten, DocSummarizer probeert niet' alles te inbedden M SK2 het gebruikt semantische pre-filtering om vertegenwoordigde segmenten te selecteren . Dit betekent dat de samenvatting gebaseerd is op een voorbeeld, niet het volledige document.
Het systeem manipuleert dit transparant:
// 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}";
Belangrijk: Dit is een samenvatting van verkregen bewijsmateriaal., geen garantie van volledige documentbeperking -documentbeperkning . Als we zeggen "verampte 3%", dat ' precies wat er gebeurde -, zag het systeem SMk7 van het document en samenvatte dat
De steekproef is niet willekeurig. - it 's Semantisch.. We gebruiken multi--anchorclustering om ervoor te zorgen dat minderheidsthema's niet uitgesloten zijn 'M SK3 Een willekeurige 3% kan alle beperkingen en randgevallen verliezen . Een semantice M3% probeert een vertegenwoordigend segment van elk groot thema vast te leggen MSC7 Het is nog steeds gedeeltelijk behandeld, maar het is intentioneel divers.
Aanpasbare steekproefingen met meerdere themaankers: De voor--filter maakt gebruik van meerdere ankeren (k-betekent de-stijlclustering van een ge stratificeerde steekproef,) om ervoor te zorgen dat minderheidsthema's niet systematisch uitgesloten zijn.
Van 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));
Dit is onderzoek.
Waarom niet alles inbedden?
Voor een document van 500- pagina's, segmenten of (2,000+, zou alles inbedden werken, maar is het niet optimal?
De multi--anchor-sampling geeft je het beste van beide onderwerpen.
Wat ik eerder beschreef, is niet alleen ', maar ook ", herhalen en ", - het is een specifiek patroon dat ik ' noem. Beperkte fuzzy-context-dragen (CFCD). De inzichten:
De meeste samenvatters blijven context toevoegen. DocSummarizer Draagt vooruit. Alleen wat de deterministische selectie overleeft, let het model vloeiend in die grenzen schrijven.
Hier ' zie je hoe de DocSummarizer pijpleiding naar CFCD gaat.
| CFCD-concept | DocSummarizer Implementatie |
|---|---|
| Salistieherkenning (fuzzyM SK1 | Embeddings, centroide overeenkomstenMska4 TFMske5 IDF-centraliteit mska6 |
| Deterministische promotie | MMRMSC1 BM25, RRF fusieM SK3 bovenaanMST4K-selectie MST5 |
| Ankerblad | Het geonttrekte segment met citatie-ID's ≥ |
| Beperkte generatie | Synthese-prompt omringd door opgenomen bewijs |
Waarom dit belangrijk is?: Het model beslist niet wat de retrievingspijplijn doet. Hetmodel genereert alleen vloeiend binnen de grenzen die we hebben ingesteld.
In de praktijk ziet het "anchorleader" er zo uit.
{
"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"
}
}
Dan in de synthese prompt:
Dit is waarom:
CFCD is dezelfde filosofische splitsing als Beperkte duisternis, Beperkte fuzzy MoM, en Beeldsomvattender - Waarschijnlijkheid voorstelt, Determinisme blijft bestaan.
OnNX-modellen kunnen kwantificeerd worden (reduceerde precisie) voor kleinere grootte en snellere conclusiesM SK2 Het handelen - is een minimale kwaliteitsverlies
| Modell | Volgrote Precisie | Kwantitair | Kwaliteitsverschillen |
|---|---|---|---|
| alle -MiniLM-LM SK3vMSC4 | 90MB | ||
| bge-small -enM SK3vMSC4 | 133MB |
Voor grote documenten is batchembedding cruciaal voor de prestatie. ONNX Runtime's InferenceSession Het kan in het algemeen veilig over threads worden gedeeld, maar de prestaties hangen af van de sessieopstelling:
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;
}
Performance-tip: Opstel SessionOptions bij het creëren van de sessie:
var sessionOptions = new SessionOptions
{
IntraOpNumThreads = 4, // Threads within a single operation
InterOpNumThreads = 2 // Threads across operations
};
var session = new InferenceSession(modelPath, sessionOptions);
Vroege grote documenten (novels, juridische documentenM SK2 vereist speciale manipulatie om te voorkomen.
// 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);
}
Echte performance op een typische ontwikkelaarmachine.
| Operatie | Snelheidsbreedte ♫ | ♫ Notas ♫ |
|---|---|---|
| Inbedden | ~150 segmenten/sec | |
| Digt onttreken | ||
| BM25 scoring | <5ms SMK2 In-geheugenomgekeerde index MMK4 | |
| RRF fusie | ||
| Eind-to-uitgang M SK2pagina PDF ) |
Toetsomgeving: Ryzen 5600X (6-core), M32GB RAMM SK5 geen GPUMSC6 Embedding maakt gebruik van allesMST7MiniLM-LMST9vM ST10M stst11quantiseerdMSt12Mst13tokenmaxMstr14Mt15draadparallele batchingM St16Retrieval corpusMSST17MsST18segmentenM SST19 Je mileage varieert met verschillende modellenMSS20HardwareMTS21 en documentcomplexiteitM SS22
De belangrijkste drywer inbedt de doorvoer. (modelkeuze + tokenlengte + stapelgrootte). Onttreken en fusie zijn in principe gratis ♫- ze kosten milliseconden ♫ . Dit versterkt het ♫
Skaleren: De hiërarchische extractie verwerkt 500+ paginadocumenten (novels, handleidingenM SK4 door in lotjes te verwerken en alleen bovenaan te houdenMST5K per lot in geheugenMst6
DocSummarizer toont aan dat geavanceerde NLP-capaciteiten geen cloud API's of Python-afhangen nodig hebben. Met ONNX Runtime voor ingebouwingen en Ollama voor de generatie, kun je een volledige RAG-pijplijn bouwen.
De belangrijkste inzichten uit het bouwen van dit gereedschap:
Dit is het resultaat van de DocSummarizer-serie. Hier' zie je hoe de drie onderdelen samen passenM SK2
Deel 1 Ze legt uit hoe het werkt. Waarom? De pijplijn aanpak overkomt naïeve LLM-roepen. Het omvat de architectuurpatronen (chunkingM SK2 hiërarchische vermindering , citatie-validatie
Deel 2 is je snelle-startgids~.InstallatieM SK2modusenMST3templates MST4common-use casesMst5~ Als je alleen maar het gereedschap wil gebruiken, dan is dat alles wat je nodig hebt.
Deel 3 ( dit artikel ) is de diepe duik voor mensen die willen begrijpen Hoe? Het werkt echt: BERT tegen zinstransformatoren, waarom ONNX belangrijk is , tokenisatie gokkenMSC3 hybride zoekhandelM SK4 afwijkingenMST5 en wat breukt in de productieMst6
Als je je eigen pijpleiding bouwt, lees alle drie.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.