Σημείωση: Αυτό το άρθρο διδάσκει τα βασικά στοιχεία της εξαγωγής περιεχομένου ιστού με LLMs χρησιμοποιώντας την απλούστερη δυνατή προσέγγιση. DocSummarizer
- εφαρμόζει την αρχιτεκτονική που φαίνεται εδώ, αλλά παραγωγής-βαθμού: BERT subbeddings, υβριδική αναζήτηση, Playwright για ΖΕΠ, προστασία SSRF, επικυρωμένες αναφορές, και σωστή παρακολούθηση κάλυψης.
Όταν ζητάτε από την ChatGPT να "διαβάσει αυτό το άρθρο και να το συνοψίσει," τι πραγματικά συμβαίνει; Αν φαντάζεστε το AI να ανοίγει ένα πρόγραμμα περιήγησης και να διαβάζει όπως θα το κάνατε - αυτό δεν είναι το πώς λειτουργεί.
Τα LLM δεν περιηγούνται στον ιστό. Λογικεύονται πάνω από θραύσματα που επιλέγει ο κώδικας σας.
Αυτό το άρθρο σας δείχνει πώς να οικοδομήσουμε αυτό το σε C# με Ollama - δεν πλαίσια, μόνο πρακτικό κώδικα μπορείτε να αποσφαλματώσετε.
TL;DR: Ο κώδικάς σας → καθαρίζει → κομμάτια → επιλέγει. Η LLM βλέπει μόνο τα θραύσματα που κάνετε την ένεση.
Εδώ είναι η διορατικότητα που αλλάζει το πώς φτιάχνετε αυτά τα συστήματα:
Η επιλογή είναι η απόφαση του προϊόντος. Είναι επίσης εκεί που προέρχονται οι περισσότερες αποτυχίες.
Το LLM είναι κατάντη της επιλογής σας. Δεν μπορεί να ανακτήσει πληροφορίες που δεν το δείξατε. Όταν ένας πράκτορας "χτυπά για να βρει την απάντηση," το πρόβλημα είναι σχεδόν ποτέ το μοντέλο - είναι ότι η λογική επιλογής σας επέλεξε τα λάθος κομμάτια. (Αυτή είναι η ίδια αρχή πίσω από γιατί αποφεύγω τα πλαίσια όπως η LangChain - αφαίρεσαν τη λογική επιλογής που χρειάζεστε για να αποσφαλματώσετε.)
Αυτός είναι ο λόγος για τον οποίο η "αναζήτηση πράκτορα" αποτυγχάνει σιωπηλά. Ο πράκτορας με αυτοπεποίθηση απαντά με βάση ό, τι είδε. Ποτέ δεν ξέρετε ότι έχασε το σωστό περιεχόμενο.
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
Το μοντέλο μπορεί να λογικευτεί μόνο για το τι του δώσατε.
Πριν γράψω οτιδήποτε, αυτοί είναι οι περιορισμοί:
Κρατήστε το γιατί έχει σημασία. |------------|----------------| | Καμία απόδοση JS Μόνο στατική HTML (Playwright for SPAs) | Κομμένες μάρκες Ο σκληρός προϋπολογισμός ανά αίτηση (2-4K μάρκες τυπικό) □ | Απαντήσεις με βάση την πηγή Η LLM δεν πρέπει να έχει παραισθήσεις στις γνώσεις του διαδικτύου. | Καθοριστική επιλογή Ίδια είσοδος → ίδια κομμάτια (αποσφαλματωτής) | Παρατηρητέο □ Καταγράψτε τι επιλέξατε, γιατί, και τι πετάξατε
Αν δεν μπορείς να εξηγήσεις γιατί επιλέχθηκε ένα κομμάτι, δεν μπορείς να αποκαλύψεις αποτυχίες.
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
Κάθε βήμα μειώνει τα δεδομένα. Μέχρι τη στιγμή που το LLM βλέπει, έχετε πάει από 57KB HTML σε ίσως 2KB του σχετικού κειμένου. Κάθε μείωση είναι χαμένη. Κάθε μείωση μπορεί να απορρίψει την απάντηση. Αυτό είναι το ίδιο μοτίβο που χρησιμοποιώ για ανάλυση μεγάλων αρχείων CSV - τους λόγους LLM, ο κώδικας σας υπολογίζει και επιλέγει.
Σε όλο αυτό το άρθρο, θα χρησιμοποιήσουμε ένα URL:
https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/overview
Και τρία ζητήματα αυξανόμενης εξειδίκευσης:
Αυτό κρατά τα παραδείγματα προσγειωμένα και δείχνει πώς η επιλογή μετράει περισσότερο ως ερωτήσεις γίνονται συγκεκριμένες.
# 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 note: Στην έκδοση 5.x,
GenerateAsyncεπιστροφέςIAsyncEnumerable<GenerateResponseStream?>- ρέει σημάδια όπως παράγονται.await foreach. Το δείγμα του έργου καρφίτσες 5.1.5.
Πρότυπο HTTP, αλλά με τις λεπτομέρειες που έχουν σημασία:
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();
}
Τι είναι αυτό που κρατάει;: Redirects (capped), συμπίεση, timeouts, User-Agent, content-type validation.
Αυτό που δεν κάνει: JavaScript απόδοση, εξακρίβωση ταυτότητας, περιορισμός ταχύτητας, ρομπότ.txt. Για την παραγωγή, προσθέστε per-host καθυστερήσεις και σεβασμό crawl πολιτικές.
Ακατέργαστο HTML είναι ως επί το πλείστον θόρυβος. Καθαρίστε το ή σπαταλάτε μάρκες στη διάταξη.
Μια τυπική σελίδα:
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
Ο καθαριστής σας μπορεί να διαγράψει την απάντηση.
.article-body, .post-text)Μητρώσεις:
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);
Εκχύλιση αναγνωσιμότητας Για την ώρα, με βάση τον επιλογέα εργασίες εξόρυξης για τεκμηρίωση και blogs. Το δείγμα έργο περιλαμβάνει έναν εκχυλιστή βαθμολόγησης αν το χρειάζεστε.
Έχεις 6KB καθαρού κειμένου.
Η στρατηγική για το κυνήγι έχει μεγαλύτερη σημασία απ' ό,τι περίμενες. Άρθρο αρχιτεκτονικής RAG - οι ίδιες αρχές ισχύουν είτε είστε τεμαχίζοντας ιστοσελίδες ή έγγραφα.
Αυτό είναι ένα σημείο εκκίνησης, όχι κωδικός παραγωγής:
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);
Γιατί αυτό είναι αφελές:
". " διαλείμματα στο "Dr. Smith," "v1.0," URLsΓια τεκμηρίωση, τεμαχίστε ανά τμήμα:
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;
}
Αυτό διατηρεί τη δομή του εγγράφου και καθιστά την επιλογή πιο ουσιαστική.
Εδώ συμβαίνουν οι περισσότερες αποτυχίες... και από πού πρέπει να ξεκινήσει η αποσφαλμάτωση.
Έχετε 5 κομμάτια. Ο χρήστης ρώτησε "Ποιες βελτιώσεις απόδοσης είναι σε .NET 10;" Μόνο 1-2 κομμάτια αναφέρουν την απόδοση.
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" };
Βασικές βελτιώσεις σε σχέση με την αφελή καταμέτρηση:
Οι λέξεις-κλειδιά αποτυγχάνουν στα συνώνυμα. Το "perf" δεν ταιριάζει με τις "βελτιώσεις απόδοσης."
Αν θες να πας πιο βαθιά στις ενδυμασίες και στην αναζήτηση διανυσματικών στοιχείων, το καλύπτω εκτενώς στην Σειρά primer RAG και σημασιολογική αναζήτηση με το 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();
}
Αμοιβές:
Για την παραγωγή, cache παρεμβολές ανά (URL, chunk hash) σε SQLite ή μια διανυσματική βάση δεδομένων όπως QdrantCity name (optional, probably does not need a translation).
Δομή της ώθησης για βίαιες απαντήσεις που συνδέονται με την πηγή με παραπομπή:
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();
}
Αυτό μετακινείται από "συζήτηση συνομιλίας" σε "ανάλυση με προέλευση."
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();
}
Αυτό που φτιάξαμε είναι ένα μοτίβο πράκτορα χωρίς το πλαίσιο. γιατί προτιμώ αυτή την προσέγγιση από την LangChain - η ρητή ενορχήστρωση νικάει τις μαγικές αφαίρεσεις όταν αποσφαλματώνεις θέματα.
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
Ο βρόχος: εάν η εμπιστοσύνη είναι χαμηλή, δοκιμάστε ξανά με περισσότερα κομμάτια ή διαφορετικές λέξεις-κλειδιά.
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
Ο κανόνας αποσφαλμάτωσης:
Αν η απάντηση είναι λάθος, είναι σχεδόν πάντα επειδή Η επιλογή ήταν λάθος., όχι επειδή το μοντέλο απέτυχε.
Η αποτυχία συνήθως δεν είναι το LLM.
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();
}
Χρήση με το τρέχον παράδειγμα μας:
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);
Η έξοδος περιλαμβάνει αναφορές και αυτοπεποίθηση:
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
Δουλεύει καλά δεν δουλεύει |------------|--------------| Τεκμηριώσεις JavaScript SPA Δημοσιεύσεις ιστολογίου, άρθρα Δυναμικό/διαδραστικό περιεχόμενο Τεχνικές αναφορές Πολυσέλιδο έρευνα Στατικό HTML & Authenticated content
Για JS-heavy sites, χρειάζεστε Playwright για .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
Η LLM μπορεί μόνο να λογικευτεί σχετικά με το τι του δώσατε.
Μην ζητήσεις από το LLM να ψάξει.
Πλήρης εφαρμογή της εργασίας: Κυρίως διαυγής.LlmWebFetcher
Περιλαμβάνει:
WebFetcher - HTTP με σωστό χειρισμόHtmlCleaner - Απομάκρυνση θορύβου + στρατηγικές οπισθοδρόμησηςContentChunker - Καταδίκη και με βάση τον τίτλο chunkingWebContentAnalyzer - Πλήρης αγωγός με καταγραφήOllamaExtensions - Βοηθός για streaming απαντήσειςcd Mostlylucid.LlmWebFetcher
dotnet run
Βιβλιοθήκες
LLMsunit synonyms for matching user input
Σχετικά άρθρα
Στοίβα Microsoft AI
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.