Crawl4AI và Python: Trích xuất Markdown sạch cho LLM và hệ thống RAG

AI tutorial - IT technology blog
AI tutorial - IT technology blog

Vấn đề thực tế: Phình token và nhiễu trong pipeline LLM

Dữ liệu web thô làm suy giảm độ chính xác khi truy xuất rất nhanh. Năm ngoái, nhóm chúng tôi đã xây dựng một trợ lý nội bộ để tìm kiếm trên 14.000 trang tài liệu dành cho lập trình viên. Chỉ trong một tuần, chi phí lưu trữ vector và embedding đã tăng vọt 140%. Tệ hơn nữa, độ chính xác tìm kiếm tụt dốc thảm hại. Một kỹ sư tìm kiếm JWT refresh rotation nhận về các chunk chứa đầy banner cookie, menu dropdown trên header và các đoạn script phân tích đã bị rút gọn thay vì mã nguồn của endpoint.

HTML vốn được thiết kế cho trình duyệt web, không phải cho mạng nơ-ron. Các công cụ cào dữ liệu truyền thống thu thập toàn bộ cây DOM thô, khiến các pipeline phía sau phải xử lý các thẻ SVG nội dòng, các thẻ <div> bao bọc lồng nhau và pixel theo dõi. Đưa cấu trúc thô đó vào một mô hình embedding như text-embedding-3-small, bạn sẽ lãng phí ngân sách token quý giá cho nhiễu cấu trúc. Tệ hơn, các bộ semantic chunker sẽ phân tách chunk dựa trên các thẻ bố cục thay vì các ranh giới ngữ nghĩa tự nhiên.

Tại sao các công cụ cào truyền thống gặp khó khăn với Context Window

Ba rào cản kiến trúc khiến các công cụ cào truyền thống không phù hợp để nạp dữ liệu cho LLM:

  • Tỷ lệ tín hiệu trên nhiễu thấp (Signal-to-Noise Ratio): Trên các trang web hiện đại, nội dung văn bản thực tế thường chiếm chưa tới 18% payload HTML thô. 82% còn lại là các class CSS, gói script và thành phần điều hướng rườm rà.
  • Cấu trúc bị san phẳng: Việc loại bỏ thẻ một cách sơ sài bằng biểu thức chính quy (regex) hoặc BeautifulSoup.get_text() sẽ làm phẳng các tiêu đề, bảng biểu và khối code thành một bức tường văn bản liền mạch. Cơ sở dữ liệu vector sẽ mất đi cấu trúc phân cấp vốn tạo nên ngữ nghĩa cho tài liệu kỹ thuật.
  • Client-Side Hydration: Các ứng dụng Single-Page (SPA) xây dựng trên Next.js, React hoặc Vue tiến hành hydrate bất đồng bộ. Những HTTP client tĩnh như requests hay httpx chỉ thu được các thẻ giữ chỗ rỗng, buộc lập trình viên phải tự quản lý dàn headless browser phức tạp và ngốn tài nguyên.

Kiến trúc cào dữ liệu: So sánh các lựa chọn

Để tìm ra một thiết lập nạp dữ liệu bền vững, chúng tôi đã thử nghiệm bốn chiến lược cào trên 500 trang tài liệu và blog động:

Công nghệ Render JS Độ trung thực của Markdown Tài nguyên tiêu tốn
BeautifulSoup + html2text Không Thấp (thường xuyên lỗi bảng biểu & khối code) ~30 MB RAM mỗi tiến trình; nhanh nhưng bỏ sót SPA
Selenium / Puppeteer Scripts Có Trung bình (đòi hỏi quy tắc phân tích AST tùy chỉnh) 350–500 MB RAM mỗi worker; tốn công bảo trì
Hosted Extraction APIs (e.g., Firecrawl) Có Cao Hạ tầng tối thiểu, nhưng tốn 10–50 USD mỗi 10k trang; dữ liệu rời khỏi VPC của bạn
Crawl4AI (Self-Hosted) Có (Playwright) Rất cao (heuristics fit_markdown tích hợp sẵn) 120–180 MB RAM mỗi context; chạy hoàn toàn trên phần cứng của bạn

Các API của bên thứ ba hoạt động tốt khi làm prototype nhanh. Tuy nhiên, việc gửi tài liệu doanh nghiệp độc quyền hoặc hàng trăm nghìn trang qua các endpoint bên ngoài tính phí theo dung lượng sẽ gây ra rủi ro tuân thủ bảo mật và chi phí định kỳ khó lường. Crawl4AI sinh ra để giải quyết chính xác bài toán này. Nó chạy cục bộ dưới dạng một engine Python bất đồng bộ, điều phối Chromium qua Playwright và loại bỏ các phần rườm rà trước khi xuất ra Markdown có cấu trúc được tinh chỉnh riêng cho các mô hình embedding.

Triển khai: Chuyển đổi trang web thành Markdown sạch

Quy trình sau đây sẽ trích xuất dữ liệu web động thành Markdown sạch, tối ưu token và sẵn sàng đưa vào pipeline vector của bạn.

1. Thiết lập môi trường

Crawl4AI hoạt động dựa trên Playwright ở tầng dưới. Hãy cài đặt gói Python và tải về các file nhị phân trình duyệt được quản lý:

pip install crawl4ai
crawl4ai-setup

2. Trích xuất bất đồng bộ cơ bản

Một crawler tối giản sẽ khởi tạo phiên trình duyệt bất đồng bộ, tải URL mục tiêu và xuất ra Markdown sạch:

import asyncio
from crawl4ai import AsyncWebCrawler

