Xây Dựng AI/Semantic Search Cục Bộ bằng PHP không cần OpenAI
Trong thời đại LLM và RAG (Retrieval-Augmented Generation), các lập trình viên thường vội vàng tích hợp OpenAI hoặc Pinecone cho tính năng tìm kiếm. Mặc dù mạnh mẽ, những công cụ này gây ra độ trễ, chi phí và độ phức tạp.
Đối với blog cá nhân hoặc trang tài liệu, bạn thường không cần một mạng neural nặng nề. Bạn cần Semantic Search — khả năng tìm nội dung dựa trên ý nghĩa và độ liên quan, không chỉ khớp từ khóa chính xác.
Trong bài viết này, tôi sẽ hướng dẫn cách tôi xây dựng tìm kiếm "Local AI" cho blog này sử dụng TF-IDF (Term Frequency-Inverse Document Frequency), Cosine Similarity, và SQLite, hoàn toàn không có dependency bên ngoài.
Lý Thuyết: Vector Space Model
Máy tính không thể hiểu văn bản; chúng hiểu số. Để so sánh hai tài liệu, chúng ta phải chuyển đổi chúng thành vector (danh sách các số).
Giải Thích TF-IDF
- TF (Term Frequency): Tần suất một từ xuất hiện trong một tài liệu cụ thể. Nó xuất hiện càng nhiều, nó càng có khả năng quan trọng cho tài liệu đó.
- IDF (Inverse Document Frequency): Độ hiếm của một từ trong tất cả các tài liệu. Các từ phổ biến như "the" hoặc "is" có điểm IDF thấp (ít quan trọng). Các từ hiếm như "polymorphism" hoặc "middleware" có điểm IDF cao.
Bằng cách nhân TF * IDF, chúng ta có được trọng số cho mỗi từ trong mỗi tài liệu. Sau đó chúng ta có thể biểu diễn một tài liệu như một vector của các trọng số này.
Cosine Similarity
Khi đã có vector cho các tài liệu và truy vấn tìm kiếm, chúng ta tính góc giữa chúng.
- Nếu góc là 0 độ (Cosine = 1), chúng giống hệt nhau.
- Nếu góc là 90 độ (Cosine = 0), chúng hoàn toàn không liên quan.
Triển Khai bằng PHP
Chúng ta cần ba thành phần chính:
- Tokenizer: Tách văn bản thành các từ.
- Indexer: Tính toán vector TF-IDF và lưu chúng.
- Search Engine: Chuyển đổi truy vấn thành vector và so sánh với database.
1. Tokenizer
Đầu tiên, chúng ta cần làm sạch văn bản. Chúng ta loại bỏ dấu câu, chuyển thành chữ thường, và loại bỏ "stop word" (các từ nhiễu phổ biến).
function tokenize(string $text): array {
// 1. Chữ thường và loại bỏ ký tự không phải chữ-số
$text = strtolower($text);
$text = preg_replace('/[^a-z0-9\s]/', '', $text);
// 2. Tách theo khoảng trắng
$tokens = explode(' ', $text);
// 3. Loại bỏ stop word & từ ngắn
$stopWords = ['the', 'is', 'at', 'which', 'on', ...];
return array_filter($tokens, fn($t) =>
strlen($t) > 2 && !in_array($t, $stopWords)
);
}
2. Vectorizer (Lõi Toán Học)
Đây là nơi phép màu xảy ra. Chúng ta tính vector cho một tài liệu nhất định.
class TfidfVectorizer {
// Từ điển ánh xạ term tới số document chứa nó
private array $documentFrequencies = [];
private int $totalDocuments = 0;
public function calculateTfidf(array $tokens): array {
$termCounts = array_count_values($tokens);
$totalTerms = count($tokens);
$vector = [];
foreach ($termCounts as $term => $count) {
// TF = (Số lần term xuất hiện trong doc) / (Tổng term trong doc)
$tf = $count / $totalTerms;
// IDF = log(Tổng Docs / (Docs chứa term + 1))
$df = $this->documentFrequencies[$term] ?? 0;
$idf = log($this->totalDocuments / ($df + 1));
$vector[$term] = $tf * $idf;
}
return $vector;
}
}
3. Chiến Lược Lưu Trữ: Serialized Vector trong SQLite
Đối với hệ thống có khả năng mở rộng lớn, bạn sẽ dùng Vector Database như Weaviate hoặc Milvus. Đối với blog với < 10,000 bài viết, SQLite nhanh hơn.
Chúng ta tạo một table đơn giản:
CREATE TABLE search_index (
post_slug TEXT PRIMARY KEY,
vector_blob BLOB, -- Mảng PHP được serialize hoặc JSON
magnitude FLOAT -- Độ dài vector được tính trước
);
Khi lưu một bài viết:
- Parse nội dung sang Markdown.
- Tokenize.
- Tính Vector.
json_encodevector và lưu vào SQLite.
4. Thuật Toán Tìm Kiếm
Khi người dùng tìm kiếm "Laravel Middleware":
- Tokenize truy vấn:
['laravel', 'middleware'] - Tính vector truy vấn dựa trên thống kê IDF toàn cục.
- Lấy tất cả document vector (hoặc một tập con) từ SQLite.
- Tính Cosine Similarity:
$$ \text{similarity} = \frac{A \cdot B}{|A| |B|} $$
Trong đó $A \cdot B$ là tích vô hướng (tổng của các trọng số term khớp được nhân với nhau), và $|A|$ là độ lớn.
public function cosineSimilarity(array $vecA, array $vecB): float {
$dotProduct = 0.0;
foreach ($vecA as $term => $weight) {
if (isset($vecB[$term])) {
$dotProduct += $weight * $vecB[$term];
}
}
// Tối ưu: Tính trước magnitude khi indexing
return $dotProduct / ($this->getMagnitude($vecA) * $this->getMagnitude($vecB));
}
Hiệu Năng & Kết Quả
Trên blog này (khoảng 50 bài viết), tìm kiếm chạy trong ~15ms.
- Sử Dụng Bộ Nhớ: Không đáng kể.
- Độ Chính Xác: Nó tìm thành công "caching" khi tôi tìm kiếm "performance" nếu có đủ thuật ngữ chồng chéo, hoặc xử lý lỗi chính tả tốt hơn
LIKE %...%đơn giản. - Chi Phí: $0.
Hạn Chế
- Không "Thực Sự" Hiểu: Không giống GPT, nó không biết rằng "canine" và "dog" là từ đồng nghĩa trừ khi các tài liệu đề cập chúng cùng nhau.
- Không Khớp Từ Vựng: Nếu bạn tìm "speed" và tôi chỉ viết "fast", nó có thể bỏ lỡ. (Stemming giúp ở đây).
Kết Luận
Bạn không phải lúc nào cũng cần búa tạ để đập hạt dẻ. TF-IDF là thuật toán mạnh mẽ, đã được kiểm chứng thực chiến, từng làm nền tảng cho các phiên bản đầu tiên của Google và Lucene. Đối với static site và blog, nó là sự cân bằng hoàn hảo giữa "thông minh" và "đơn giản."