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
Friday, 19 December 2025
Notitie: Dit artikel leert de basisprincipes van webcontent extractie met LLM's met behulp van de eenvoudigst mogelijke aanpak. Voor productie use cases (websamenvatting, document analyse, agent tools), zie DocSummarizer
- het implementeert de hier getoonde architectuur maar productie-kwaliteit: BERT inbeddingen, hybride zoekopdracht, Playwright voor SPA's, SSRF bescherming, gevalideerde citaten, en de juiste dekking tracking.
Als je ChatGPT vraagt om "lees dit artikel en vat het samen," wat gebeurt er dan eigenlijk? Als je je voorstelt dat de AI een browser opent en leest zoals je zou doen - zo werkt het niet.
LLM's surfen niet op het web. Ze redeneren over fragmenten die uw code selecteert.
Dit artikel laat zien hoe je dit kunt bouwen in C# met Ollama - geen kaders, alleen praktische code die je kunt debuggen.
TL;DR: Uw code haalt → reinigt → brokken → selecteert. De LLM ziet alleen de fragmenten die u injecteert. Selectie is verlies door ontwerp - dat is zowel de beperking als de architectuur.
Hier is het inzicht dat verandert hoe je deze systemen bouwt:
Selectie is de productbeslissing. Het is ook waar de meeste mislukkingen ontstaan.
De LLM is stroomafwaarts van uw selectie. Het kan informatie die u niet hebt getoond niet herstellen. Wanneer een agent "fails om het antwoord te vinden," het probleem is bijna nooit het model - het is dat uw selectie logica koos de verkeerde brokken. (Dit is hetzelfde principe achter waarom ik kaders als LangChain vermijd - ze abstracteren de selectie logica die je nodig hebt om te debuggen.)
Dit is de reden waarom "agent surfen" faalt stilletjes. De agent antwoordt zelfverzekerd op basis van wat het zag. Je weet nooit dat het miste de juiste inhoud.
flowchart LR
URL[Full Page] --> Select[Your Selection]
Select --> LLM[LLM Sees This]
LLM --> Answer[Answer]
URL -.->|"50KB"| Select
Select -.->|"2KB"| LLM
Miss[Missed Content] -.->|"Never seen"| X[❌]
style Select stroke:#e74c3c,stroke-width:3px
style Miss stroke:#95a5a6,stroke-width:2px,stroke-dasharray: 5 5
Het model kan alleen redeneren over wat je het gaf. Bouw dienovereenkomstig.
Alvorens iets te schrijven, dit zijn de beperkingen:
Waarom het er toe doet |------------|----------------| | Geen JS-weergave Alleen HTML (Playwright voor SPA's) | Gebonden tokens Hard budget per aanvraag (met 2-4K tokens typisch) | Brongebonden antwoorden LLM mag webkennis niet hallucineren | Deterministische selectie Zelfde invoer → zelfde brokken (debugable) | Waarneembaar Log in wat je hebt geselecteerd, waarom en wat je hebt weggegooid
Als je niet kunt uitleggen waarom een brok is geselecteerd, kun je geen fouten debuggen.
flowchart TB
URL[URL] --> Fetch[1. Fetch]
Fetch --> Clean[2. Clean]
Clean --> Chunk[3. Chunk]
Chunk --> Select[4. Select]
Select --> LLM[LLM]
LLM --> Answer[Answer]
Fetch -.->|"57KB HTML"| Clean
Clean -.->|"6KB text"| Chunk
Chunk -.->|"5 chunks"| Select
Select -.->|"2 chunks"| LLM
style Select stroke:#e74c3c,stroke-width:3px
style Clean stroke:#f39c12,stroke-width:3px
Elke stap vermindert de data. Tegen de tijd dat de LLM het ziet, ben je van 57KB van HTML naar misschien 2KB van relevante tekst gegaan. Elke reductie is verliesloos. Elke reductie kan het antwoord weggooien. Dit is hetzelfde patroon dat ik gebruik voor het analyseren van grote CSV-bestanden - de LLM redenen, uw code berekent en selecteert.
In dit artikel gebruiken we één URL:
https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/overview
En drie vragen van toenemende specificiteit:
Dit houdt de voorbeelden gegrond en laat zien hoe selectie belangrijker is naarmate vragen specifiek worden.
# Install Ollama from https://ollama.ai
ollama pull llama3.2:3b
# NuGet packages
dotnet add package AngleSharp # HTML parsing
dotnet add package OllamaSharp # Ollama client (5.1.x)
OllamaSharp-notitie: In versie 5.x,
GenerateAsyncgeeftIAsyncEnumerable<GenerateResponseStream?>Het stroomt tokens als ze worden gegenereerd.await foreach. De steekproef project pinnen 5.1.5.
Standaard HTTP, maar met de details die er toe doen:
public class WebFetcher : IDisposable
{
private readonly HttpClient _http;
public WebFetcher()
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 5, // Cap redirects
AutomaticDecompression = DecompressionMethods.All
};
_http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
_http.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
}
public async Task<string> FetchAsync(string url)
{
var response = await _http.GetAsync(url);
// Bail if not HTML
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.Contains("html") && !contentType.Contains("text"))
throw new InvalidOperationException($"Not HTML: {contentType}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
public void Dispose() => _http.Dispose();
}
Waar dit mee omgaat.: Redirects (capped), compressie, time-outs, User-Agent, content-type validatie.
Wat het niet doet.: JavaScript rendering, authentication, rate limiting, robots.txt. Voor de productie, voeg per-host vertragingen en respect crawl beleid.
Raw HTML is meestal lawaai. Maak het schoon of verspil tokens op de lay-out.
Een typische pagina:
57KB HTML → 6KB useful text (90% reduction)
flowchart LR
HTML[Raw HTML] --> Remove[Remove Noise]
Remove --> Find[Find Main Content]
Find --> Extract[Extract Text + Headings]
Extract --> Normalize[Normalize]
Remove -.->|"script, style, nav, ads"| Find
Find -.->|"main, article, .content"| Extract
style Remove stroke:#e74c3c,stroke-width:3px
style Find stroke:#f39c12,stroke-width:3px
Uw schoonmaker kan absoluut verwijderen het antwoord. Veel voorkomende fouten:
.article-body, .post-text)Mitigaties:
h1-h6)public class HtmlCleaner
{
private readonly HtmlParser _parser = new();
// Known noise - remove these entirely
private static readonly string[] NoiseElements =
{ "script", "style", "nav", "footer", "aside", "iframe", "noscript" };
// Boilerplate patterns - remove by role, not aggressive wildcards
private static readonly string[] NoiseSelectors =
{
"[role='navigation']", "[role='banner']", "[role='complementary']",
"[class*='cookie']", "[class*='newsletter']", "[aria-hidden='true']"
};
// Where to find content - order matters (most specific first)
private static readonly string[] ContentSelectors =
{ "main", "article", "[role='main']", ".content", ".post-content" };
public CleanResult Clean(string html)
{
var doc = _parser.ParseDocument(html);
// Remove noise
foreach (var tag in NoiseElements)
foreach (var el in doc.QuerySelectorAll(tag).ToList())
el.Remove();
foreach (var selector in NoiseSelectors)
foreach (var el in doc.QuerySelectorAll(selector).ToList())
el.Remove();
// Find main content
IElement? main = null;
string? matchedSelector = null;
foreach (var selector in ContentSelectors)
{
main = doc.QuerySelector(selector);
if (main != null) { matchedSelector = selector; break; }
}
// Fallback to body if main content is suspiciously short
var text = main?.TextContent ?? "";
if (text.Length < 500 && doc.Body != null)
{
main = doc.Body;
matchedSelector = "body (fallback)";
text = main.TextContent;
}
return new CleanResult
{
Text = NormalizeWhitespace(text),
MatchedSelector = matchedSelector ?? "none",
OriginalLength = html.Length
};
}
private string NormalizeWhitespace(string text)
{
text = Regex.Replace(text, @"[ \t]+", " ");
text = Regex.Replace(text, @"\n\s*\n+", "\n\n");
return text.Trim();
}
}
public record CleanResult(string Text, string MatchedSelector, int OriginalLength);
Extractie van leesbaarheid (Scoring paragraphs, text density) is een heel konijnenhol. Voor nu, selecte-based extractie werkt voor documentatie en blogs. Het voorbeeld project bevat een scoren extractor als je het nodig hebt.
Je hebt 6KB aan schone sms'jes.
Chunking strategie is belangrijker dan je zou verwachten. Ik bedek dit in de diepte in de RAG architectuur artikel - dezelfde principes gelden of je nu webpagina's of documenten aan het doorknippen bent.
Dit is een beginpunt, geen productiecode:
public List<string> ChunkBySentence(string text, int maxTokens = 2000)
{
var chunks = new List<string>();
// WARNING: This breaks on abbreviations, decimals, URLs, code samples
var sentences = text.Split(new[] { ". ", ".\n", "! ", "? " },
StringSplitOptions.RemoveEmptyEntries);
var current = new StringBuilder();
var tokens = 0;
foreach (var sentence in sentences)
{
var sentenceTokens = EstimateTokens(sentence);
if (tokens + sentenceTokens > maxTokens && current.Length > 0)
{
chunks.Add(current.ToString().Trim());
current.Clear();
tokens = 0;
}
current.Append(sentence).Append(". ");
tokens += sentenceTokens;
}
if (current.Length > 0)
chunks.Add(current.ToString().Trim());
return chunks;
}
// Rough estimate - OK for demos, not for billing
private int EstimateTokens(string text)
=> (int)(text.Split(' ').Length * 1.3);
Waarom dit naïef is:
". " pauzes op "Dr. Smith," "v1.0," URL'sVoor documentatie, brok per sectie:
public List<ContentChunk> ChunkByHeadings(string html)
{
var doc = new HtmlParser().ParseDocument(html);
var chunks = new List<ContentChunk>();
var headings = doc.QuerySelectorAll("h1, h2, h3");
foreach (var heading in headings)
{
var content = new StringBuilder();
content.AppendLine(heading.TextContent);
var sibling = heading.NextElementSibling;
while (sibling != null && !sibling.TagName.StartsWith("H"))
{
content.AppendLine(sibling.TextContent);
sibling = sibling.NextElementSibling;
}
chunks.Add(new ContentChunk
{
Heading = heading.TextContent.Trim(),
Content = content.ToString().Trim(),
HeadingLevel = int.Parse(heading.TagName[1..])
});
}
return chunks;
}
Dit behoudt de documentstructuur en maakt selectie zinvoller.
Dit is waar de meeste mislukkingen gebeuren. En waar debuggen zou moeten beginnen.
Je hebt 5 brokken. De gebruiker vroeg "Welke prestatieverbeteringen zijn in .NET 10?" Slechts 1-2 brokken noemen prestaties. Stuur deze.
flowchart TB
Q["Question: What perf improvements?"] --> Score[Score Each Chunk]
subgraph Chunks
C1["Chunk 1: Overview..."]
C2["Chunk 2: Runtime perf..."]
C3["Chunk 3: Libraries..."]
C4["Chunk 4: SDK changes..."]
end
Score --> C1
Score --> C2
Score --> C3
Score --> C4
C2 -->|"score: 3"| Top[Selected]
C3 -->|"score: 1"| Top
style C2 stroke:#27ae60,stroke-width:3px
style C1 stroke:#95a5a6,stroke-width:2px,stroke-dasharray: 5 5
style C4 stroke:#95a5a6,stroke-width:2px,stroke-dasharray: 5 5
public record ScoredChunk(string Content, string? Heading, int Score, List<string> MatchedKeywords);
public List<ScoredChunk> SelectByKeywords(
List<ContentChunk> chunks,
string question,
int topK = 3)
{
// Normalize and filter stopwords
var keywords = question.ToLower()
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length > 3)
.Where(w => !Stopwords.Contains(w))
.Select(w => w.Trim(',', '.', '?', '!'))
.Distinct()
.ToList();
var scored = chunks.Select(chunk =>
{
var text = (chunk.Heading + " " + chunk.Content).ToLower();
var matched = keywords.Where(kw => text.Contains(kw)).ToList();
// Boost if keyword appears in heading
var headingBoost = chunk.Heading != null &&
keywords.Any(kw => chunk.Heading.ToLower().Contains(kw)) ? 2 : 0;
return new ScoredChunk(
chunk.Content,
chunk.Heading,
matched.Count + headingBoost,
matched
);
})
.OrderByDescending(x => x.Score)
.Take(topK)
.ToList();
// LOG THIS - it's your debugging lifeline
foreach (var s in scored)
Console.WriteLine($" [{s.Score}] {s.Heading ?? "(no heading)"}: {string.Join(", ", s.MatchedKeywords)}");
return scored;
}
private static readonly HashSet<string> Stopwords = new()
{ "what", "how", "does", "the", "are", "is", "in", "for", "of", "to", "and" };
Belangrijkste verbeteringen boven naïef tellen:
Trefwoorden falen op synoniemen. "perf" komt niet overeen met "prestatieverbeteringen."
Inbeddingen vinden semantische gelijkenis. Als je dieper wilt gaan op inbeddingen en vector zoeken, ik bedek dit uitgebreid in de RAG primer series en semantisch zoeken met ONNX.
public async Task<List<ScoredChunk>> SelectByEmbedding(
List<ContentChunk> chunks,
string question,
int topK = 3)
{
var questionEmbed = await EmbedAsync(question);
// Cache these per URL in production
var scored = new List<(ContentChunk Chunk, double Score)>();
foreach (var chunk in chunks)
{
var chunkEmbed = await EmbedAsync(chunk.Content);
var similarity = CosineSimilarity(questionEmbed, chunkEmbed);
scored.Add((chunk, similarity));
}
return scored
.OrderByDescending(x => x.Score)
.Take(topK)
.Select(x => new ScoredChunk(x.Chunk.Content, x.Chunk.Heading, (int)(x.Score * 100), new()))
.ToList();
}
private async Task<double[]> EmbedAsync(string text)
{
var request = new EmbedRequest { Model = "nomic-embed-text", Input = [text] };
var response = await _ollama.EmbedAsync(request);
return response.Embeddings.First().ToArray();
}
Afhandelen:
Voor productie, cache inbeddingen per (URL, chunk hash) in SQLite of een vector database zoals Qdrant.
Structuur van de prompt om brongebonden antwoorden te forceren met aanhaling:
public string BuildPrompt(string url, List<ScoredChunk> chunks, string question)
{
var sb = new StringBuilder();
sb.AppendLine("You are answering a question using ONLY the content below.");
sb.AppendLine("Rules:");
sb.AppendLine("- Answer ONLY from the provided sources");
sb.AppendLine("- Cite which SOURCE number supports each claim");
sb.AppendLine("- Include 1-2 brief quotes as evidence");
sb.AppendLine("- If the answer isn't in the sources, say 'Not enough information'");
sb.AppendLine("- End with Confidence: High/Medium/Low");
sb.AppendLine();
for (int i = 0; i < chunks.Count; i++)
{
sb.AppendLine($"=== SOURCE {i + 1} ===");
if (chunks[i].Heading != null)
sb.AppendLine($"Section: {chunks[i].Heading}");
sb.AppendLine($"From: {url}");
sb.AppendLine(chunks[i].Content);
sb.AppendLine();
}
sb.AppendLine($"Question: {question}");
sb.AppendLine();
sb.AppendLine("Answer (with citations and confidence):");
return sb.ToString();
}
Dit gaat van "chatty summary" naar "analyse met herkomst."
public async Task<string> AskAsync(string prompt)
{
var request = new GenerateRequest { Model = "llama3.2:3b", Prompt = prompt };
var response = new StringBuilder();
await foreach (var chunk in _ollama.GenerateAsync(request))
{
if (chunk?.Response != null)
response.Append(chunk.Response);
}
return response.ToString().Trim();
}
Wat we hebben gebouwd is een agent patroon zonder het kader. Waarom heb ik liever deze aanpak dan LangChain - expliciete orkestratie is beter dan magische abstracties bij debuggen.
flowchart LR
subgraph Tools["Tools (Deterministic)"]
T1[fetch_url]
T2[clean_html]
T3[chunk_text]
T4[select_relevant]
end
subgraph LLM["LLM (Reasoning)"]
R[Interpret + Answer]
end
T1 --> T2 --> T3 --> T4 --> R
R -->|"Low confidence"| Retry[Retry with different selection]
Retry --> T4
style T4 stroke:#e74c3c,stroke-width:3px
style R stroke:#3498db,stroke-width:3px
De lus: als het vertrouwen laag is, probeer het opnieuw met meer brokken of verschillende trefwoorden.
var answer = await AskAsync(prompt);
if (answer.Contains("Not enough information") || answer.Contains("Confidence: Low"))
{
// Retry with more chunks
var moreChunks = SelectByKeywords(allChunks, question, topK: 5);
answer = await AskAsync(BuildPrompt(url, moreChunks, question));
}
flowchart TB
subgraph Failures["Failure Modes"]
F1[Cleaner removes content]
F2[Chunking breaks mid-thought]
F3[Selection picks wrong chunks]
F4[LLM hallucinates connections]
end
F1 --> R1["'Not enough info' - answer existed"]
F2 --> R2["Partial answer - context lost"]
F3 --> R3["Wrong answer - right content skipped"]
F4 --> R4["Confident but wrong"]
style F3 stroke:#e74c3c,stroke-width:3px
De debugregel:
Als het antwoord verkeerd is, is het bijna altijd omdat selectie was fout, niet omdat het model faalde.
De storing is meestal niet de LLM, maar stroomopwaarts.
public class WebAnalyzer : IDisposable
{
private readonly WebFetcher _fetcher = new();
private readonly HtmlCleaner _cleaner = new();
private readonly OllamaApiClient _ollama = new(new Uri("http://localhost:11434"));
public async Task<AnalysisResult> AnalyzeAsync(string url, string question)
{
// 1. Fetch
var html = await _fetcher.FetchAsync(url);
// 2. Clean (with observability)
var cleaned = _cleaner.Clean(html);
Console.WriteLine($"Cleaned: {cleaned.OriginalLength} → {cleaned.Text.Length} bytes ({cleaned.MatchedSelector})");
// 3. Chunk
var chunks = ChunkByHeadings(html);
Console.WriteLine($"Chunks: {chunks.Count}");
// 4. Select (with logging)
Console.WriteLine("Selection scores:");
var selected = SelectByKeywords(chunks, question, topK: 3);
// 5. Prompt + LLM
var prompt = BuildPrompt(url, selected, question);
var answer = await AskAsync(prompt);
return new AnalysisResult
{
Answer = answer,
ChunksUsed = selected.Count,
SelectionScores = selected.Select(s => s.Score).ToList()
};
}
public void Dispose() => _fetcher.Dispose();
}
Gebruik met ons draaiende voorbeeld:
using var analyzer = new WebAnalyzer();
var result = await analyzer.AnalyzeAsync(
"https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/overview",
"What performance improvements are in .NET 10?"
);
Console.WriteLine(result.Answer);
Output omvat aanhalingstekens en vertrouwen:
Based on SOURCE 1 and SOURCE 2:
.NET 10 includes several performance improvements:
- JIT improvements including better inlining and method devirtualization (SOURCE 1)
- "Enhanced loop inversion for better optimization" (SOURCE 1)
- NativeAOT enhancements for improved code generation (SOURCE 2)
Confidence: High
Werkt goed Werkt niet |------------|--------------| Documentatie JavaScript STA's Blogposts, artikelen en dynamische/interactieve inhoud Technische referenties Multi-page onderzoek . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Geauthentiseerde HTML-inhoud
Voor JS-zware sites, moet je Playwright voor .NET.
flowchart LR
subgraph Your["Your Code's Job"]
direction TB
F[Fetch reliably]
C[Clean carefully]
S[Select correctly]
O[Observe everything]
end
subgraph LLM["LLM's Job"]
direction TB
R[Reason over what you gave it]
A[Admit when it doesn't know]
end
Your --> LLM
style S stroke:#e74c3c,stroke-width:3px
style R stroke:#3498db,stroke-width:3px
De LLM kan alleen redeneren over wat je hem hebt gegeven. Selectie is jouw verantwoordelijkheid.
Vraag de LLM niet om te bladeren, vraag het om te redeneren.
Complete uitvoering van het werk: Meestal lucid.LlmWebFetcher
Omvat:
WebFetcher - HTTP met de juiste handlingHtmlCleaner - Geluidsverwijdering + terugvalstrategieënContentChunker - Verdachtheid en op koers gebaseerde brokstukkenWebContentAnalyzer - Volledige pijpleiding met houtkapOllamaExtensions - Helper voor het streamen van reactiescd Mostlylucid.LlmWebFetcher
dotnet run
Bibliotheken
LLM's
Gerelateerde artikelen
Microsoft AI Stack
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.