async def fetch_simple_markdown(url: str):
    async with AsyncWebCrawler(verbose=False) as crawler:
        result = await crawler.arun(url=url)
        
        if result.success:
            print("--- Mẫu Markdown được trích xuất ---")
            print(result.markdown[:400])
            print(f"\nTổng số ký tự: {len(result.markdown):,}")
        else:
            print(f"Cào dữ liệu thất bại: {result.error_message}")

if __name__ == "__main__":
    asyncio.run(fetch_simple_markdown("https://news.ycombinator.com"))

3. Lọc heuristic để loại bỏ nhiễu bố cục

Cách chuyển đổi thông thường sẽ để lại sidebar, chính sách cookie và các liên kết điều hướng lồng nhau trong kết quả đầu ra. Crawl4AI xử lý vấn đề này bằng PruningContentFilter. Bộ lọc này chấm điểm mật độ văn bản trên các cây con DOM, cắt tỉa các khối liên kết lặp lại và các thẻ bao bọc điều hướng trước khi tạo văn bản cuối cùng.

import asyncio
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
from crawl4ai.content_filter_strategy import PruningContentFilter
from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator

async def extract_optimized_content(url: str) -> str:
    browser_config = BrowserConfig(
        headless=True,
        verbose=False,
    )

    # Loại bỏ các node có tỷ lệ mật độ văn bản trên thẻ (text-to-tag) thấp
    prune_filter = PruningContentFilter(
        threshold=0.45,
        threshold_type="fixed",
        min_word_threshold=10
    )
    
    md_generator = DefaultMarkdownGenerator(
        content_filter=prune_filter,
        options={"ignore_links": False}
    )

    run_config = CrawlerRunConfig(
        cache_mode=CacheMode.BYPASS,
        markdown_generator=md_generator,
        word_count_threshold=15,
        excluded_tags=["nav", "footer", "aside", "script", "style", "header"],
        exclude_external_links=True
    )

    async with AsyncWebCrawler(config=browser_config) as crawler:
        result = await crawler.arun(url=url, config=run_config)
        
        if result.success:
            # fit_markdown chứa nội dung cô đọng đã được lọc sạch
            return result.markdown.fit_markdown
        return ""

if __name__ == "__main__":
    target = "https://en.wikipedia.org/wiki/Retrieval-augmented_generation"
    markdown_output = asyncio.run(extract_optimized_content(target))
    print(f"Đã trích xuất {len(markdown_output):,} ký tự nội dung cô đọng.")
    print(markdown_output[:600])

Việc lọc bỏ các container bố cục (<nav>, <footer>, <aside>) đã giúp giảm trung bình 58% lượng token của chúng tôi. Đối với một kho dữ liệu gồm 10.000 trang, điều này giúp thu gọn chỉ mục vector từ 42 triệu token xuống còn 17,6 triệu token.

4. Cào các SPA động và trang cuộn vô tận (Infinite Scroll)

Các ứng dụng web hiện đại thường trì hoãn render dữ liệu cho đến khi các request XHR ngầm hoàn tất. Crawl4AI xử lý điều này bằng cách cho phép bạn inject các đoạn mã JavaScript và khai báo điều kiện chờ CSS rõ ràng trước khi trích xuất snapshot:

import asyncio
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig

async def scrape_dynamic_page(url: str):
    # Cuộn trang để kích hoạt các phần tử lazy-load
    scroll_script = """
    window.scrollTo(0, document.body.scrollHeight);
    await new Promise(resolve => setTimeout(resolve, 800));
    """

    config = CrawlerRunConfig(
        js_code=scroll_script,
        wait_for="css:article.main-content",
        delay_before_return_html=1.0
    )

    async with AsyncWebCrawler() as crawler:
        result = await crawler.arun(url=url, config=config)
        if result.success:
            return result.markdown.fit_markdown
        return None

Nguyên tắc triển khai trên Production khi nạp tri thức

Đưa crawler lên môi trường production đòi hỏi những rào chắn vận hành chặt chẽ. Dưới đây là các nguyên tắc cốt lõi mà chúng tôi áp dụng trên các ingestion worker của mình:

  • Khử trùng lặp bằng mã băm nội dung (Content Hash): Không bao giờ embedding lại các trang không thay đổi. Hãy tính toán mã băm SHA-256 trên result.markdown.fit_markdown và đối chiếu với kho metadata trước khi kích hoạt quá trình chunking tiếp theo.
  • Giới hạn số tiến trình trình duyệt đồng thời: Các instance Chromium headless rất dễ rò rỉ bộ nhớ nếu không được kiểm soát. Hãy dự trù khoảng 120 MB đến 180 MB RAM cho mỗi trang chạy đồng thời. Trên một cloud node tiêu chuẩn với 4 vCPU và 16 GB RAM, hãy giới hạn pool worker ở mức 10 đến 12 context đồng thời bằng asyncio.Semaphore.
  • Luân phiên User-Agent tùy chỉnh: Thiết lập chuỗi User-Agent hợp lệ của các trình duyệt hiện đại. Header headless mặc định sẽ ngay lập tức kích hoạt thử thách bot của Cloudflare và CloudFront trên các cổng tài liệu lập trình viên phổ biến.

Dữ liệu đầu vào sạch sẽ tạo ra các embedding chất lượng. Bằng cách đưa công đoạn cắt tỉa DOM và tạo markdown vào ngay bước cào dữ liệu, các chunk vector của bạn sẽ ăn khớp một cách tự nhiên với các tiêu đề Markdown. Các mô hình phía sau sẽ tập trung vào sự thật chuyên môn thay vì các phần thừa từ điều hướng trang web.

Share: