Це Частина 3 з серии DocSummarizer:
Це частина мого підходу: "TimeM SK1Boxed Tools" give myself a fixed window to build something functional . It forces decisions and produces working code rather than theoretical designs
DocSummarizer розпочався як демонстрація того, повинна збудувати підсумки dokumentów за допомогою LLMs - підхід до pipeline, який я описав у частині 1. Більшість уроків показують вам, як втиснути текст в LLM і сподіватися на найкращу схему.
Але, як я завжди роблю, я зацікавився проблемним простіром.4 дні потому я почав досліджувати.4 - Як виробництво.5 - Оцінкові системи насправді справляються з сумірацією?6 - Що робить пошук ефективним?7 - Чому деякі вбудовані мають кращий результат, ніж інші?8 -
Я запровадив версії цих підходів. Те, що почалося як M SK1 тут ' є правильним шаблоном МSK3 стало ONNX вбудовами, що запускаються локально М SK4 гібридні пошуки, які об 'єднують BM M SK5 з щільним відтворенням , Максимальна маргінална важливість для різноманітності MSC7 і Реципрочний розрив рангу для об' єднання сигналів
Справедливе попередження: Це МSK1 Я зайшов надто далеко МSK2 глибоке плавання . Якщо ви просто хочете використати інструмент МСК4 прочитайте частину М СК5 Якщо вам захочеться зрозуміти чому він працює і як частинки підходять одна до одної, продовжувати читати
Цей artykuł стосується:
Перед тим, як зануритися у деталі, ось тут:
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
Підсумовуючи інструкцію для сторінки 500-, вам потрібно знайти необхідні розділиM SK2 Традиційне пошук по ключевым словам не вдалось :
Вам потрібно пошук семантики - співпадіння по значущам
Втілення розв 'язують це, перетворюючи текст на щільні вектори ( сукупності чисел ) що захоплюють семантичні значення МSK2 Схожі значення
Тут – це інтуїція – МСК1. Уявіть собі простір, у якому кожен шматок тексту має певну позицію – МSK3. Тексти з схожими значеннями збираються разом.
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
Проблема: Мені потрібні були вбудовані додатки, які працювали для семантичної подібності . Raw BERT був розроблений для класифікаційних завдань МSK2 а не для пошуку подібічності
Рішення: Використовувати трансформатори речень Моделі - спеціально навчені на задачах схожості, використовуючи контрастне навчання . Вони МSK2 базуються на Архітектура BERT але добре-налаштований по-іншомуM SK1
Моделі, такі як all-MiniLM-L6-v2 і bge-small-en-v1.5 були треновані на мільярдах пар слів, таких як ":".
Тренування вчить їх:: подібні значення M SK1 близькі вектори ( висока схожість косину
Подібне: Якщо ви хочете зрозуміти, як працюють моделі трансформаторів на глибшому рівні МSK1 включно з механізмами уваги МSK2 кодувальник - архітектура кодувальника \ , і чому вбудова працює Як працює нейронний машинний переклад. Це стосується тих самих концепцій трансформатора з точки зору перекладу
Втілення: Ми беремо модель ' і застосовуємо значне об 'єднання - середня кількість вбудованих символів для того, щоб отримати один вектор на весь текст
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
Я хотів, щоб вбудова у " просто працювала" коли хтось керує інструментомM SK2 Стандартний підхідMSC3
Це безглуздо. Пользовачі хочуть docsummarizer -f doc.pdf, не є МSK1пособним інструкцією для налагодженняM SK2
ONNX (Open Neural Network Exchange) - це відкритий формат для моделей MLM SK2 Найголовніша характеристика : висновку про час запуску без Python.
Що я отримую з Runtime ONNX:
Торгівля-offM SK1 Трохи повільніше, ніж GPU PyTorch, але набагато швидше, ніж просити користувачів заinstalувати PythonMSC3
DocSummarizer включає в себе декілька вбудованих моделей, кожен з різними товарамиM SK1offs:
| Модель МSK1 Wymiary МSK2 Максимальні символи | Розмір ХМSK4 Квантилізовані МСК5 | МСк6 | Корпус для використання | MСК7 | Виmaganі інструкції | MСК8 | |
|---|---|---|---|---|---|---|---|
AllMiniLmL6V2 МSK0 384 ♫ ♫ МSK2 ♫ |
|||||||
BgeSmallEnV15 МSK0 384 ♫ ♫ МSK2 ♫ |
|||||||
GteSmall МSK0 384 ♫ ♫ МSK2 ♫ |
|||||||
MultiQaMiniLm МSK0 384 ♫ ♫ МSK2 ♫ |
Примітка: Всі записи реестру показують на WordPieceM SK1компанітивні експорти ONNX (застосування vocab.txt). BPE / Моделі Unigram ще не підтримують
Формат інструкцій BGE: Деякі моделі МSK1, як і BGEM SK2 потребують префиксів для оптимальної продуктивності . Точний формат залежить від моделі
// 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);
Rejestr відстежує, які моделі потребують інструкцій через RequiresInstruction і QueryInstruction поля. Яка завжди якість відтворення Benchmark при роботі з інструкціями -побудовані моделі МSK2
Ось як працює модельний реєстр.
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
};
}
}
Різні моделі використовують різні токенізери. all-MiniLM-L6-v2 модель використовує символізацію WordPiece ( на зразок BERT),, яка розділяє невідомі слова на підсловні символиM SK2 інші моделі можуть використовувати BPE (ByteMSC4Pair EncodingMska5 або Unigram tokenizersM Ska6
Важливо: Модельні токенізери повинні підходити до тренувального токінізера . Наш реестр визначає, який токінізатор для кожного моделі необхідний Наразі впроваджується: WordPiece (via vocab.txt). BPE/Unigram підтримка через tokenizer.json заплановано, але ще не введено - дотримуватись моделей WordPiece в Rejestrі на даний момент
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;
}
}
}
Наприклад, символізація:
| Вхід | Торки МSK2 |
|---|---|
"embedding" |
["em", "##bed", "##ding"] |
"DocSummarizer" |
["doc", "##su", "##mm", "##ari", "##zer"] |
"the quick brown" |
["the", "quick", "brown"] |
Після того, як BERT обробляє символи, ми отримуємо прихований стан для кожного символу.
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;
}
Тут' є повним потоком від тексту до введення :
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);
}
}
Наївний підхід провалився:
var text = File.ReadAllText("500-page-manual.txt"); // 2MB of text
var summary = await llm.GenerateAsync($"Summarize: {text}"); // ❌ Doesn't fit in context
Навіть з Windows контексту 128K1 ви не можете просто скинути величезні документи в МSK3
Замість того, щоб надсилати все, надсилайте тільки те, що є важливим
Чому це працює: LLM бачить 10KB дуже релевантного контенту замість 2MB більш неважливого текстуM SK2
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 підтримує багато стратегій відокремлення на основі структури dokumentu:
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;
}
}
Для довшего документу, DocSummarizer виділяє окремі сегменти M SK1 речення , пункти списку МSK3 кодові блоки M SK4 з відбитком salience
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
};
}
}
Без MMR, пошук для МSK1 Як працює キャッシュування? ?" повернуто
Верхні результати 3 всі говорять одне й те ж: . I МSK2m марнування контексту на повторенні МSK3
Баланси ММР важливість (подібність до запиту різноманітність (неподібність до попередньогоM SK1вибраних речей).
Формула: МSK0MMR = МSK2lambda СМСК3cdot СМСК4текстМСК5simММСК6sМПСК7 queryMСМСк8 ♫ ♫ ММСК9 ♫ MМСК10 ♫_МSK0с' МSK2в відібраномуM SK3 \ \текст{simMST6sMSC7 sMSL8
Що він робить: Викарбує кандидатів, схожих на вже наявні-вибрані сегментиM SK1 Це запобігає тому, що підсумок буде МSK2 версією одного й того ж абзацу
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
Формула:
МSK0MMR = МSK2lambda \cdot simM SK4s, centroid СМСК6 СМСК7 МСК8 МСК9 MSК10laMBda \ ) \ \ MSК12c dot \ ММСК13max_МSK0s' \в вибраних M SK3 simM SK4s+, 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;
}
Я натрапив на це під час тестування:
Запитання: МSK1 Що МSK2 є кінцевим пунктом API для аутентифікації ?"
Семантичний пошук повернувся:
Що він пропустив: Фактичний кінцевий пункт API закопаний в прикладах кодуM SK1 POST /api/v1/auth/login
Чому?: Моделі вбудованих модулів тренуються на природному мові POST /api/v1/auth/login не співпадає семантично з " кінцевим пунктом аутентифікації МSK2 МSK3 це ' є буквальною технічною референцією
Об 'єднати дві методи пошуку з докупними перевагами:
| Тип пошуку МSK1 Сили МSK2 Загнічення | ||
|---|---|---|
| Dense (Embedding) | Порозуміння семантикиM SK1 синоніми МSK2 Може пропустити точні співпадіння, рідкісні терміни | | |
| Набір запасних частин (BMM SK1 | Точне співпадіння ключевих слівM SK1 рідкісні терміни | Без семантичного розуміння МSK3 |
Гібридний пошук об 'єднує обидві способи: Reciprocal Rank Fusion (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
Примітка: Оцінки РР показані є ілюстративнимиМSK1 константа k МSK2 - стандартна ; реальний рейтинг залежить від комплекту кандидатів
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();
}
}
БММСК0 МSK1 Найкраще співпадіння 25) - це класичний алгоритм пошуку інформації МSK3 Він об 'єднує термінову частоту , зворотньу частоту документу M SK5 та стандартизацію довжини документу
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;
}
}
Підсумовуючи роман, я отримав такі результати:
" Головний герой одягнув блакитний халат . Уотсон зауважив, що погода легка МSK2 У дослідженні було дерев 'яне меблі
Це точні виділення, але вони колір (сцена центральні точки.
Задача: Як ви розрізняєте
TFМSK0IDF (Термічна частота МSK2 Інверсивна частота документу ) оцінки наскільки центральним терміном є документ, не його значення
Логіка:
Це не про правду. ( повторюваний твердження може бути неправдою МSK1 рідкісний факт може бути правдою центральність до документа.
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' M SK1BertRagSummarizer) об 'єднує всі ці поняття
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;
}
}
Коли ви будуєте і використовуєте DocSummarizer, Я ' зіткнувся з цими проблемами M SK2 і ви також зіткнетеся з ними):
Незбіг токенізеру → нерозумні вбудовані: Завантаження Vocab WordPiece для BPE-викончений модель створює важливийM SK2глядливі, але семантично безглузді вектори . Знову перевіряйте, чи токенізер відповідає моделі
Домінуючий-топічна упередженість в одній команді: Використовуючи один документ центраїд систематично вниз - оцінює меншітні теми МSK2 перешкоди МSK3 винятки
BM25 перевершує щільні пошуки на рідкісних термінах: Якщо ваше запитання містить неправильний технічний жаргон або відповідний назви, - представлений у вбудованій моделі МSK2 тренувальні дані МSK3 lexikalне співпадіння
ОCR сміття в сканованих PDF: Docling - хороший , але помилки OCR змішані МSK2 Якщо ви бачите безглуздо в підсумках M SK3 перевіряйте результати відмітки з Doclingа першими - підсумовувач може MSC5 не виправити вхідні дані з сміття MSSK6
Низкий - підсумки об 'єму мають захищати мову: Якщо ви МSK1 бачите лише МSK2 dokumentu , такі фрази, як \ "\ врешті-решт \ МSK5\ або "\ в кінцевому підсумку
Алюцинація цитацій: Невеликі LLM (1.5BM SK2B парами МSK3 іноді вигадують правдоподібні МSK4звучні цитати мSK5 Ми підтверджуємо, аналізуючи вихід [chunk-N], підтверджує, що N існує в вихідних шматочках , і позначає або виправляє вимоги, які стосуються бракуючих шматочків M SK2 Якщо ви бачите [chunk-999] для документу 10-chunk, ваш LLM бореться з завданням
Це не баги , а недоліки . МСК1 вони ' є вродженими напруженнями у просторі проектування. . Хороші системи виробництва визнають і зменшують їх.
Коли обробляємо дуже великі документи, DocSummarizer не намагається вміщувати все. Це означає, що підсумок базується на зразку
Система обробляє це прозоро:
// 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}";
Важливо: Це резюме отриманих доказівМСК0 не гарантує повного МSK1 поширення документу . Коли ми кажемо, МSK3 зразковували 3%", що мSK5 це точно те, що сталося M-, система побачила МСК7 документа і підсумувала, щоМСК8
Вибір не випадковий. - it 's семантичні. Ми використовуємо мультип 'єси МSK1 кластерування заchor, щоб гарантувати, що темами меншості не будуть виокремлені МSK2 excluded . Слуховий мSK4 може пропустити всі обмеження та крайні випадки M. Семантичний МСК6 намагається захопити один відображаючий сегмент з кожної головної теми МПС7 Він МОС8 все ще є частинним поширенням МОС9 але він МУС10 намірно різноманітним частиннім поширенням МБС11
Адаптивне зразок з багатьма тематичними анкерами: Давний МSK1флітер використовує численні анкери (kM SK3сформує стиль кластерування страйдизованого зразка МSK5 для того, щоб гарантувати, що меншітні теми не будуть систематично виокремлені MSC6. Це запобігає упередженості в темах, які домінують МСК8МСК9 там, де один центроід донизу М СК10вважається важливими M СК11но ж, М Ск12рідкісні обмеження, такі як обмеження MСК13 винятки М SК14 або висновки М СК15
Від 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));
Це дослідження-інформоване M SK1вихилення одноразового - колапсування зворотнього зв 'язку запросу МSK3 але практичное МSK4 він запускається в секундах на процесорі
Чому б не просто вмонтувати все?
Для документу на сторінці 500- сегменти МSK1 ), вбудова все спрацює, але не є оптимальною
Мультитаборні зразки -anchor дають вам найкращий результат з усіх двох : широкого спектру тематики з легкою комп 'ютеризацією МSK2
Те, що я описав раніше - це не лише " відтворення, а й " МSK4. Я називаю це конкретним шаблоном Перетягування обмеженого неясного контексту (CFCD). Розуміння
Більшість підсумовувачів продовжує додавати контекст. DocSummarizer тягне вперед лише те, що виживає детерміністичне відбору, тоді дозволяє моделі плавно писати всередині цих кордонівM SK1
Ось як pipeline DocSummarizer об 'єднується з CFCD.
| Концепція CFCD | Втілення DocSummarizer МSK2 |
|---|---|
| Розпізнавання язика (фузійнийM SK1 МSK2 Вмонтовані деталі , центроїдна подібність МSK4 TF | |
| Детерміністичне просування | MMRM SK1 BM25, РРФ синтезMSC3 верхні частини МSK4 вибір МSK5 |
| Вказівник | Згенерований сегмент, впорядкований з ідеями цитати МSK1 |
| Стримана генерація | Про prompt синтезу, обмежений отриманими доказами |
Чому це важливо?: Модель не вирішує, що для цього потрібне ' МSK3 реакційна труба для отримання інформації. МSK4 Моделі генерують лише плавно в межах, які ми закріпили \ ' \ МSK6 \ Ось чому малі локальні моделі працюють. : \ Анкери роблять важке підтримування \
На практиці "anchor ledger" виглядає ось так.
{
"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"
}
}
Тоді в синтезі про prompt:
Ось чому
CFCD - це той самий філософський поділ, що і Стримана неясність, Обмежений фузій МСМ, і Підсумовувач зображення - ймовірність запропонує
Моделі ONNX можуть бути квантовані (знижена precyzія МSK1 для меншого розміру і швидшої гіпотези МSK2 Торгівля - знизується мінімальним втратою якості
| Модель МSK1 Полна precyzія МSK2 Квантова величина | Різниця у якості мSK4 | ||
|---|---|---|---|
| МSK0 всіМСК1MiniLM-LM SK3vMSC4 | ♫ ♫ МSK6МЗБ ♫ | ♫ | |
| МSK0 bge-small-enM SK3vMSC4 MST5 \133MB | 34MB |
Для великих Documents batch embedding є вирішальним для ефективності InferenceSession в цілому можна вільно ділитися потоками,, але продуктивність залежить від конфігурації сеансу
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;
}
Наказ про продуктивність: Налагодити SessionOptions при створення сеансу:
var sessionOptions = new SessionOptions
{
IntraOpNumThreads = 4, // Threads within a single operation
InterOpNumThreads = 2 // Threads across operations
};
var session = new InferenceSession(modelPath, sessionOptions);
Дуже великі документи (новелла, правні документиM SK2 потребують спеціального обробки, щоб уникнути
// 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);
}
Реальний-виконка світу на типовій машині-розробнику M SK1Різен 5600X, МSK4ГБ оперативної пам 'яті МSK5 без ҐПУ
| Операція МSK1 пропускна спроможність | |
|---|---|
| Вбудова МSK0 ~150 сегментиM SK2сек | розмір пакету МSK4 усі M SK5MiniLM-LMska7vMske8 кількісно мSK9 |
| Dense retrieval МSK0 <10ms | Схожість косину на сегментах МSK3 |
| Результати: BM25 МSK0 <5ms | В МSK3перевернутий індекс пам 'яті |
| РРФ синтез МSK0 <2ms МSK2 Об 'єднати СМС3 рейтинги СМС4 | |
| Закінчити-наM SK1закінчити (25-страница PDFMSC3 | МSK1s МSK2 Включно з відрізкомM SK3 вбудоваю, пошукомMSC5 синтезом LLM |
Випробовування середовищаМSK0 Різен 5600X МSK2 ядро ), мSK4 ГБ оперативної пам 'яті \ , без ҐПЗ \ МSK6 Усадка використовує все -MiniLM \ мSK8 \ L \ 6- \ v \ एमSK10 \ ( \ квантований | МSK12 \ {\displaystyle 256} \ } макс. символу \ МSK14 \ / 8- \ 'Parallel Batching " Штридовий пакетування (thread parallel batching) \
Головним драйвером є вбудова пропускної спроможності (вибір моделі + довжина токену МSK2 розмір партии МSK3 Перейняття та синтез є по суті безкоштовними мSK4 вони займають мілісекунди M. Це підкріплює принцип МСК6LLM останнійМСК7 МСК8 дешеві процесори працюють MSК9введенняМ СК10 перейняттяМ סК11 перший М СК12 дорогий ЛЛМ працює лише на відфільтрованому контенті МК13
Скалування: Ієрархічне вилучення упорядковує МSK1 документи сторінок МSK2 новелла , інструкції \ ) , обробляючи їх в партіях і зберігаючи лише верхню частину
DocSummarizer показує, що складні NLP можливості не потребують хмарних API чи віддаленості від Pythonу. За допомогою ONNX Runtime для вміщування та Ollama для генерації, ви можете побудувати повну RAG трубку, яка
Найголовніша ідея створення цього інструменту:
Це підсумовує серію DocSummarizer.
Частина 1 пояснює чому цей підхід перевершує наївні LLM виклики. Він охоплює архітектурні шаблони (chunkingM SK2 ієрархічне скороченняМSK3 цитування підтвердженьMSC4 що роблять будь-який документальний сумізер добре працювати
Частина 2 це ваш швидкий-початковий інструкторM SK1Installation,модиMSC3テンプレーтиМSK4звичайні випадки використанняMST5 Якщо ви просто хочете користуватися інструментомМСК6 щоMСК7 це все, що вам потрібно
Частина 3 ( цей artykuł ) - це глибоке занурення для тих, хто хоче зрозуміти як це дійсно працює: BERT проти речення трансформаторів , чому ONNX має значенняM SK2 готування символів МSK3 гібридна пошукова торгівля M SK4 відхилення , і що розбивається в процесі виробництваМSK6
Якщо ви – МСК0 – будуєте власний трубопровод, – МSK1 – читайте всі три частини, – . – якщо ви – MСК3 – користуєтесь лише інструментом, –МSK4 – читаєте частину 2 і, можливо, скиньте секцію про невдачі частини МSK6
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.