Trong quá trình xây dựng lucid RAG, tôi đọc mạng xã hội và thấy mọi người cứ hỏi đi hỏi lại cùng một câu: “Làm thế nào để trích xuất đặc trưng (features) từ văn bản quét (scanned text)?” Câu trả lời sai về bản chất luôn là “chỉ cần dùng LLM”… cách này hoạt động nhưng rất tốn kém. Vì tôi đã đi sâu vào lĩnh vực OCR, tôi nghĩ mình nên viết một bài hướng dẫn “thân thiện với người mới bắt đầu” về cách làm KHÔNG dùng LLM (hoặc LLM là tùy chọn) cho bài toán này.
Bạn có những bức ảnh chứa văn bản. Bạn muốn trích xuất văn bản đó, sau đó tìm cấu trúc hữu ích bên trong (tên người, công ty, địa điểm) mà không cần gọi LLM, không gửi dữ liệu lên cloud, hay trả phí theo token.
Bài viết này trình bày pipeline đơn giản nhất có thể: Tesseract để trích xuất văn bản, sau đó BERT NER (qua ONNX) để nhận diện thực thể. Toàn bộ chạy local. Toàn bộ mang tính xác định (deterministic). Toàn bộ viết bằng C#.
Deterministic ở đây có nghĩa là phiên bản cố định, dữ liệu ngôn ngữ cố định, và không có học thích nghi (adaptive learning) tại thời điểm chạy.
NuGet sắp ra mắt – Tôi đang đóng gói giải pháp này thành thư viện đơn giản
mostlylucid.ocrner. Hiện tại, đoạn mã dưới đây có thể copy-paste dùng ngay.
Mục lục
Toàn bộ Pipeline
Hai bước, hai mô hình, tất cả chạy local. Hãy cùng xây dựng từng phần.
Phần 1: OCR với Tesseract
Tesseract là engine OCR mã nguồn mở tiêu chuẩn. Chúng ta sẽ dùng Tesseract.NET, một wrapper C#.
dotnet add package Tesseract
Bạn cũng cần các file dữ liệu đã huấn luyện. Tải eng.traineddata từ tessdata và đặt vào thư mục tessdata.
using Tesseract;
public static string ExtractText(string imagePath)
{
using var engine = new TesseractEngine("./tessdata", "eng", EngineMode.Default);
using var img = Pix.LoadFromFile(imagePath);
using var page = engine.Process(img);
return page.GetText();
}
Chỉ vậy thôi. Gọi ExtractText("invoice.png") là bạn nhận được một chuỗi văn bản.
Quan trọng:
TesseractEnginerất tốn kém khi tạo mới. Trong ứng dụng thực tế, hãy tạo một lần và tái sử dụng.
Hạn chế
Tesseract hoạt động tốt với văn bản sạch, độ tương phản cao, phông chữ tiêu chuẩn. Nó gặp khó khăn với:
- Phông chữ cách điệu hoặc trang trí
- Ảnh quét hoặc ảnh chụp chất lượng thấp
- Văn bản xoay hoặc cong
- Văn bản trên nền phức tạp
- Ảnh GIF động có phụ đề
- Ngắt dòng có dấu gạch nối (
inter-) có thể cần xử lý hậu kỳ trước khi đưa vào NER
national
Với các hệ thống production cần xử lý dữ liệu phức tạp, hãy xem The Three-Tier OCR Pipeline – pipeline này bổ sung Florence-2 ONNX làm tầng trung gian và nâng cấp lên Vision LLM cho các ca khó.
Với bài hướng dẫn này, chúng ta giả định bạn có ảnh sạch hoặc văn bản từ nguồn khác (trích xuất PDF, copy-paste, v.v.).
Trong thực tế, bạn thường nên chuẩn hóa đầu ra OCR (cắt khoảng trắng thừa, gộp các dòng mới lặp lại, sửa lỗi gạch nối rõ ràng) trước khi đưa vào NER.
Phần 2: NER với ONNX
Tại sao cách tiếp cận này hiệu quả
Trước khi đi sâu vào code, hãy hiểu chúng ta thực sự đang làm gì. Nếu bạn là lập trình viên C# chưa từng đụng đến ML, phần này dành cho bạn.
NER là gì?
Nhận diện thực thể có tên (Named Entity Recognition – NER) là một bài toán đã được giải quyết. Các nhà nghiên cứu đã huấn luyện mạng nơ-ron có thể đọc văn bản và đánh dấu những “phần thú vị”:
- PER – Tên người (“John Smith”, “Dr. Jane Doe”)
- ORG – Tổ chức (“Microsoft”, “NHS”, “Acme Corp”)
- LOC – Địa điểm (“London”, “Mount Everest”)
- MISC – Thực thể khác (“COVID-19”, “iPhone 15”)
Mô hình không “hiểu” văn bản – nó học được các mẫu thống kê từ hàng triệu ví dụ được gán nhãn. NER là trích xuất đặc trưng, không phải suy luận. Nó là khớp mẫu (pattern matching) ở cấp độ cao.
Tại sao chọn ONNX?
ONNX (Open Neural Network Exchange) là định dạng tiêu chuẩn cho các mô hình ML. Hãy nghĩ về nó như một DLL suy luận đóng băng (frozen inference DLL) cho mạng nơ-ron: trọng số cố định vào, tensor ra, không có logic huấn luyện, không có tính ngẫu nhiên.
Ý tưởng chính: người khác đã làm phần khó (huấn luyện mô hình bằng Python). Bạn chỉ cần chạy suy luận trong C#.
Tại sao không chỉ dùng LLM?
Bạn có thể gửi văn bản tới GPT-4 và yêu cầu “tìm người và công ty trong văn bản này”. Cách đó hoạt động! Nhưng:
| Phương pháp | Tốc độ | Chi phí / 1000 tài liệu | Quyền riêng tư | Nhất quán |
|---|---|---|---|---|
| ONNX NER | ~50ms | $0 | Local | Cao |
| Local LLM API | 4–30s | $0 | Mô hình nhỏ có thể không ổn định | Biến thiên |
| LLM API | 1–5s | $20–50 | Dữ liệu bị gửi ra ngoài | Biến thiên |
LLM rất giỏi trong suy luận phức tạp. Còn với trích xuất mẫu ở quy mô lớn, một mô hình chuyên dụng nhanh gấp 40 lần và miễn phí.
Pipeline
Đây là những gì chúng ta sẽ xây dựng:
Mỗi bước đều đơn giản. Hãy đi qua từng bước.
Bước 1: Tải mô hình
Bạn cần ba file từ HuggingFace. Tải chúng thủ công vào một thư mục (ví dụ: ./models/ner/):
| File | Kích thước | URL |
|---|---|---|
model.onnx |
~430MB | Download |
vocab.txt |
~230KB | Download |
config.json |
~1KB | Download |
Mô hình là bert-base-NER được xuất sang định dạng ONNX bởi protectai.
Thư mục của bạn sẽ trông như thế này:
models/
ner/
model.onnx (mạng nơ-ron)
vocab.txt (ánh xạ từ → ID)
config.json (định nghĩa nhãn)
Bước 2: Thiết lập dự án
Tạo một console app mới và thêm các gói NuGet:
dotnet new console -n NerDemo
cd NerDemo
dotnet add package Microsoft.ML.OnnxRuntime
dotnet add package Microsoft.ML.Tokenizers
Chỉ vậy thôi. Hai gói:
- OnnxRuntime – Chạy mô hình
- ML.Tokenizers – Xử lý chuyển đổi văn bản → token
Bước 3: Hiểu về Tokenization
Trước khi mô hình xử lý văn bản, chúng ta cần chuyển nó thành số. Quá trình này gọi là tokenization.
Các điểm chính:
[CLS]và[SEP]là các token đặc biệt đánh dấu ranh giới câu- Mỗi từ trở thành một số từ
vocab.txt - Mô hình chỉ thấy số, không bao giờ thấy văn bản thực
Tải Tokenizer
using Microsoft.ML.Tokenizers;
// Load the vocabulary file
var vocabPath = "./models/ner/vocab.txt";
var options = new BertOptions
{
LowerCaseBeforeTokenization = false, // BERT-NER phân biệt hoa thường!
UnknownToken = "[UNK]",
ClassificationToken = "[CLS]",
SeparatorToken = "[SEP]",
PaddingToken = "[PAD]"
};
using var stream = File.OpenRead(vocabPath);
var tokenizer = BertTokenizer.Create(stream, options);
Tại sao LowerCaseBeforeTokenization = false? Mô hình này được huấn luyện trên văn bản có phân biệt hoa thường (cased). “John” và “john” có ý nghĩa khác nhau – một cái có khả năng là tên người, một cái thì không.
Quan trọng: Tokenizer phải khớp chính xác với mô hình. Dùng vocab khác, tùy chọn hoa thường khác, hoặc ID token đặc biệt khác sẽ âm thầm làm giảm chất lượng kết quả. Luôn dùng
vocab.txtđi kèm với mô hình.
Token hóa văn bản
var text = "John Smith works at Microsoft in London.";
// Tokenize (tách thành các subword)
var encoded = tokenizer.EncodeToTokens(text, out _);
// Lấy ID token đặc biệt
var clsId = 101; // token [CLS]
var sepId = 102; // token [SEP]
// Xây dựng chuỗi đầy đủ: [CLS] + tokens + [SEP]
var tokenIds = new List<int> { clsId };
tokenIds.AddRange(encoded.Select(t => t.Id));
tokenIds.Add(sepId);
// Giữ lại các token văn bản để dùng sau
var tokens = new List<string> { "[CLS]" };
tokens.AddRange(encoded.Select(t => t.Value));
tokens.Add("[SEP]");
Sau bước này, chúng ta có:
tokenIds:[101, 1287, 3455, 2573, 1120, 7513, 1999, 2414, 119, 102]tokens:["[CLS]", "John", "Smith", "works", "at", "Microsoft", "in", "London", ".", "[SEP]"]
Bước 4: Chạy mô hình
Bây giờ chúng ta đưa những con số đó vào mô hình ONNX. Mô hình trả về “logits” – điểm số thô cho từng nhãn khả dĩ tại từng vị trí.
Tải mô hình
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
// Cấu hình cho hiệu năng tốt nhất
var sessionOptions = new SessionOptions
{
GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL,
IntraOpNumThreads = Math.Min(4, Environment.ProcessorCount)
};
// Tải mô hình (mất ~2 giây ở lần đầu)
var session = new InferenceSession("./models/ner/model.onnx", sessionOptions);
Quan trọng: Hãy tạo tokenizer và inference session một lần (singleton/service) và tái sử dụng chúng. Đừng nạp lại cho từng tài liệu –
InferenceSessionrất tốn kém khi tạo mới.
Chuẩn bị đầu vào
Mô hình yêu cầu:
- input_ids: ID token của chúng ta dạng
long[] - attention_mask: 1 cho token thật, 0 cho phần padding
// Pad tới độ dài cố định (BERT yêu cầu shape cố định; lũy thừa của 2 thân thiện với cache)
int sequenceLength = 64; // hoặc 128, 256, 512
var inputIds = new long[sequenceLength];
var attentionMask = new long[sequenceLength];
for (int i = 0; i < sequenceLength; i++)
{
if (i < tokenIds.Count)
{
inputIds[i] = tokenIds[i];
attentionMask[i] = 1;
}
else
{
inputIds[i] = 0; // token PAD
attentionMask[i] = 0;
}
}
Chạy suy luận
// Tạo tensor (shape: [batch_size=1, sequence_length])
var inputIdsTensor = new DenseTensor<long>(inputIds, [1, sequenceLength]);
var attentionMaskTensor = new DenseTensor<long>(attentionMask, [1, sequenceLength]);
// Xây dựng đầu vào
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("input_ids", inputIdsTensor),
NamedOnnxValue.CreateFromTensor("attention_mask", attentionMaskTensor)
};
// Chạy mô hình
using var results = session.Run(inputs);
// Lấy logits đầu ra
var output = results.First(r => r.Name == "logits");
var logits = output.AsTensor<float>();
Đầu ra logits có shape [1, sequence_length, 9] – 9 nhãn khả dĩ cho mỗi vị trí token.
Bước 5: Giải mã đầu ra
Mô hình xuất ra điểm số thô. Chúng ta cần:
- Tìm nhãn có điểm cao nhất cho mỗi token
- Chuyển các nhãn đó thành thực thể thực tế
Hiểu về thẻ BIO
Mô hình sử dụng ký pháp BIO:
- B-PER = Bắt đầu (Beginning) của thực thể Người
- I-PER = Bên trong (Inside, phần tiếp nối) của thực thể Người
- O = Ngoài (Outside) mọi thực thể (không quan trọng)
- B-ORG = Bắt đầu của Tổ chức
Điều này giúp mô hình xử lý các thực thể nhiều từ như “John Smith” hay “United Kingdom”.
Ánh xạ nhãn
// Đây là 9 nhãn mà mô hình được huấn luyện (bộ dữ liệu CoNLL-2003)
string[] labels =
{
"O", // 0: Ngoài mọi thực thể
"B-PER", // 1: Bắt đầu Người
"I-PER", // 2: Bên trong Người
"B-ORG", // 3: Bắt đầu Tổ chức
"I-ORG", // 4: Bên trong Tổ chức
"B-LOC", // 5: Bắt đầu Địa điểm
"I-LOC", // 6: Bên trong Địa điểm
"B-MISC", // 7: Bắt đầu Thực thể khác
"I-MISC" // 8: Bên trong Thực thể khác
};
Lưu ý: Mô hình cụ thể này dùng lược đồ CoNLL chuẩn 9 nhãn. Một số bản export ONNX có bao gồm tên nhãn trong
config.json(trườngid2label). Nếu bạn đổi mô hình, hãy đọc nhãn từ config thay vì hardcode.
Tìm nhãn tốt nhất
Với mỗi token, chúng ta chọn nhãn có điểm logit cao nhất:
var predictions = new List<(string Token, string Label, float Confidence)>();
int numLabels = 9;
for (int i = 0; i < tokens.Count; i++)
{
// Bỏ qua các token đặc biệt
if (tokens[i] is "[CLS]" or "[SEP]" or "[PAD]")
continue;
// Tìm nhãn có điểm cao nhất
float maxScore = float.MinValue;
int maxIndex = 0;
for (int j = 0; j < numLabels; j++)
{
float score = logits[0, i, j];
if (score > maxScore)
{
maxScore = score;
maxIndex = j;
}
}
// Chuyển logit thành xác suất (softmax)
float confidence = Softmax(logits, i, numLabels, maxIndex);
predictions.Add((tokens[i], labels[maxIndex], confidence));
}
Hàm Softmax chuyển điểm số thô thành xác suất (0–1):
static float Softmax(Tensor<float> logits, int position, int numLabels, int targetIndex)
{
// Tìm max để ổn định số học
float maxLogit = float.MinValue;
for (int j = 0; j < numLabels; j++)
maxLogit = Math.Max(maxLogit, logits[0, position, j]);
// Tính softmax
float sumExp = 0f;
for (int j = 0; j < numLabels; j++)
sumExp += MathF.Exp(logits[0, position, j] - maxLogit);
return MathF.Exp(logits[0, position, targetIndex] - maxLogit) / sumExp;
}
Lưu ý về độ tin cậy: Điểm softmax là tương đối, không phải xác suất được hiệu chuẩn. Chúng hữu ích để xếp hạng và đặt ngưỡng, nhưng đừng coi
0.92là “đúng 92%”. Hãy dùng chúng để lọc các dự đoán có độ tin cậy thấp, chứ không phải làm chân lý tuyệt đối.
Bước 6: Trích xuất thực thể
Giờ chúng ta có dự đoán cho từng token. Cần gộp chúng thành các thực thể.
Đầu tiên là các hàm hỗ trợ gộp WordPiece. Chúng xử lý chính xác các subword ## và khoảng trắng quanh dấu câu:
static void AppendWordPiece(StringBuilder sb, string token)
{
if (string.IsNullOrEmpty(token)) return;
// WordPiece tiếp nối: "##soft" → nối không có khoảng trắng
if (token.StartsWith("##", StringComparison.Ordinal))
{
sb.Append(token.AsSpan(2));
return;
}
// Không có khoảng trắng đầu nếu là token đầu tiên hoặc là dấu câu
if (sb.Length > 0 && !IsPunctuationToken(token))
sb.Append(' ');
sb.Append(token);
}
static bool IsPunctuationToken(string token) =>
token.Length == 1 && char.IsPunctuation(token[0]);
static string MergeWordPieces(IEnumerable<string> tokens)
{
var sb = new StringBuilder();
foreach (var t in tokens)
AppendWordPiece(sb, t);
return sb.ToString();
}
Tiếp theo là phần trích xuất thực thể. Độ tin cậy của thực thể là độ tin cậy token tối thiểu trên toàn đoạn – mang tính thận trọng, để một token yếu duy nhất không bị che giấu bởi phép lấy trung bình:
public sealed class Entity
{
public required string Text { get; init; }
public required string Type { get; init; } // PER, ORG, LOC, MISC
public required float Confidence { get; init; }
}
static List<Entity> ExtractEntities(
IReadOnlyList<(string Token, string Label, float Confidence)> predictions)
{
var entities = new List<Entity>();
string? currentType = null;
var currentTokens = new List<string>();
float currentConfidence = 1.0f;
void Flush()
{
if (currentType == null || currentTokens.Count == 0) return;
entities.Add(new Entity
{
Type = currentType,
Text = MergeWordPieces(currentTokens),
Confidence = currentConfidence
});
currentType = null;
currentTokens.Clear();
currentConfidence = 1.0f;
}
foreach (var (token, label, conf) in predictions)
{
// Tiếp tục thực thể hiện tại nếu mô hình nói I-<cùng loại>
if (currentType != null && label == $"I-{currentType}")
{
currentTokens.Add(token); // giữ dạng ##, hàm merge sẽ xử lý
currentConfidence = Math.Min(currentConfidence, conf);
continue;
}
// Thực thể mới bắt đầu
if (label.StartsWith("B-", StringComparison.Ordinal))
{
Flush();
currentType = label[2..];
currentTokens.Add(token);
currentConfidence = conf;
continue;
}
// Bất cứ điều gì khác (O, hoặc I- không khớp loại hiện tại) kết thúc thực thể
Flush();
}
Flush();
return entities;
}
Lưu ý: WordPiece được xử lý hoàn toàn bởi MergeWordPieces – không cần logic điều khiển đặc biệt nào.
Ví dụ hoàn chỉnh
Đây là một ví dụ hoạt động tối thiểu bạn có thể copy và chạy:
using System.Text;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using Microsoft.ML.Tokenizers;
// === Cấu hình ===
var modelPath = "./models/ner/model.onnx";
var vocabPath = "./models/ner/vocab.txt";
// === Tải tokenizer ===
var bertOptions = new BertOptions
{
LowerCaseBeforeTokenization = false,
UnknownToken = "[UNK]",
ClassificationToken = "[CLS]",
SeparatorToken = "[SEP]"
};
using var vocabStream = File.OpenRead(vocabPath);
var tokenizer = BertTokenizer.Create(vocabStream, bertOptions);
// === Tải mô hình ===
var sessionOptions = new SessionOptions
{
GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL
};
using var session = new InferenceSession(modelPath, sessionOptions);
// === Xử lý văn bản ===
var text = "John Smith, CEO of Microsoft, announced the acquisition in London yesterday.";
// Token hóa
var encoded = tokenizer.EncodeToTokens(text, out _);
// Xây dựng chuỗi với các token đặc biệt
var tokens = new List<string> { "[CLS]" };
tokens.AddRange(encoded.Select(t => t.Value));
tokens.Add("[SEP]");
var rawIds = new List<int> { 101 }; // [CLS]
rawIds.AddRange(encoded.Select(t => t.Id));
rawIds.Add(102); // [SEP]
// Pad tới độ dài cố định
int seqLen = 64;
var inputIds = new long[seqLen];
var attentionMask = new long[seqLen];
for (int i = 0; i < seqLen; i++)
{
inputIds[i] = i < rawIds.Count ? rawIds[i] : 0;
attentionMask[i] = i < rawIds.Count ? 1 : 0;
}
// === Chạy suy luận ===
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("input_ids",
new DenseTensor<long>(inputIds, [1, seqLen])),
NamedOnnxValue.CreateFromTensor("attention_mask",
new DenseTensor<long>(attentionMask, [1, seqLen]))
};
using var results = session.Run(inputs);
var logits = results.First().AsTensor<float>();
// === Giải mã dự đoán ===
string[] labels = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC", "B-MISC", "I-MISC"];
// Hàm gộp WordPiece
static string MergeWordPieces(List<string> tokens)
{
var sb = new StringBuilder();
foreach (var t in tokens)
{
if (t.StartsWith("##", StringComparison.Ordinal))
sb.Append(t.AsSpan(2));
else if (sb.Length > 0 && t.Length > 0 && !char.IsPunctuation(t[0]))
sb.Append(' ').Append(t);
else
sb.Append(t);
}
return sb.ToString();
}
Console.WriteLine($"Input: {text}\n");
Console.WriteLine("Entities found:");
string? currentType = null;
var currentTokens = new List<string>();
for (int i = 1; i < tokens.Count - 1; i++) // Bỏ qua [CLS] và [SEP]
{
var token = tokens[i];
// Tìm nhãn tốt nhất
int bestIdx = 0;
float bestScore = float.MinValue;
for (int j = 0; j < 9; j++)
{
if (logits[0, i, j] > bestScore)
{
bestScore = logits[0, i, j];
bestIdx = j;
}
}
var label = labels[bestIdx];
// Tiếp tục thực thể hiện tại nếu mô hình nói I-<cùng loại>
if (currentType != null && label == $"I-{currentType}")
{
currentTokens.Add(token);
continue;
}
// Thực thể mới bắt đầu
if (label.StartsWith("B-"))
{
if (currentType != null)
Console.WriteLine($" [{currentType}] {MergeWordPieces(currentTokens)}");
currentType = label[2..];
currentTokens = [token];
continue;
}
// Bất cứ điều gì khác kết thúc thực thể hiện tại
if (currentType != null)
Console.WriteLine($" [{currentType}] {MergeWordPieces(currentTokens)}");
currentType = null;
currentTokens.Clear();
}
// Xuất thực thể cuối cùng
if (currentType != null)
Console.WriteLine($" [{currentType}] {MergeWordPieces(currentTokens)}");
Kết quả:
Input: John Smith, CEO of Microsoft, announced the acquisition in London yesterday.
Entities found:
[PER] John Smith
[ORG] Microsoft
[LOC] London
Hiểu về Subword WordPiece
BERT sử dụng tokenization WordPiece, tách các từ chưa biết thành các subword. Tiền tố ## nghĩa là “phần tiếp nối của từ trước đó”:
Hàm MergeWordPieces xử lý việc này: các token ## được nối không có khoảng trắng, tạo ra "Elasticsearch" thay vì "Elastic search".
Mẹo hiệu năng
1. Xử lý theo batch nhiều văn bản
Nếu bạn có nhiều văn bản, hãy xử lý theo batch:
// Thay vì: 1 văn bản × 1 lần suy luận = 50ms
// Hãy làm: 16 văn bản × 1 lần suy luận = 100ms (6ms mỗi văn bản)
int batchSize = 16;
var batchInputIds = new long[batchSize, seqLen];
// ... điền vào batch ...
var tensor = new DenseTensor<long>(batchInputIds, [batchSize, seqLen]);
2. Dùng smart bucketing
Đừng luôn luôn pad tới 512. Hãy dùng bucket nhỏ nhất đủ chứa:
int[] buckets = [32, 64, 128, 256, 512];
int targetLength = buckets.FirstOrDefault(b => b >= tokenIds.Count);
if (targetLength == 0) targetLength = 512;
Trong thực tế, ONNX NER đủ nhanh để chạy inline trong lúc nạp dữ liệu (ingestion), không chỉ là tác vụ batch. Bạn có thể trích xuất thực thể ngay khi tài liệu đến thay vì xếp hàng đợi xử lý sau.
3. Tăng tốc GPU (tùy chọn)
Với throughput cao, hãy dùng DirectML (Windows) hoặc CUDA:
dotnet add package Microsoft.ML.OnnxRuntime.DirectML # Windows GPU
# hoặc
dotnet add package Microsoft.ML.OnnxRuntime.Gpu # NVIDIA CUDA
var options = new SessionOptions();
options.AppendExecutionProvider_DML(); // Dùng GPU
Khi nào dùng cách này so với LLM
| Dùng ONNX NER | Dùng LLM (GPT-4/Claude) |
|---|---|
| Khối lượng lớn (hàng nghìn tài liệu) | Phân tích một lần |
| Thực thể chuẩn (người, tổ chức, địa điểm) | Loại thực thể tùy chỉnh |
| Dữ liệu nhạy cảm về quyền riêng tư | Khi bạn cần lời giải thích |
| Pipeline mang tính xác định | Phân tích thăm dò (exploratory) |
| Trích xuất đặc trưng | Diễn giải / tổng hợp |
Cả hai cách đều hoạt động. Chúng giải quyết các bài toán khác nhau. NER trích xuất cấu trúc; LLM suy luận về ý nghĩa.
Bức tranh lớn hơn
Mô hình này – trích xuất xác định với các mô hình đóng băng, sau đó tùy chọn tổng hợp ở bước sau – mở rộng quy mô tốt hơn nhiều so với việc đẩy văn bản thô vào LLM và hy vọng nó hoạt động đúng.
NER không phải thứ để “agent hóa” (agentify). Nó là hạ tầng (infrastructure). Bạn chạy nó lúc nạp dữ liệu, lưu trữ các thực thể, và dùng chúng ở hạ nguồn để lọc, liên kết, hoặc đưa vào các pipeline tinh vi hơn.
Cách tiếp cận tương tự áp dụng cho các tác vụ trích xuất đặc trưng khác: embeddings, phân loại, phân tích cảm xúc. Huấn luyện một lần (hoặc dùng mô hình tiền huấn luyện), xuất sang ONNX, chạy ở mọi nơi, một cách xác định.
Nơi phù hợp: Pipeline OCR + NER này là một khối xây dựng. Để có bức tranh đầy đủ – cách các thực thể trích xuất được nuôi vào quá trình xây dựng đồ thị, khử trùng lặp và truy xuất – hãy xem Reduced RAG và tài liệu LucidRAG. Các thực thể bạn trích xuất ở đây trở thành các nút (nodes); các tài liệu trở thành cạnh (edges); LLM chỉ nhìn thấy những gì nó cần.
Tài nguyên
Thư viện & Mô hình:
- Tesseract.NET – Wrapper C# cho Tesseract OCR
- tessdata – Các file dữ liệu đã huấn luyện cho Tesseract
- BERT-base-NER ONNX – Mô hình NER chúng ta sử dụng
- ONNX Runtime – Tài liệu chính thức
- ML.Tokenizers – Thư viện tokenizer của Microsoft
Bài viết liên quan:
- The Three-Tier OCR Pipeline – Khi OCR đơn giản là chưa đủ
- Reduced RAG – Nơi các thực thể trích xuất được đặt vào bức tranh lớn hơn
- LucidRAG – Bản triển khai đầy đủ với khử trùng lặp thực thể và xây dựng đồ thị



