DocSummarizer Part 3 - Utvecklingskonzepter: Den ♫ " ♫ Jag gick för långt ♫🤦" ♫ djupdykning (Svenska (Swedish))

DocSummarizer Part 3 - Utvecklingskonzepter: Den ♫ " ♫ Jag gick för långt ♫🤦" ♫ djupdykning

Sunday, 21 December 2025

//

27 minute read

Inleiding

Det här är Del 3 av DocSummarizer-serien:

  1. Del 1: Att bygga en dokumentuppfattare med RAG - Architekturen och varför pipeline-metoden överträffar naiva LLM-sändningar
  2. Del 2: Att använda verktyget - Vinniga -Startledande för CLI
  3. Del 3: avancerade koncept (den här artikeln
  4. Del 4: Byggnader RAG Pipelines - Använd NuGet biblioteket för att bygga dina egna RAG-apps

Det här är en del av min "Time -Boxed Tools" tillvägagångssättM SK3 ge mig ett fixet fönster för att bygga något funktionellt

DocSummarizer började som en demonstration av hur man borde bygga dokumentuppfattningar med LLMs - pipeline-metoden som jag förmedlade i del 1. De flesta instruktioner visar dig hur man stoppar text i en LLM och hoppas på det bästa . Jag ville visa den rätta arkitekturen

Men som jag alltid gör.,, jag blev intresserad av problemrummet.., fyra dagar senare, ,, jag grävde in i forskningen.

Jag implementerade versioner av dessa tillvägagångssätt.. Det som började med " här, ' var rätt mönster." blev lokalt drivna ONNX-integreringar.

Rättvisa varning: Det här är | " | Jag gick för långt |" | djupdykning | МSK3 | Om du bara vill använda verktyget varför det fungerar och hur bitar passar ihop, fortsätta läsa

Detta artikel omfattar:

  • Sätzebedrägerier: Hur transformeringsmodeller (MiniLM/BGEM SK3GTEMSC4 omvandlar text till vektorer
  • Runtime på ONNX: Att köra ML-modeller lokalt utan Python eller moln APIs
  • RAG (Attrival-Augmented GenerationM SK2: Grunding LLM utgång i källmaterial
  • Hybridsökning: Kombinerar semantisk och lexisk extraktion med RRF

Architekturen på en titt

Innan man dyker in i specifikationer, här ' är hur de delar passar ihop

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

Understanding Embeddings

Problemet: Hur hittar du relevanta innehåll utan Keywords?

När du sammanfattar en 500-sidahandboken , behöver du hitta relevanta sektioner. Traditionella Keywords-sökningar misslyckas

  • Användaren frågar " Hur återställer jag maskinen?"
  • Handboken säger: " För att återställa fabriksreglerna..."
  • Keywords-sökningar missar det ( inga gemensamma ord)

Du behöver vetenskapliga sökningar - matchning efter meningen , inte bara ord

Vad är inbäddar?

Embeddings löser detta genom att omvandla text till täta vektorer. ( rader av tal, ) som fångar semantiska betydelser.

Här är "'", det är intuitionen, ":", föreställ dig ett dimensionellt utrymme där varje text har en plats.

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

Varför meningstransformatorer ( Inte rå BERT )?

Problemet: Jag behövde inbäddar som fungerade för semantisk likhet . Bort BERT var designat för klassificeringsuppgifter

Lösningen: Använd meningstransformatorer --modeller som specialiserades på likhetsuppgifter med hjälp av kontrastiv inlärning BERT-arkitekturen men bra-justerat annorlundaM SK1

modeller som all-MiniLM-L6-v2 och bge-small-en-v1.5 tränades på miljarder textpar som ":".

  • "Hur att återställa apparaten" ↔ | | "Rösta fabriksreglernaM SK4 ♫ (sammanställde apparater
  • "Hur att återställa apparaten" ↔ | | "Product Specifications

Lärningen lär dem: : liknande betydelser, = nära vektorer, ♫ ♫ ( ♫ hög kosinelikhet ♫

Binärligt: Om du vill förstå hur transformeringsmodeller fungerar på en djupare nivå - som innefattar uppmärksamhetsmekanismer Hur Neurola maskinöversättning fungerar. Den täcker samma transformeringskoncepter från ett översättningsperspektiv

Implementering: Vi tar modellen 's utgångslayer och applicerar medelpooling - mäter i genomsnitt inbäddarna av alla tokens för att få en enda vektor för hela texten

flowchart LR
    subgraph Input
        TEXT["The quick brown fox"]
    end
    
    subgraph Tokenization
        CLS["[CLS]"]
        T1["the"]
        T2["quick"]
        T3["brown"]
        T4["fox"]
        SEP["[SEP]"]
    end
    
    subgraph "BERT Encoder"
        direction TB
        L1["Layer 1: Self-Attention"]
        L2["Layer 2: Self-Attention"]
        L3["..."]
        L6["Layer 6: Self-Attention"]
    end
    
    subgraph Output
        E1["E[CLS]"]
        E2["E[the]"]
        E3["E[quick]"]
        E4["E[brown]"]
        E5["E[fox]"]
        E6["E[SEP]"]
    end
    
    subgraph Pooling
        MEAN["Mean Pool<br/>(with attention mask)"]
        VEC["384-dim Vector"]
    end
    
    TEXT --> CLS & T1 & T2 & T3 & T4 & SEP
    CLS & T1 & T2 & T3 & T4 & SEP --> L1
    L1 --> L2 --> L3 --> L6
    L6 --> E1 & E2 & E3 & E4 & E5 & E6
    E1 & E2 & E3 & E4 & E5 & E6 --> MEAN
    MEAN --> VEC

ONNX: Att köra ML-modeller lokalt

Problemet: Python Dependency Hell

Jag ville ha embeddinger till " bara att fungera " när någon driver redskapet

  1. Installera Python + PyTorch + omvandlare
  2. ladda ner modeller manuell
  3. Förhoppningsversion konflikter don' inte bryter allt

Det här är strunt. Användare vill docsummarizer -f doc.pdf, inte en 30-instruktion för inställning

Varför ONNX?

ONNX (Open Neural Network Exchange drivtidsförklaring utan Python.

Vad får jag med ONNX Runtime:

  1. Null externa beroenden - Inga Python-tredare
  2. Auto-download models - Först kör nedladningar från HuggingFace (~23-34MB), sedan kastars
  3. Rent .NET - Fungerar var som helst
  4. CPU-förklaring - Ingen GPU krävs , kör på billig hårdvara

Världshandel-off: Lite långsammare än GPU PyTorch, men mycket snabbare än att be kunderna installera Python

Modellregistret

DocSummarizer inkluderar flera inbäddade modeller , var och en med olika marknader- avbetalningar:

Modell Dimensioner Max tokens МSK3 Storlek Mengen
AllMiniLmL6V2 384
BgeSmallEnV15
GteSmall 384
MultiQaMiniLm

Beteckning: Alla registrets inskrywings pekar på WordPiece -kompatibla ONNX-exporter vocab.txt). BPE

BGE instruktionsformat: Vissa modeller | ( | som BGE |) | kräver prefixer för optimal prestation | . | Det exakta formatet är beroende av modellen

// 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);

Registret följer vilka modeller som behöver instruktioner via RequiresInstruction och QueryInstruction fält. Always benchmark retrieval quality when working with instruction

Här är hur modellens register fungerar'

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
        };
    }
}

Tokenisering : WordPiece vs BPE

Olika modeller använder olika tokenier. Den all-MiniLM-L6-v2 modellen använder WordPiece-tokenisering ( som BERT ), som delar ut okända ord i subord-tokener. Andra modeller kan använda BPE | ( | Byte |- | Paarkodning | МSK5 | eller Unigram tokenizer | .

Nyckel: Modell-tokenier måste matcha utbildningstokenizern . Vår registrer följer vilka tokenier varje modell behöver Ännu implementerad: WordPiece (via vocab.txt). BPE tokenizer.json är planerad men inte implementerad än - håll fast vid WordPiece-modellerna i registret för tillfället

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;
        }
    }
}

exempel tokenisering:

Input ♫ ♫ ♫ Tokens ♫
"embedding" ["em", "##bed", "##ding"]
"DocSummarizer" ["doc", "##su", "##mm", "##ari", "##zer"]
"the quick brown" ["the", "quick", "brown"]

Mean Pooling med uppmärksamhetsmask

Efter att BERT har bearbetat tokensen, får vi en dold status för varje token.

private static float[] MeanPool(Tensor<float> hiddenStates, long[] attentionMask, int hiddenSize)
{
    // Assumes last_hidden_state shape: [batch=1, seq_len, hidden_size]
    // Note: Many sentence-transformer models export a pooled output directly,
    // but we use mean pooling for consistency across all ONNX exports.
    var result = new float[hiddenSize];
    var dims = hiddenStates.Dimensions.ToArray();
    var seqLen = (int)dims[1];
    
    // Count real tokens (not padding)
    float maskSum = attentionMask.Count(x => x == 1);
    if (maskSum == 0) maskSum = 1; // Avoid division by zero

    // Average each dimension, weighted by attention mask
    for (int h = 0; h < hiddenSize; h++)
    {
        float sum = 0;
        for (int s = 0; s < seqLen; s++)
        {
            if (attentionMask[s] == 1)
                sum += hiddenStates[0, s, h];
        }
        result[h] = sum / maskSum;
    }

    // L2 normalize for cosine similarity
    float norm = MathF.Sqrt(result.Sum(x => x * x));
    if (norm > 0)
    {
        for (int i = 0; i < result.Length; i++)
            result[i] /= norm;
    }

    return result;
}

Den kompletta Embedding Pipelinen

Här är fullflödet från text till embedding.

public class OnnxEmbeddingService : IEmbeddingService, IDisposable
{
    private InferenceSession? _session;
    private BertTokenizer? _tokenizer;

    public async Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
    {
        await InitializeAsync(ct);  // Downloads model if needed
        
        // Prepend instruction for models that need it (like BGE)
        if (_modelInfo.RequiresInstruction)
            text = _modelInfo.QueryInstruction + text;

        // Tokenize
        var (inputIds, attentionMask, tokenTypeIds) = 
            _tokenizer.Encode(text, _maxSequenceLength);

        // Create ONNX tensors
        var inputIdsTensor = new DenseTensor<long>(inputIds, new[] { 1, inputIds.Length });
        var attentionMaskTensor = new DenseTensor<long>(attentionMask, new[] { 1, attentionMask.Length });
        var tokenTypeIdsTensor = new DenseTensor<long>(tokenTypeIds, new[] { 1, tokenTypeIds.Length });

        var inputs = new List<NamedOnnxValue>
        {
            NamedOnnxValue.CreateFromTensor("input_ids", inputIdsTensor),
            NamedOnnxValue.CreateFromTensor("attention_mask", attentionMaskTensor),
            NamedOnnxValue.CreateFromTensor("token_type_ids", tokenTypeIdsTensor)
        };

        // Run inference
        using var results = _session.Run(inputs);
        
        // Get hidden states output
        var output = results.First(r => r.Name == "last_hidden_state");
        var outputTensor = output.AsTensor<float>();

        // Mean pooling with attention mask
        return MeanPool(outputTensor, attentionMask, _modelInfo.EmbeddingDimension);
    }
}

RAG: Retrieval-Augmented Generation

Problemet: LLMs kan ' inte läsa 500-Page-Dokument

Den naiva metoden misslyckas:

var text = File.ReadAllText("500-page-manual.txt"); // 2MB of text
var summary = await llm.GenerateAsync($"Summarize: {text}"); // ❌ Doesn't fit in context

Även med 128K kontextuella fönster , kan du inte bara dumpa enorma dokument i

  • Avvikning: Bara de första | 100 | sidorna passar |, | resten är ignorerade
  • Hallucinationer: LLM uppfinner " fakta M SK2 för att fylla hål
  • kostnaderna: Bearbetande ♫ 2 ♫ MB av textkostnader ♫$$$ ♫ per fråga
  • Jakt: LLMs blir förvirrade med en massiv kontext (" förlorad i mitten

Lösningen

Istället för att skicka allt, skicka bara det som är relevant.

  1. Folketing: Splitter dokumentet i segment
  2. Inbäddar: Konvertera segment till vektorer
  3. Tillförsel: Hitta de 10-20 mest relevanta segmenten för queryn
  4. Synthes: LLM summerar bara de segmenten

Varför det fungerar: LLM ser 10KB av mycket relevant innehåll istället för 2MB av mer eller mindre irrelevant text

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

Dokumenteringsstrategier

DocSummarizer supporterar flera hamneringsstrategier baserat på dokumentstrukturen:

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;
    }
}

Segment extraktion för Fine-Grätt urval

För längre dokument, DocSummarizer extraherar enskilda segmenter (talen , listade punkterM SK3 kodblockerMska4 med salience scoringM Ska5

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
        };
    }
}

Problemet: Syntetiska sökresultat återvänder duplikater

Utan MMR, att få tag i " Hur funkar kaskandet?

  1. "Kaching överblick" (0.95 likhetM SK3
  2. "
  3. " Vad är datalagning?
  4. "Details om att implementera skalan

De övre "3" -resultaten säger alla samma sak.

Lösningen: Max Marginal Relevance (MMRM SK2

MMR saldor relevans (similaritet till query) med mångfald (

Formel: $$MMR = \lambda \cdot_{s' \in utvalda varianterM SK3 | | \ | text

Vad gör den: Bestraffar kandidater som är liknande redan

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

Formeln:

\(MMR = \lambda \cdot sim(sMSク5 centroid ) - smk8 mk9 Smk10 lambda_{sM SK1 \in utvaldade} 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;
}

Hybridsökning med RRF

Problemet: Semantiskt sök missar exakt matchar

Jag stötte på detta när jag testade:

Fråga: " Vad är API-gränssnittet för autentiseringen?

Semantiska söktog gav tillbaka:

  1. "User login flow overview" ( hög likhet
  2. "
  3. "Sessionshantering" ( hög likhetM SK3

Vad det missade: Den faktiska API-endedpunkten begravd i kodexempler POST /api/v1/auth/login

Varför?: Embedding-modeller är tränade på naturlig språk POST /api/v1/auth/login matchar inte semantiskt

Lösningen

Kombinera två extraherande metoder med komplementära styrkor:

sökandets typ styrkor ♫ ♫ svagheter ♫
Dense (Embedding Synonymer
Sparse (BMM SK1 Exakt nyckeltal som matchar , sällsynta termer Ingen semantisk förståelse

Hybrid sök kombinerar båda med 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

Beteckning: RRF: s poäng är illustrerande . Den konstanta kM SK2 är standard

Implementering av RRF

public static class HybridRRF
{
    /// <summary>
    /// Reciprocal Rank Fusion: combine multiple rankings into one.
    /// 
    /// Formula: RRF(d) = Σ 1/(k + rank_i(d))
    /// 
    /// Where k = 60 (standard constant to prevent division by small numbers)
    /// </summary>

    public static List<Segment> Fuse(
        List<Segment> segments,
        string query,
        BM25Scorer bm25,
        int k = 60,
        int topK = 20)
    {
        // Rank by dense similarity
        var byDense = segments
            .Where(s => s.Embedding != null)
            .OrderByDescending(s => s.QuerySimilarity)
            .ToList();
        
        // Rank by BM25 (scorer is built over the same ordered segment list)
        var bm25Scores = segments
            .Select((s, i) => (segment: s, score: bm25.Score(i, query)))
            .OrderByDescending(x => x.score)
            .Select(x => x.segment)
            .ToList();
        
        // Rank by salience (pre-computed importance)
        var bySalience = segments
            .OrderByDescending(s => s.SalienceScore)
            .ToList();
        
        // Compute RRF scores
        var rrfScores = new Dictionary<Segment, double>();
        
        void AddRRFScore(List<Segment> ranking)
        {
            for (int i = 0; i < ranking.Count; i++)
            {
                var segment = ranking[i];
                var rrfContribution = 1.0 / (k + i + 1);  // 1-based rank
                
                if (!rrfScores.TryAdd(segment, rrfContribution))
                    rrfScores[segment] += rrfContribution;
            }
        }
        
        AddRRFScore(byDense);
        AddRRFScore(bm25Scores);
        AddRRFScore(bySalience);
        
        // Return top-K by fused score
        return rrfScores
            .OrderByDescending(kv => kv.Value)
            .Take(topK)
            .Select(kv => kv.Key)
            .ToList();
    }
}

BM25: Sparse Retrieval Workhorse

BM25 (Best Matching 25) är den klassiska informationupphävningsalgoritmen

public class BM25Scorer
{
    private const double K1 = 1.5;  // Term frequency saturation
    private const double B = 0.75;  // Length normalization factor
    
    public double Score(int docIndex, string query)
    {
        var queryTerms = Tokenize(query);
        var docTermFreq = _docTermFreqs[docIndex];
        var docLength = _docLengths[docIndex];
        
        double score = 0;
        
        foreach (var term in queryTerms.Distinct())
        {
            if (!docTermFreq.TryGetValue(term, out var tf)) continue;
            if (!_docFreqs.TryGetValue(term, out var df)) continue;
            
            // IDF with smoothing
            var idf = Math.Log((_corpusSize - df + 0.5) / (df + 0.5) + 1);
            
            // BM25 TF component with length normalization
            var tfNorm = (tf * (K1 + 1)) / 
                (tf + K1 * (1 - B + B * docLength / _avgDocLength));
            
            score += idf * tfNorm;
        }
        
        return score;
    }
}

TF-IDF för innehållscentralitet

Problemet: Hur kan man skilja kärnans innehåll från Trivia?

När jag summerar en novell fick jag resultat som

"The protagonist wore a blue coat" Watson märkte att vädret var mild "The study had oak furniture"

Det här är korrekta extraktioner, men de färg (scen grundstenar.

Utmaningen: Hur kan man se skillnaden mellan

  • Kerninnehåll: dyker upp i hela dokumentet
  • Tillhandahållande detaljer: dyker upp i vissa sektioner.
  • Färg: SällsyntM SK1 specifika detaljer ( vad någon hade på sig

Lösningen för Centralitetsklassificering

TF-IDF M SK1Term Frequens - Omvänd dokumentfrequens) uppskattningar hur central en term är för dokumentet, inte dess riktiga värde

Logik:

  • Hög DF (>50% av stycken): "Sherlock", ♫ ♫ "Watson ♫
  • Medium DF (20-50%): "Baker Street", ♫ ♫ " ♫ utforskningen ♫
  • låg DF (<20%): "bla pälsコーta", ♫ ♫ " ♫ åkmöbeln ♫

Det handlar inte om sanning. Ett upprepade påstående kan vara falskt. Ett ovanligt faktum kan vara sant. centralitet till dokumentet.

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;
    }
}

Den kompletta BertRag-tunneln

DocSummarizers produktionsrör (BertRagSummarizer) kombinerar alla dessa koncept

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;
    }
}

vanliga misslyckande former

När man bygger och använder DocSummarizer

  1. Markifier mismatch → nonsens inbäddar: Att ladda upp ett WordPiece vocab för en BPE

  2. Dominant-topicisk bias i enstakaM SK1centroid poäng: Genom att använda ett dokument som är centraliserat systematiskt nedåt - rankar minoritetsämnen ( begränsningar M SK3 undantag, , randprov, ). Multi

  3. BM25 slår mot tät sökning på ovanliga ord: Om din fråga innehåller tekniskt jargon eller korrekta namn som inte är bra -representerad i inbäddade modellerna MSC2 utbildningsdata M SK3 lexisk matchning ( BM MST5 kommer att överträffa semantiska sökningar Mst6 Det är därför hybrid sökningar är viktiga M st7

  4. OCR skräp i scannade PDFs: Dokling är bra , men OCR-errorer är sammansatta M SK2 Om du ser nonsens i sammanfattningar , kolla markerdown-utgången från Docling först Mska4 kan sammanfattaren lösa problem

  5. Low- skyddsförteckningar måste skydda språket: Om du ' ser bara | 3% av ett dokument ♪ , ♪ fraser som ♫ " ♪ slutligen ♪ МSK5 ♪ eller ♫ " ♪ i slutsatsen ♪" ♪ är otrevliga ♪

  6. Citationshallucinationer: Kleina LLMs (1.5B-3B paramerM SK3 ibland uppfinner trovärdigaMSC4ljudande citatMST5 Vi validerar genom att analysera utgången för [chunk-N], bekräftar att N existerar i källskivorna , och markerar eller reparerar påståenden som nämner saknade skivor [chunk-999] för ett 10- snork dokument , ditt LLM kämpar med uppgifterna

De är inga bugs, de är inhärdliga spänningar i designrummet. Bra produktionssystem erkänner och dämpar dem.

praktiska övervägningar

Omfattning och godhet när man samlar

När man bearbetar väldigt stora dokument, använder DocSummarizer inte, ', att försöka inbädda allting. Det betyder att sammanfattningen är baserad på ett prov, inte hela dokumentet.

Systemet hanterar detta transparentt:

// 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}";

Nyckel: Det här är en sammanfattning av bevis som tagits fram, inte en garantera för full dokumentskydd - När vi säger "samplerade 3%", att ' är precis vad som hände M- så såg systemet ♫ 3% av dokumentet och summerade det ♫

Samplingen är inte slumpmässigt - it 's semantisk. Vi använder multi-, -, ankarclustering för att se till att minoritetsämnen inte är excluserade ', ., en slumpmässig 3%, kan missa alla begränsningar och marginalfall. M SK5, en semantisk МSK6, försöker fånga ett representativt segment från varje större tema.

Anpassningssampling med flera temaankar: Filteret före - använder flera ankar | ( | k | - | betyder - | strukturerad klusterläggning av en skiftad prover | МSK5 | för att säkerställa att minoritetsämnen inte är excludererade |' | utan systematiskt släppt ut |

Från 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));

Det här är forskning -informerat ( att undvika ett enda ♫ - ♫ datan återhämtades ♫ МSK3 ♫ men praktiskt ♫- ♫ så funkar det i sekunder på CPU ♫

Varför inte bara inbädda allt?

För ett 500-sidadokument, (2,000+ segmenter, ),, skulle allt sättas in fungera men är det inte optimalt?

  • kostnaderna: OM SK1N) embeddings dominerar urlauftid . På ~150 segment, /sek, , så är det samma ♫ 's ♫13+ ♫ sekunder bara för inbädding innan du ens börjar plocka upp data
  • Jakt: Att inbädda allting ökar oljudet i att hämta data . Du behöver fortfarande rankingen , så varför inbäddar segment som aldrig kommer att rankas högt
  • Praktiskhet: minnes- och latensbegränsningar är viktiga. Att hålla 2,000 384-dim-vektorer i minnet och beräkningar | | cosine likheter per query är slösaktigt när man bara behöver den övre |

Multi- -ankerprobering ger dig det bästa av både - -omfattande ämnen med hjälp av användbar dator.

Kontextt dragning: Varför tar man fram data utan' Bara söka

Det jag tidigare beskrev är Begränsat Fuzzy Kontexttdragning (CFCD). Insikten

De flesta summerare fortsätter att lägga till kontext. DocSummarizer drar framåt bara det som överlever deterministiskt urval, ,, så låter modellen skriva flytande inuti dessa gränser.

Här är vad DocSummarizers pipeline kartlägger till CFCD.

CFCD Concept DocSummarizer Implementation
Saliens-detektor (
Deterministisk promotion MMR
Ledger Den hämtade segmenten sattes ihop med citationsidentifikationer
Begränsade gener Synthes prompt begränsad av uppfunna bevis

Varför är detta viktigt?: Modellet bestämmer inte vad som är relevant för ', -, återvinningssledningen gör, M SK4, Modellen genererar bara flytandet inom gränserna vi har set, ', Mska6, Det är därför små lokala modeller fungerar, Mske7, ankaren klarar av den tunga lyftningen.

Ledgern " i praktiken ser ut så här.

{
  "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"
  }
}

Sedan i syntesisen prompten:

  • "Kader claim måste citera ett inbegript chunk ID
  • "Om vaktnad <
  • " Använd dessa termer konsekvent

Det är därför

  • Längare kontextfenster är en röd herring Problemet är att bestämma vad som förtjänar överlevnad
  • Rekursiv summering misslyckas - den behandlar mellanresultatet som text
  • LLM kan förstärka begränsningar -, de, ', "strukturen," ,, "är inte ett ordspråk som kan slippa från

CFCD är samma filosofiska splittring som Begränsad otydighet, Begränsad Fuzzy MoM, och Image Summarizer - sannolikhet föreslår

Kvantisering

ONNX-modeller kan mätas i kvantitet (reducerad precision) för mindre storlek och snabbare gissningar

Modell Full Precision Quantiserad МSK3 Stor skillnad i kvalitet
alla -MiniLM
bge-small -enMska3vM Ska4 mska5 Mska6MB

Batchprocessing och konkurrerande

För stora dokument InferenceSession kan generellt delads över trådar på ett säkert sätt, men kvaliteten beror på sessiekonfigurationen

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;
}

Performancetips: Konfigurera SessionOptions när man skapar sessien:

var sessionOptions = new SessionOptions
{
    IntraOpNumThreads = 4,  // Threads within a single operation
    InterOpNumThreads = 2   // Threads across operations
};
var session = new InferenceSession(modelPath, sessionOptions);

Memory Management för stora dokument

Väldigt stora dokument (

// 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);
}

Förmågor och egenskaper

Verklig performance på ett typiskt utvecklande maskineri.

Operation Utgångsrikedom Notar
Inbäddar ~150 segmenterM SK2sek Storleken i en grupp M64, alla-MiniLMMska6LMka7vMkka8 kvantifierad
Dense retrieval <10ms ♫ ♫ ♫ Kosineliknande över ♫
BM25 poäng
RRF fusion <2ms Kombinera 3 Ranger 4
Slutar-till- slutar (25-sida PDFM SK3 ~15-20s ♫ ♫ Inbegriper chunking ♫

Försöksmiljö: Ryzen | 5600 | X | МSK2 | Core | 3 | 4 | GB RAM | 5 | no GPU | 6 | Embedding använder alla | 7 | MiniLM | 8 | L | 9 | v | 10 | 11 | Quantifierad | 12 | 13 | Token max | 14 | 15 | Thread parallel batching | 16 | Retrieval corpus | 17 | 18 | Segment | 19 | Dina andelen varierar med olika modeller | 20 | Hardware | 21 | och dokument komplexitet | 22

Den huvudsakliga drivern är att bygga in genomströmning (modellval | + | tokenlängd |+ | lagerstorlek | МSK3 | spårning och fuktion är i grunden fria ♫ - | de tar millisekundar . | Detta förstärker | " | LLM | lm | last | " | princip | : | gör billig CPU-arbete ♪ ( | embedding |, | dragning | Sk11 | första | sk12 | dyrt LLM-arbete bara på filtrerad innehåll |

Skalering: Den hierarkiska extraktionen hanterar

sammanfattning

DocSummarizer demonstrerar att sofistikerade NLP-skapaciteter inte kräver moln API eller Python- beroenden.

  • körs helt lokalt
  • Producerar spårbara sammanfattningar
  • Handler dokument av vilken storlek som helst
  • Fungerar offline

De viktigaste insikterna från att bygga detta verktyg:

  1. Embedding är grunden - Bra dragning är beroende av bra inbäddar
  2. Hybridsökningar slår antingen ensam - Kombinera semantik och lexik för robusthet
  3. MMR förhindrar upprepande - mångfald är lika viktig som relevans
  4. Strukturfrågor - Att respektera dokumentstrukturen | ( | Rubriker | , | sektioner |) | förbättrar resultaten
  5. LLMs borde vara sista - Gör billig CPU första gången, Den dyra LLM fungerar bara på filtrerad innehåll

Fortsättning avläsning

Dokument och tekniska referenser

  • Hur Neurola maskinöversättning fungerar - Omfattar transformeringsarkitekturen , uppmärksamhetsmekanismer , och inbäddar från en översättningssynvinkel | | många av samma koncept gäller för att förstå dokument

Att skriva upp serien

Detta är slutsatsen till DocSummarizer-serien

Del 1 förklarar varför pipeline-metoden slår mot naiva LLM ringer. Den täcker de arkitekturella mönsterna | ( | knuffande |, | hierarkisk reduktion | МSK3 | citationsvalidation ♫ ) | som gör att vilket dokument som summerar fungerar bra |

Del 2 är din snabb guide.

Del 3 (den här artikeln ) är djupdykning för människor som vill förstå hur det fungerar faktiskt: BERT mot meningstransformatorer, varför ONNX spelar rollM SK2 tokeniseringsköp, , hybridsökningshandel,- avgångar, , och vad som går sönder i produktionen

Om du' bygger ditt eget rörverk , så läser du alla tre.

Binärligt

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.