LLM工程师手册——数据工程

227 阅读38分钟

本章将深入探讨LLM双生子项目。我们将学习如何设计和实现数据收集管道,以收集所有LLM用例(如微调或推理)所需的原始数据。由于本书并非关于数据工程的专著,因此我们将保持简洁,仅关注收集所需原始数据的必要内容。从第4章开始,我们将专注于LLM和生成式AI(GenAI),深入探讨理论及具体实现细节。

在玩具项目或研究中,通常会使用一个静态数据集进行工作。但在LLM双生子项目中,我们希望模拟现实场景,自己收集并整理数据。因此,构建我们的数据管道将有助于完整了解一个端到端ML项目的工作方式。本章将探讨如何设计和实现一个“提取-转换-加载”(ETL)管道,从Medium、Substack或GitHub等多个社交平台抓取数据,并将收集的数据聚合到MongoDB数据仓库中。我们将展示如何实现多种抓取方法,标准化数据,并将其加载到数据仓库中。

我们将从设计LLM双生子的收集管道开始,解释ETL管道的架构。接下来,我们将直接进入管道的实现,从使用ZenML进行编排整个流程开始。我们将研究爬虫的实现,了解如何实现一个调度层,通过提供的链接域动态实例化适当的爬虫类,同时遵循软件的最佳实践。然后,我们将学习如何分别实现每个爬虫,并展示如何在MongoDB之上实现数据层,以结构化我们的所有文档并与数据库交互。

最后,我们将探讨如何使用ZenML运行数据收集管道,并从MongoDB中查询收集到的数据。

因此,本章将涉及以下主题:

  • 设计LLM双生子的收集管道
  • 实现LLM双生子的收集管道
  • 将原始数据收集到数据仓库

通过本章的学习,你将掌握设计和实现ETL管道的技能,以提取、转换和加载可供ML应用程序使用的原始数据。

设计LLM双生子的收集管道

在进入实现之前,我们需要了解LLM双生子的数据收集ETL架构,如图3.1所示。我们将探讨将从哪些平台抓取数据,以及如何设计我们的数据结构和流程。然而,第一步是了解我们的数据收集管道如何映射到ETL过程。

ETL管道包含三个基本步骤:

  1. 提取数据:从各种来源提取数据。我们将从Medium、Substack和GitHub等平台抓取数据,以收集原始数据。
  2. 转换数据:通过清洗和标准化,将数据转化为适合存储和分析的一致格式。
  3. 加载数据:将转换后的数据加载到数据仓库或数据库中。

在我们的项目中,我们选择MongoDB作为NoSQL数据仓库。虽然这不是传统的方法,但我们将在稍后解释这一选择的原因。

image.png

我们希望设计一个ETL管道,该管道以用户和链接列表作为输入。随后,它会逐个抓取每个链接,标准化收集到的内容,并将其存储在MongoDB数据仓库中该特定作者的名下。

因此,数据收集管道的接口如下所示:

  • 输入:一个链接列表及其关联的用户(即作者)
  • 输出:存储在NoSQL数据仓库中的一系列原始文档

我们会将“用户”和“作者”这两个词交替使用,因为在ETL管道的大多数场景中,用户即是所提取内容的作者。然而,在数据仓库中,我们只有一个用户集合。

ETL管道将检测每个链接的域名,并根据域名调用相应的专用爬虫。我们为三种不同的数据类别实现了四种不同的爬虫,如图3.2所示。首先,我们将探讨书中涉及的三个基本数据类别。所有收集的文档都可以归纳为文章、代码库(或代码)和帖子。数据的来源并不重要,我们主要关注文档的格式。在大多数情况下,我们需要对这些数据类别进行不同的处理。因此,我们为每种类别创建了不同的领域实体,每个实体在MongoDB中都有其对应的类和集合。由于我们在文档的元数据中保存了源URL,因此我们依然可以知道数据来源,并在生成式AI用例中进行引用。

image.png

我们的代码库支持四种不同的爬虫:

  1. Medium爬虫:用于从Medium收集数据,输出为文章文档。它会登录Medium并抓取文章链接的HTML,然后提取、清洗并标准化文本,最后将标准化的文章文本加载到NoSQL数据仓库中。
  2. 自定义文章爬虫:其操作类似于Medium爬虫,但更通用,可用于从各种网站收集文章。因此,它不包含特定平台的功能,不执行登录步骤,而是直接抓取特定链接的HTML。这适用于可以免费在线获取的文章,例如Substack和个人博客。当链接的域名不属于其他支持的爬虫时,将默认使用该爬虫。例如,提供Substack链接时会使用自定义文章爬虫,而提供Medium链接时会使用Medium爬虫。
  3. GitHub爬虫:用于从GitHub收集数据,输出为代码库文档。它会克隆代码库,解析文件树,清理并标准化文件,并将其加载到数据库中。
  4. LinkedIn爬虫:用于从LinkedIn收集数据,输出为多个帖子文档。它会登录LinkedIn,导航到用户的动态,抓取所有最新帖子。对于每个帖子,它提取HTML、清理并标准化文本,然后将其加载到MongoDB中。

在下一节中,我们将详细检查每个爬虫的实现。目前需要注意的是,每个爬虫都以特定方式访问特定平台或站点并从中提取HTML。随后,所有爬虫都会解析HTML,提取其中的文本,清理并标准化,使其能够以相同接口存储在数据仓库中。

通过将所有收集的数据归为三类(文章、代码库、帖子)而非为每个新数据源创建新的数据类别,我们可以轻松地将该架构扩展到多个数据源,几乎不需额外工作。例如,如果我们想开始收集X(原Twitter)上的数据,只需实现一个输出为帖子文档的新爬虫即可,其他代码保持不变。否则,如果在类和文档结构中引入了来源维度,则需要在所有下游层添加代码以支持新数据源,例如为每个新来源实现新文档类并适配特征管道来支持它。

对于概念验证,抓取几百个文档就足够了,但如果我们希望将其扩展为现实产品,可能需要更多数据源。LLM对数据的需求量很大,因此需要成千上万的文档以获得理想效果。许多项目的最佳策略是首先实现一个端到端项目版本,即使不是最准确的,然后再进行迭代优化。使用此架构,可以在未来迭代中轻松添加更多数据源以获取更大数据集。第4章将进一步探讨LLM微调和数据集规模。

ETL过程如何与特征管道相连接?特征管道从MongoDB数据仓库提取原始数据,进一步清理并将其处理为特征,再存储到Qdrant向量数据库中,以便用于LLM的训练和推理。第4章将提供更多特征管道的信息。ETL过程独立于特征管道,这两个管道仅通过MongoDB数据仓库进行通信。因此,数据收集管道可以独立于特征管道写入MongoDB,而特征管道可以在不同时间读取数据。

为什么选择MongoDB作为数据仓库?使用MongoDB这样的事务型数据库作为数据仓库并不常见,但在我们的用例中,由于数据量较小,MongoDB完全能够胜任。即使我们计划在MongoDB集合上计算统计数据,也可以在LLM双生子的规模(数百个文档)上顺利运行。我们选择MongoDB来存储原始数据,主要是因为我们处理的是非结构化的网络文本数据。由于主要是处理非结构化文本,选择不强制要求模式的NoSQL数据库使我们的开发更为简便快捷。此外,MongoDB稳定易用,提供直观的Python SDK,本地Docker镜像开箱即用,并有适合概念验证的云端免费版本,非常适合像LLM双生子这样的项目。然而,对于大数据(数百万个文档或更多),建议使用专用数据仓库,如Snowflake或BigQuery。

现在我们了解了LLM双生子的数据收集管道架构,接下来将进入实现部分。

实现LLM双生子的数据收集管道

正如我们在第2章介绍的那样,LLM双生子项目中每个管道的入口点是一个ZenML管道,可以通过YAML文件在运行时配置并在ZenML生态系统中运行。因此,让我们从ZenML的digital_data_etl管道开始。你会注意到,这与第2章中用来说明ZenML的示例管道相同。但这次,我们将深入探讨其实现,解释数据收集在后台是如何工作的。在了解管道如何工作后,我们将探索用于从各种网站收集数据的每个爬虫的实现,以及用于在数据仓库中存储和查询数据的MongoDB文档。

ZenML管道和步骤

在下面的代码片段中,可以看到ZenML digital_data_etl管道的实现,它以用户的全名和一组链接作为输入,这些链接将在该用户名下抓取(该用户被视为从这些链接中提取的内容的作者)。在函数中,我们调用了两个步骤。第一步,根据用户的全名在数据库中查找用户。接下来,我们遍历所有链接,逐个独立抓取。管道的实现可在我们代码库中的pipelines/digital_data_etl.py文件中找到。

from zenml import pipeline
from steps.etl import crawl_links, get_or_create_user

@pipeline
def digital_data_etl(user_full_name: str, links: list[str]) -> str:
    user = get_or_create_user(user_full_name)
    last_step = crawl_links(user=user, links=links)
    return last_step.invocation_id

图3.3展示了digital_data_etl管道在ZenML仪表板上的运行情况。接下来,我们将分别探讨get_or_create_usercrawl_links两个ZenML步骤。步骤的实现可以在我们代码库中的steps/etl目录下找到。

image.png

我们从get_or_create_user ZenML步骤开始,首先导入脚本中使用的必要模块和函数:

from loguru import logger
from typing_extensions import Annotated
from zenml import get_step_context, step
from llm_engineering.application import utils
from llm_engineering.domain.documents import UserDocument

接下来,定义函数签名,该函数接收用户全名作为输入,并在MongoDB数据库中检索现有用户,或在用户不存在时创建一个新用户:

@step
def get_or_create_user(user_full_name: str) -> Annotated[UserDocument, "user"]:

我们使用一个工具函数将全名拆分为姓和名,然后尝试在数据库中检索该用户,若不存在则创建一个新用户。同时,我们还检索当前的步骤上下文,并将用户的元数据添加到输出中,使其反映在ZenML的用户输出工件的元数据中:

    logger.info(f"Getting or creating user: {user_full_name}")
    first_name, last_name = utils.split_user_full_name(user_full_name)
    user = UserDocument.get_or_create(first_name=first_name, last_name=last_name)
    step_context = get_step_context()
    step_context.add_output_metadata(output_name="user", metadata=_get_metadata(user_full_name, user))
    return user

此外,我们定义了一个辅助函数_get_metadata(),它构建一个包含查询参数和检索到的用户信息的字典,将其添加为用户工件的元数据:

def _get_metadata(user_full_name: str, user: UserDocument) -> dict:
    return {
        "query": {
            "user_full_name": user_full_name,
        },
        "retrieved": {
            "user_id": str(user.id),
            "first_name": user.first_name,
            "last_name": user.last_name,
        },
    }

接下来是crawl_links ZenML步骤,该步骤从提供的链接中收集数据。代码首先导入进行网络抓取所需的基本模块和库:

from urllib.parse import urlparse
from loguru import logger
from tqdm import tqdm
from typing_extensions import Annotated
from zenml import get_step_context, step
from llm_engineering.application.crawlers.dispatcher import CrawlerDispatcher
from llm_engineering.domain.documents import UserDocument

在导入之后,主函数接收特定作者的链接列表作为输入。在此函数中,初始化一个爬虫调度器(crawler dispatcher),并配置它以处理特定域名,如LinkedIn、Medium和GitHub:

@step
def crawl_links(user: UserDocument, links: list[str]) -> Annotated[list[str], "crawled_links"]:
    dispatcher = CrawlerDispatcher.build().register_linkedin().register_medium().register_github()
    logger.info(f"Starting to crawl {len(links)} link(s).")

该函数初始化变量用于存储输出元数据并统计成功抓取的数量。然后,它遍历每个链接,尝试抓取并提取数据,更新成功抓取的计数并累计每个URL的元数据:

    metadata = {}
    successfull_crawls = 0
    for link in tqdm(links):
        successfull_crawl, crawled_domain = _crawl_link(dispatcher, link, user)
        successfull_crawls += successfull_crawl
        metadata = _add_to_metadata(metadata, crawled_domain, successfull_crawl)

处理完所有链接后,函数将累计的元数据附加到输出工件上:

    step_context = get_step_context()
    step_context.add_output_metadata(output_name="crawled_links", metadata=metadata)
    logger.info(f"Successfully crawled {successfull_crawls} / {len(links)} links.")
    return links

代码还包含一个辅助函数,使用适当的爬虫尝试从每个链接中提取信息,并根据链接的域名处理抓取中的任何异常,返回一个指示抓取是否成功以及链接域名的元组:

def _crawl_link(dispatcher: CrawlerDispatcher, link: str, user: UserDocument) -> tuple[bool, str]:
    crawler = dispatcher.get_crawler(link)
    crawler_domain = urlparse(link).netloc
    try:
        crawler.extract(link=link, user=user)
        return (True, crawler_domain)
    except Exception as e:
        logger.error(f"An error occurred while crawling: {e!s}")
        return (False, crawler_domain)

另一个辅助函数用于更新元数据字典,以记录每次抓取的结果:

def _add_to_metadata(metadata: dict, domain: str, successfull_crawl: bool) -> dict:
    if domain not in metadata:
        metadata[domain] = {}
    metadata[domain]["successful"] = metadata.get(domain, {}).get("successful", 0) + successfull_crawl
    metadata[domain]["total"] = metadata.get(domain, {}).get("total", 0) + 1
    return metadata

正如上述_crawl_link()函数中所示,CrawlerDispatcher类能够根据每个链接的域名确定初始化哪个爬虫。此逻辑封装在爬虫的extract()方法中。接下来,让我们深入了解CrawlerDispatcher类,以便完全理解其工作原理。

调度器:如何实例化正确的爬虫?

爬虫逻辑的入口点是CrawlerDispatcher类。正如图3.4所示,调度器充当提供的链接与爬虫之间的中间层,负责确定每个URL应关联的爬虫。

CrawlerDispatcher类能够识别每个链接的域名并初始化用于从该站点收集数据的合适爬虫。例如,当提供一个文章链接并检测到https://medium.com域名时,它会构建一个MediumCrawler实例来抓取该特定平台的数据。了解这一点后,让我们深入探讨CrawlerDispatcher类的实现。

所有的爬虫逻辑均可在GitHub代码库的llm_engineering/application/crawlers目录中找到。

image.png

我们首先导入处理URL和正则表达式的必要Python模块,同时导入各个爬虫类:

import re
from urllib.parse import urlparse
from loguru import logger
from .base import BaseCrawler
from .custom_article import CustomArticleCrawler
from .github import GithubCrawler
from .linkedin import LinkedInCrawler
from .medium import MediumCrawler

CrawlerDispatcher类用于管理和调度基于给定URL及其域名的适当爬虫实例。其构造函数初始化了一个注册表,用于存储已注册的爬虫:

class CrawlerDispatcher:
    def __init__(self) -> None:
        self._crawlers = {}

由于我们使用了建造者模式来实例化和配置调度器,因此定义了一个build()类方法来返回调度器的实例:

    @classmethod
    def build(cls) -> "CrawlerDispatcher":
        dispatcher = cls()
        return dispatcher

调度器包含注册特定平台爬虫的方法,例如Medium、LinkedIn和GitHub。这些方法使用了一个通用的register()方法,将每个爬虫添加到注册表中。通过返回self,我们遵循建造者模式(关于建造者模式的更多信息请参见:builder pattern)。在实例化调度器时,可以通过链式调用多个register_*()方法,例如:CrawlerDispatcher.build().register_linkedin().register_medium()

    def register_medium(self) -> "CrawlerDispatcher":
        self.register("https://medium.com", MediumCrawler)
        return self

    def register_linkedin(self) -> "CrawlerDispatcher":
        self.register("https://linkedin.com", LinkedInCrawler)
        return self

    def register_github(self) -> "CrawlerDispatcher":
        self.register("https://github.com", GithubCrawler)
        return self

通用的register()方法对每个域名进行标准化,以确保其格式一致,然后将其作为键添加到调度器的self._crawlers注册表中。这是一个关键步骤,因为我们将使用字典的键作为域名模式,以便将来匹配URL时使用合适的爬虫:

    def register(self, domain: str, crawler: type[BaseCrawler]) -> None:
        parsed_domain = urlparse(domain)
        domain = parsed_domain.netloc
        self._crawlers[r"https://(www.)?{}/*".format(re.escape(domain))] = crawler

最后,get_crawler()方法通过匹配注册的域名来确定给定URL的合适爬虫。如果没有找到匹配项,它会记录一个警告并默认使用CustomArticleCrawler

    def get_crawler(self, url: str) -> BaseCrawler:
        for pattern, crawler in self._crawlers.items():
            if re.match(pattern, url):
                return crawler()
        else:
            logger.warning(f"No crawler found for {url}. Defaulting to CustomArticleCrawler.")
            return CustomArticleCrawler()

下一步是逐一分析每个爬虫,进一步了解数据收集管道的工作原理。

爬虫

在探索每个爬虫的实现之前,我们需要介绍它们的基类,该类定义了所有爬虫的统一接口。正如图3.4所示,我们之所以能够实现调度器层,是因为每个爬虫遵循相同的接口签名。每个爬虫类都实现了extract()方法,从而使我们可以利用面向对象编程(OOP)中的多态性来处理抽象对象,而无需关心它们的具体子类。例如,在ZenML步骤的_crawl_link()函数中,代码如下:

crawler = dispatcher.get_crawler(link)
crawler.extract(link=link, user=user)

注意,我们在调用extract()方法时并不关心具体实例化的是哪种爬虫类型。使用抽象接口可以确保代码的核心可重用性和扩展性。

基类

接下来,我们来看一下BaseCrawler接口,可以在GitHub仓库中找到。

from abc import ABC, abstractmethod

class BaseCrawler(ABC):
    model: type[NoSQLBaseDocument]
    
    @abstractmethod
    def extract(self, link: str, **kwargs) -> None:
        ...

如上所述,接口定义了一个extract()方法,接收一个链接作为输入。此外,它在类级别定义了一个model属性,用于表示保存到MongoDB数据仓库的数据类别文档类型。这种方式允许我们在保留类级别相同属性的情况下,用不同的数据类别自定义每个子类。接下来我们会深入了解NoSQLBaseDocument类。

我们还通过BaseSeleniumCrawler类扩展了BaseCrawler类,实现了可复用的Selenium功能,Selenium用于自动控制浏览器(例如登录LinkedIn、滚动浏览页面等)。

要使用Selenium爬虫,您需要在机器上安装Chrome浏览器(或基于Chromium的浏览器,例如Brave)。

BaseSeleniumCrawler类的代码首先设置了Selenium和ChromeDriver初始化所需的导入和配置。chromedriver_autoinstaller确保自动安装与本地Chrome浏览器版本兼容的ChromeDriver:

import time
from tempfile import mkdtemp
import chromedriver_autoinstaller
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from llm_engineering.domain.documents import NoSQLBaseDocument

# 检查当前版本的chromedriver是否存在,如不存在则自动下载
chromedriver_autoinstaller.install()

接下来,我们定义BaseSeleniumCrawler类,用于需要Selenium进行数据收集的场景,例如从Medium或LinkedIn收集数据。

其构造函数初始化了多个Chrome选项来优化性能、增强安全性并确保无头浏览环境。这些选项禁用了一些不必要的功能,例如GPU渲染、扩展和通知,这些功能可能会干扰自动化浏览:

class BaseSeleniumCrawler(BaseCrawler, ABC):
    def __init__(self, scroll_limit: int = 5) -> None:
        options = webdriver.ChromeOptions()
        
        options.add_argument("--no-sandbox")
        options.add_argument("--headless=new")
        options.add_argument("--disable-dev-shm-usage")
        options.add_argument("--log-level=3")
        options.add_argument("--disable-popup-blocking")
        options.add_argument("--disable-notifications")
        options.add_argument("--disable-extensions")
        options.add_argument("--disable-background-networking")
        options.add_argument("--ignore-certificate-errors")
        options.add_argument(f"--user-data-dir={mkdtemp()}")
        options.add_argument(f"--data-path={mkdtemp()}")
        options.add_argument(f"--disk-cache-dir={mkdtemp()}")
        options.add_argument("--remote-debugging-port=9226")

在设置Chrome选项之后,代码允许子类通过调用set_extra_driver_options()方法来设置其他选项。然后初始化滚动限制并创建Chrome驱动程序的新实例:

        self.set_extra_driver_options(options)
        self.scroll_limit = scroll_limit
        self.driver = webdriver.Chrome(
            options=options,
        )

BaseSeleniumCrawler类包含用于设置附加驱动程序选项的set_extra_driver_options()和登录的login()占位方法,子类可以覆盖这些方法,以提供特定的功能。这确保了模块化,因为每个平台都有不同的登录页面和HTML结构:

    def set_extra_driver_options(self, options: Options) -> None:
        pass

    def login(self) -> None:
        pass

最后,scroll_page()方法实现了一个滚动机制,用于遍历页面(例如LinkedIn),达到指定的滚动限制。它滚动到底部,等待新内容加载,重复该过程直到页面结束或滚动限制超出:

    def scroll_page(self) -> None:
        """Scroll through the LinkedIn page based on the scroll limit."""
        current_scroll = 0
        last_height = self.driver.execute_script("return document.body.scrollHeight")
        while True:
            self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
            time.sleep(5)
            new_height = self.driver.execute_script("return document.body.scrollHeight")
            if new_height == last_height or (self.scroll_limit and current_scroll >= self.scroll_limit):
                break
            last_height = new_height
            current_scroll += 1

特定爬虫的实现

我们已经了解了爬虫基类的结构,接下来我们将深入探讨以下特定爬虫的实现:

  • GitHubCrawler(BaseCrawler)
  • CustomArticleCrawler(BaseCrawler)
  • MediumCrawler(BaseSeleniumCrawler)

您可以在GitHub仓库中找到这些爬虫的实现。

GitHubCrawler 类

GithubCrawler类用于爬取GitHub仓库,扩展了BaseCrawler的功能。由于可以利用Git的克隆功能,无需通过浏览器登录GitHub,因此不需要Selenium的功能。初始化时,它设置了一个忽略的文件和目录列表,例如.git.toml.lock.png,以确保排除不必要的文件:

class GithubCrawler(BaseCrawler):
    model = RepositoryDocument

    def __init__(self, ignore=(".git", ".toml", ".lock", ".png")) -> None:
        super().__init__()
        self._ignore = ignore

接下来,我们实现extract()方法。首先,爬虫检查仓库是否已经在数据库中存储过。如果已存在,则退出方法以避免重复存储:

def extract(self, link: str, **kwargs) -> None:
    old_model = self.model.find(link=link)
    if old_model is not None:
        logger.info(f"Repository already exists in the database: {link}")
        return

如果仓库是新的,爬虫会从链接中提取仓库名称。然后创建一个临时目录来克隆仓库,以便在处理后清理本地磁盘上的克隆文件:

    logger.info(f"Starting scrapping GitHub repository: {link}")
    repo_name = link.rstrip("/").split("/")[-1]
    local_temp = tempfile.mkdtemp()

try块中,爬虫将当前工作目录更改为临时目录,并在不同进程中执行git clone命令:

    try:
        os.chdir(local_temp)
        subprocess.run(["git", "clone", link])

克隆成功后,爬虫构建克隆仓库的路径,初始化一个空字典来标准化文件内容。它遍历目录树,跳过任何与忽略模式匹配的目录或文件。对于每个相关文件,读取内容,删除空格,并将其存储在字典中,以文件路径作为键:

        repo_path = os.path.join(local_temp, os.listdir(local_temp)[0])
        tree = {}
        for root, _, files in os.walk(repo_path):
            dir = root.replace(repo_path, "").lstrip("/")
            if dir.startswith(self._ignore):
                continue
            for file in files:
                if file.endswith(self._ignore):
                    continue
                file_path = os.path.join(dir, file)
                with open(os.path.join(root, file), "r", errors="ignore") as f:
                    tree[file_path] = f.read().replace(" ", "")

然后,它创建一个RepositoryDocument模型的新实例,并填充仓库内容、名称、链接、平台信息和作者详细信息,最后保存到MongoDB:

        user = kwargs["user"]
        instance = self.model(
            content=tree,
            name=repo_name,
            link=link,
            platform="github",
            author_id=user.id,
            author_full_name=user.full_name,
        )
        instance.save()

无论爬取成功与否,爬虫都会确保删除临时目录,以清理所使用的资源:

    except Exception:
        raise
    finally:
        shutil.rmtree(local_temp)
    logger.info(f"Finished scrapping GitHub repository: {link}")

CustomArticleCrawler 类

CustomArticleCrawler类采用不同的方法从互联网收集数据。它使用AsyncHtmlLoader类读取链接中的HTML,使用Html2TextTransformer类从HTML中提取文本。这两个类由langchain_community包提供。

首先,导入所需模块:

from urllib.parse import urlparse
from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_community.document_transformers.html2text import Html2TextTransformer
from loguru import logger
from llm_engineering.domain.documents import ArticleDocument
from .base import BaseCrawler

CustomArticleCrawler类继承自BaseCrawler。在extract()方法中,首先检查文章是否已经在数据库中存在,以避免重复内容:

class CustomArticleCrawler(BaseCrawler):
    model = ArticleDocument

    def extract(self, link: str, **kwargs) -> None:
        old_model = self.model.find(link=link)
        if old_model is not None:
            logger.info(f"Article already exists in the database: {link}")
            return

如果文章不存在,我们继续爬取内容。使用AsyncHtmlLoader类从提供的链接加载HTML,然后使用Html2TextTransformer类将其转换为纯文本,并返回一个文档列表。由于我们将整个逻辑委托给这两个类,因此对内容的提取和解析不完全可控。这种方式适合用作没有自定义实现的域的备选系统:

        logger.info(f"Starting scrapping article: {link}")
        loader = AsyncHtmlLoader([link])
        docs = loader.load()
        html2text = Html2TextTransformer()
        docs_transformed = html2text.transform_documents(docs)
        doc_transformed = docs_transformed[0]

我们从提取的文档中获取页面内容以及元数据(如标题、副标题、内容和语言):

        content = {
            "Title": doc_transformed.metadata.get("title"),
            "Subtitle": doc_transformed.metadata.get("description"),
            "Content": doc_transformed.page_content,
            "language": doc_transformed.metadata.get("language"),
        }

然后解析URL以确定文章爬取的平台或域名:

        parsed_url = urlparse(link)
        platform = parsed_url.netloc

创建一个新的文章模型实例,填充提取的内容,最后将该实例保存到MongoDB数据仓库中:

        user = kwargs["user"]
        instance = self.model(
            content=content,
            link=link,
            platform=platform,
            author_id=user.id,
            author_full_name=user.full_name,
        )
        instance.save()
        logger.info(f"Finished scrapping custom article: {link}") 

MediumCrawler类

MediumCrawler 类代码从导入必要的库并定义继承自 BaseSeleniumCrawlerMediumCrawler 类开始:

from bs4 import BeautifulSoup
from loguru import logger
from llm_engineering.domain.documents import ArticleDocument
from .base import BaseSeleniumCrawler

class MediumCrawler(BaseSeleniumCrawler):
    model = ArticleDocument

MediumCrawler 类中,我们利用 set_extra_driver_options() 方法扩展了 Selenium 使用的默认驱动选项:

def set_extra_driver_options(self, options) -> None:
    options.add_argument(r"--profile-directory=Profile 2")

extract() 方法实现了核心功能,首先检查文章是否已存在于数据库中,以防重复条目。如果是新文章,方法继续导航到文章链接并滚动页面,以确保加载所有内容:

def extract(self, link: str, **kwargs) -> None:
    old_model = self.model.find(link=link)
    if old_model is not None:
        logger.info(f"Article already exists in the database: {link}")
        return
    logger.info(f"Starting scrapping Medium article: {link}")
    self.driver.get(link)
    self.scroll_page()

在页面完全加载后,该方法使用 BeautifulSoup 解析 HTML 内容,提取文章的标题、副标题和完整文本。BeautifulSoup 是用于网页抓取和解析 HTML 或 XML 文档的流行 Python 库。因此,我们使用它从 Selenium 访问的 HTML 中提取所需的所有 HTML 元素,并将所有内容聚合到字典中:

soup = BeautifulSoup(self.driver.page_source, "html.parser")
title = soup.find_all("h1", class_="pw-post-title")
subtitle = soup.find_all("h2", class_="pw-subtitle-paragraph")
data = {
    "Title": title[0].string if title else None,
    "Subtitle": subtitle[0].string if subtitle else None,
    "Content": soup.get_text(),
}

最后,该方法关闭 WebDriver 以释放资源。然后,它创建一个新的 ArticleDocument 实例,使用提取的内容和通过 kwargs 提供的用户信息进行填充,并将其保存到数据库中:

self.driver.close()
user = kwargs["user"]
instance = self.model(
    platform="medium",
    content=data,
    link=link,
    author_id=user.id,
    author_full_name=user.full_name,
)
instance.save()
logger.info(f"Successfully scraped and saved article: {link}")

至此,MediumCrawler 的实现完成。LinkedIn 爬虫的模式与 Medium 爬虫类似,它使用 Selenium 登录并访问用户最新帖子的动态,提取帖子并滚动加载页面直至达到限制。完整实现可以在我们的仓库中查看:GitHub

随着 LLM 的兴起,从互联网上收集数据已成为许多实际 AI 应用的重要步骤。因此,Python 生态系统中出现了更多高级工具,如用于爬取网站并从页面提取结构化数据的 Scrapy 和专门针对 LLM 和 AI 应用程序的数据爬取的 Crawl4AI

在本节中,我们研究了三种爬虫的实现:一种利用 git 可执行文件在子进程中克隆 GitHub 仓库,一种使用 LangChain 工具提取单个网页的 HTML,另一种使用 Selenium 应对更复杂的场景,如导航登录页面、滚动文章加载全部 HTML 并将其提取为文本格式。最后,我们理解了章节中使用的文档类(如 ArticleDocument)的工作原理。

NoSQL 数据仓库文档

我们需要实现三个文档类来构建数据类别。这些类定义了文档所需的特定属性,例如内容、作者和源链接。最佳实践是将数据结构化为类,而不是字典,因为为每个项设置的属性更加详尽,这减少了运行错误。例如,在从 Python 字典中访问一个值时,无法确定该值是否存在或其类型是否正确。而通过类封装数据项,可以确保每个属性符合预期。

通过使用 Pydantic 等 Python 包,我们可以进行开箱即用的类型验证,确保数据集的一致性。因此,我们将数据类别建模为以下文档类,代码中已经使用了它们:

  • ArticleDocument
  • PostDocument
  • RepositoryDocument

这些类不仅是简单的 Python 数据类或 Pydantic 模型,它们支持对 MongoDB 数据仓库的读写操作。为了避免在所有文档类中重复代码,我们使用了基于对象-关系映射 (ORM) 模式的对象-文档映射 (ODM) 软件模式。接下来我们会先探讨 ORM,再了解 ODM,最后深入我们自定义的 ODM 实现和文档类。

ORM 和 ODM 软件模式

在讨论软件模式之前,先了解什么是 ORM。ORM 是一种允许使用面向对象的范式查询和操作数据库数据的技术。通过 ORM 类封装数据库操作(主要是 CRUD 操作),我们可以避免手动处理数据库操作,减少了编写样板代码的需求。ORM 通常与 SQL 数据库(如 PostgreSQL 或 MySQL)交互。

大多数现代 Python 应用在与数据库交互时使用 ORM。尽管 SQL 在数据领域仍很流行,但在 Python 后端组件中很少见到原生 SQL 查询。最流行的 Python ORM 是 SQLAlchemy。此外,随着 FastAPI 的兴起,SQLModel 也成为一种常用选择,它是 SQLAlchemy 的封装,便于与 FastAPI 集成。

例如,使用 SQLAlchemy,我们可以定义一个包含 ID 和 name 字段的 User ORM。该 User ORM 被映射到 SQL 数据库中的 users 表。因此,当我们创建新用户并提交到数据库时,数据会自动保存到 users 表中,所有 CRUD 操作也同样适用:

from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker

Base = declarative_base()

# 定义一个类来映射到 users 表
class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    name = Column(String)

利用 User ORM,我们可以直接在 Python 中插入或查询用户,而无需编写 SQL 语句。通常,ORM 支持所有 CRUD 操作。以下代码展示了如何将 User ORM 的实例保存到 SQLite 数据库中:

engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)

# 创建一个与数据库交互的会话
Session = sessionmaker(bind=engine)
session = Session()

# 添加新用户
new_user = User(name="Alice")
session.add(new_user)
session.commit()

以下代码展示了如何从 users 表中查询用户:

user = session.query(User).first()
if user:
    print(f"User ID: {user.id}")
    print(f"User name: {user.name}")

完整脚本及其运行方式可以在 GitHub 仓库中找到:code_snippets/03_orm.py

ODM 模式与 ORM 非常相似,但它适用于 NoSQL 数据库(如 MongoDB)和非结构化集合。与 SQL 数据库使用的表不同,NoSQL 数据库的结构围绕集合展开,存储 JSON-like 文档,而不是表格中的行。

综上所述,ODM 简化了基于文档的 NoSQL 数据库操作,将面向对象的代码映射到 JSON-like 文档。我们将基于 MongoDB 实现一个轻量级的 ODM 模块,以深入理解 ODM 的工作原理。

实现 ODM 类

本节将探讨如何从头开始实现一个 ODM 类。这是一个绝佳的练习,可以帮助我们了解 ODM 的工作原理,并提高编写模块化和可复用 Python 类的技能。我们将实现一个名为 NoSQLBaseDocument 的基础 ODM 类,所有其他文档类将继承该类以与 MongoDB 数据仓库交互。

代码可在我们的仓库中找到:llm_engineering/domain/base/nosql.py

代码首先导入必要的模块并设置数据库连接。通过 _database 变量,我们与在设置中指定的数据库建立连接,默认情况下该数据库名为 twin

import uuid
from abc import ABC
from typing import Generic, Type, TypeVar
from loguru import logger
from pydantic import UUID4, BaseModel, Field
from pymongo import errors
from llm_engineering.domain.exceptions import ImproperlyConfigured
from llm_engineering.infrastructure.db.mongo import connection
from llm_engineering.settings import settings

_database = connection.get_database(settings.DATABASE_NAME)

接下来,我们定义一个绑定到 NoSQLBaseDocument 类的类型变量 T,以便泛化类的类型。例如,当我们实现继承自 NoSQLBaseDocumentArticleDocument 类时,T 的所有实例将被替换为 ArticleDocument 类型。

NoSQLBaseDocument 类作为一个抽象基类被声明,继承自 Pydantic 的 BaseModel、Python 的 Generic(提供泛型功能)和 ABC(使该类成为抽象类):

T = TypeVar("T", bound="NoSQLBaseDocument")

class NoSQLBaseDocument(BaseModel, Generic[T], ABC):

NoSQLBaseDocument 类中,我们定义了一个 id 字段,类型为 UUID4,并通过默认工厂生成一个唯一的 UUID。该类还实现了 __eq____hash__ 方法,以便基于唯一的 id 属性对实例进行比较和在哈希集合中使用(例如在集合或字典键中):

id: UUID4 = Field(default_factory=uuid.uuid4)

def __eq__(self, value: object) -> bool:
    if not isinstance(value, self.__class__):
        return False
    return self.id == value.id

def __hash__(self) -> int:
    return hash(self.id)

该类提供了在 MongoDB 文档和类实例之间转换的方法。from_mongo() 类方法将从 MongoDB 获取的字典转换为类实例,而 to_mongo() 实例方法将模型实例转换为适合 MongoDB 插入的字典:

@classmethod
def from_mongo(cls: Type[T], data: dict) -> T:
    if not data:
        raise ValueError("Data is empty.")
    id = data.pop("_id")
    return cls(**dict(data, id=id))

def to_mongo(self: T, **kwargs) -> dict:
    exclude_unset = kwargs.pop("exclude_unset", False)
    by_alias = kwargs.pop("by_alias", True)
    parsed = self.model_dump(exclude_unset=exclude_unset, by_alias=by_alias, **kwargs)
    if "_id" not in parsed and "id" in parsed:
        parsed["_id"] = str(parsed.pop("id"))
    for key, value in parsed.items():
        if isinstance(value, uuid.UUID):
            parsed[key] = str(value)
    return parsed

save() 方法允许将模型实例插入到 MongoDB 集合中。它获取适当的集合,利用 to_mongo() 方法将实例转换为 MongoDB 兼容的文档,并尝试将其插入数据库,处理可能发生的写入错误:

def save(self: T, **kwargs) -> T | None:
    collection = _database[self.get_collection_name()]
    try:
        collection.insert_one(self.to_mongo(**kwargs))
        return self
    except errors.WriteError:
        logger.exception("Failed to insert document.")
        return None

get_or_create() 类方法尝试在数据库中查找符合给定筛选条件的文档。如果找到匹配的文档,则将其转换为类实例;如果没有,则使用筛选条件创建一个新实例并保存到数据库中:

@classmethod
def get_or_create(cls: Type[T], **filter_options) -> T:
    collection = _database[cls.get_collection_name()]
    try:
        instance = collection.find_one(filter_options)
        if instance:
            return cls.from_mongo(instance)
        new_instance = cls(**filter_options)
        new_instance = new_instance.save()
        return new_instance
    except errors.OperationFailure:
        logger.exception(f"Failed to retrieve document with filter options: {filter_options}")
        raise

bulk_insert() 类方法允许一次性将多个文档插入到数据库中:

@classmethod
def bulk_insert(cls: Type[T], documents: list[T], **kwargs) -> bool:
    collection = _database[cls.get_collection_name()]
    try:
        collection.insert_many([doc.to_mongo(**kwargs) for doc in documents])
        return True
    except (errors.WriteError, errors.BulkWriteError):
        logger.error(f"Failed to insert documents of type {cls.__name__}")
        return False

find() 类方法在数据库中搜索符合给定筛选条件的单个文档:

@classmethod
def find(cls: Type[T], **filter_options) -> T | None:
    collection = _database[cls.get_collection_name()]
    try:
        instance = collection.find_one(filter_options)
        if instance:
            return cls.from_mongo(instance)
        return None
    except errors.OperationFailure:
        logger.error("Failed to retrieve document.")
        return None

类似地,bulk_find() 类方法检索符合筛选条件的多个文档,将每个获取到的 MongoDB 文档转换为模型实例,并将它们收集到一个列表中:

@classmethod
def bulk_find(cls: Type[T], **filter_options) -> list[T]:
    collection = _database[cls.get_collection_name()]
    try:
        instances = collection.find(filter_options)
        return [document for instance in instances if (document := cls.from_mongo(instance)) is not None]
    except errors.OperationFailure:
        logger.error("Failed to retrieve document.")
        return []

最后,get_collection_name() 类方法确定与类关联的 MongoDB 集合的名称。它要求类具有一个嵌套的 Settings 类,其中包含指定集合名称的 name 属性。如果此配置缺失,将引发 ImproperlyConfigured 异常,提示子类应定义嵌套的 Settings 类:

@classmethod
def get_collection_name(cls: Type[T]) -> str:
    if not hasattr(cls, "Settings") or not hasattr(cls.Settings, "name"):
        raise ImproperlyConfigured(
            "Document should define an Settings configuration class with the name of the collection."
        )
    return cls.Settings.name

我们可以通过嵌套的 Settings 类来配置每个子类,例如定义集合名称或其他特定于子类的内容。在 Python 生态系统中,有一个基于 MongoDB 的 ODM 实现,名为 mongoengine。我们实现了自己的 ODM 类,这是一个很好的练习,有助于实践编写模块化和通用代码,遵循面向对象编程的最佳实践,这是实现生产级代码的基础。

数据类别和用户文档类

最后一部分是查看从 NoSQLBaseDocument 基类继承的子类的实现。这些具体类定义了我们的数据类别。你在章节中看到这些类被用于处理爬虫类中的文章、仓库和帖子。

我们首先导入必要的 Python 模块和 ODM 基类:

from abc import ABC
from typing import Optional
from pydantic import UUID4, Field
from .base import NoSQLBaseDocument
from .types import DataCategory

接着定义一个枚举类,集中所有的数据类别类型。这些变量将在本书中用于配置所有 ODM 类的常量。该类可以在仓库中的 llm_engineering/domain/types.py 文件中找到:

from enum import StrEnum

class DataCategory(StrEnum):
    PROMPT = "prompt"
    QUERIES = "queries"
    INSTRUCT_DATASET_SAMPLES = "instruct_dataset_samples"
    INSTRUCT_DATASET = "instruct_dataset"
    PREFERENCE_DATASET_SAMPLES = "preference_dataset_samples"
    PREFERENCE_DATASET = "preference_dataset"
    POSTS = "posts"
    ARTICLES = "articles"
    REPOSITORIES = "repositories"

Document 类作为 NoSQLBaseDocument ODM 类的抽象基础模型,为其他文档提供继承。在此类中定义了常见属性,如内容、平台和作者详细信息,为继承它的文档提供了标准化的结构:

class Document(NoSQLBaseDocument, ABC):
    content: dict
    platform: str
    author_id: UUID4 = Field(alias="author_id")
    author_full_name: str = Field(alias="author_full_name")

最后,通过扩展 Document 类定义了特定的文档类型。RepositoryDocumentPostDocumentArticleDocument 类表示不同类别的数据,每个类包含唯一的字段和设置,用于指定它们在数据库中的集合名称:

class RepositoryDocument(Document):
    name: str
    link: str
    class Settings:
        name = DataCategory.REPOSITORIES

class PostDocument(Document):
    image: Optional[str] = None
    link: str | None = None
    class Settings:
        name = DataCategory.POSTS

class ArticleDocument(Document):
    link: str
    class Settings:
        name = DataCategory.ARTICLES

最后,我们定义了 UserDocument 类,用于存储和查询 LLM Twin 项目中的所有用户:

class UserDocument(NoSQLBaseDocument):
    first_name: str
    last_name: str
    class Settings:
        name = "users"

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

通过实现 NoSQLBaseDocument ODM 类,我们仅需关注每个文档或领域实体的字段和特定功能,所有 CRUD 功能都委托给父类。同时,通过使用 Pydantic 定义字段,我们具备开箱即用的类型验证。例如,当创建 ArticleDocument 类的实例时,如果提供的 link 为 None 或不是字符串,将抛出错误,表明数据无效。

至此,我们已经完成了数据收集管道的实现,首先是 ZenML 组件,然后是爬虫的实现,最后完成了 ODM 类和数据类别文档的封装。最后一步是运行数据收集管道,并将原始数据摄入 MongoDB 数据仓库。

将原始数据收集到数据仓库

ZenML 协调数据收集管道。因此,通过利用 ZenML,可以手动运行数据收集管道,或根据特定事件进行调度或触发。在这里,我们将展示如何手动运行管道,其他场景将在第 11 章的 MLOps 深入讨论中进行探讨。

我们为每个作者配置了不同的管道运行,并提供了一个用于 Paul Iusztin 或 Maxime Labonne 数据的 ZenML 配置文件。例如,要调用数据收集管道来收集 Maxime 的数据,可以运行以下 CLI 命令:

poetry poe run-digital-data-etl-maxime

该命令将使用以下 ZenML YAML 配置文件来调用管道:

parameters:
  user_full_name: Maxime Labonne # [First Name(s)] [Last Name]
  links:
    # 个人博客
    - https://mlabonne.github.io/blog/posts/2024-07-29_Finetune_Llama31.html
    - https://mlabonne.github.io/blog/posts/2024-07-15_The_Rise_of_Agentic_Data_Generation.html
    # Substack
    - https://maximelabonne.substack.com/p/uncensor-any-llm-with-abliteration-d30148b7d43e
    - https://maximelabonne.substack.com/p/create-mixtures-of-experts-with-mergekit-11b318c99562
    - https://maximelabonne.substack.com/p/merge-large-language-models-with-mergekit-2118fb392b54
    … # 更多 Substack 链接

在前面的图 3.3 中,我们可以在 ZenML 的仪表板上看到管道的运行 DAG 及其详细信息。同时,图 3.5 展示了此数据收集管道生成的用户输出工件。您可以查看查询 user_full_name 及从 MongoDB 数据库中检索到的用户信息,其中我们收集了该特定运行中的链接。

image.png

在图 3.6 中,你还可以看到 crawled_links 输出工件,其中列出了我们收集数据的所有域名、每个域中爬取的链接总数以及成功收集的链接数量。

我们再次强调这些工件的强大之处,它们记录了每个管道的结果和元数据,使得单独监控和调试每次管道运行变得极为简单。

image.png

我们可以通过以下代码在任何位置下载 crawled_links 工件,其中工件的 ID 可以在 ZenML 中找到,每个版本的工件都有唯一的 ID:

from zenml.client import Client

artifact = Client().get_artifact_version('8349ce09-0693-4e28-8fa2-20f82c76ddec')
loaded_artifact = artifact.load()

例如,我们可以使用 Paul Iusztin 的 YAML 配置轻松运行相同的数据收集管道,配置如下:

parameters:
  user_full_name: Paul Iusztin # [First Name(s)] [Last Name]
  links:
    # Medium
    - https://medium.com/decodingml/an-end-to-end-framework-for-production-ready-llm-systems-by-building-your-llm-twin-2cc6bb01141f
    - https://medium.com/decodingml/a-real-time-retrieval-system-for-rag-on-social-media-data-9cc01d50a2a0
    - https://medium.com/decodingml/sota-python-streaming-pipelines-for-fine-tuning-llms-and-rag-in-real-time-82eb07795b87
    … # 更多 Medium 链接
    # Substack
    - https://decodingml.substack.com/p/real-time-feature-pipelines-with?r=1ttoeh
    - https://decodingml.substack.com/p/building-ml-systems-the-right-way?r=1ttoeh
    - https://decodingml.substack.com/p/reduce-your-pytorchs-code-latency?r=1ttoeh
    … # 更多 Substack 链接

要使用 Paul 的配置运行管道,我们调用以下 poe 命令:

poetry poe run-digital-data-etl-paul

这个命令实际上调用了以下 CLI 命令,引用了 Paul 的配置文件:

poetry run python -m tools.run --run-etl --no-cache --etl-config-filename digital_data_etl_paul_iusztin.yaml

所有配置文件可以在仓库的 configs/ 目录中找到。此外,通过 poe,我们配置了一个命令来调用所有作者的数据收集管道:

poetry poe run-digital-data-etl

我们可以使用 ODM 类轻松查询 MongoDB 数据仓库。例如,查询 Paul Iusztin 收集的所有文章:

from llm_engineering.domain.documents import ArticleDocument, UserDocument

user = UserDocument.get_or_create(first_name="Paul", last_name="Iusztin")
articles = ArticleDocument.bulk_find(author_id=str(user.id))

print(f"User ID: {user.id}")
print(f"User name: {user.first_name} {user.last_name}")
print(f"Number of articles: {len(articles)}")
print("First article link:", articles[0].link)

以上代码的输出为:

User ID: 900fec95-d621-4315-84c6-52e5229e0b96
User name: Paul Iusztin
Number of articles: 50
First article link: https://medium.com/decodingml/an-end-to-end-framework-for-production-ready-llm-systems-by-building-your-llm-twin-2cc6bb01141f

仅需两行代码,我们就可以使用项目中定义的任何 ODM 查询并过滤 MongoDB 数据仓库。

此外,为确保数据收集管道按预期工作,可以使用 IDE 的 MongoDB 插件在 MongoDB 集合中进行搜索。插件需单独安装,例如可以在 VSCode 中使用此插件:MongoDB 插件。对于其他 IDE,可以使用类似插件或外部 NoSQL 可视化工具。连接到 MongoDB 可视化工具后,可以通过以下 URI 连接到本地数据库:

mongodb://llm_engineering:llm_engineering@127.0.0.1:27017

对于云端 MongoDB 集群,需要更改 URI,这将在第 11 章中探讨。

至此,你已学会如何使用不同的 ZenML 配置运行数据收集管道,以及如何查看每次运行的输出工件。我们还探讨了如何针对特定数据类别和作者查询数据仓库。这样,我们完成了数据工程章节,即将进入总结部分。

故障排查

存储在 MongoDB 数据库中的原始数据是所有后续步骤的核心。因此,如果由于爬虫问题未成功运行本章的代码,本节提供了解决潜在问题的方案,帮助你继续前进。

Selenium 问题

众所周知,运行 Selenium 可能会因为浏览器驱动(如 ChromeDriver)的问题而导致问题。如果使用 Selenium 的爬虫(如 MediumCrawler)因 ChromeDriver 问题而失败,你可以通过注释掉数据收集 YAML 配置中的 Medium 链接来绕过此问题。

为此,请前往 configs/ 目录,找到所有以 digital_data_etl_* 开头的 YAML 文件,例如 digital_data_etl_maxime_labonne.yaml。打开它们并注释掉所有与 Medium 相关的 URL,如图 3.7 所示。你可以保留 Substack 或个人博客的链接,因为这些使用了不依赖 Selenium 的 CustomArticleCrawler

image.png

导入备份数据

如果其他方法都无法解决问题,你可以使用 data/data_warehouse_raw_data 目录中保存的备份数据来填充 MongoDB 数据库,这样可以在不运行数据收集 ETL 代码的情况下继续后续的微调和推理部分。要导入该目录中的所有数据,请运行以下命令:

poetry poe run-import-data-warehouse-from-json

运行上述 CLI 命令后,你将获得一个与开发代码时使用的数据集一模一样的副本。为了确保导入成功,MongoDB 数据库中应包含 88 篇文章和 3 位用户。

总结

在本章中,我们学习了如何设计和构建适用于 LLM Twin 的数据收集管道。我们没有依赖静态数据集,而是收集了自定义数据,以模拟现实情况,为构建 AI 系统中的实际挑战做好准备。

首先,我们分析了 LLM Twin 的数据收集管道的架构,该架构作为一个 ETL 过程运行。接下来,我们深入了解了管道的实现过程,从使用 ZenML 编排管道开始,随后分析了爬虫的实现。我们学习了三种抓取数据的方式:通过子进程中的 CLI 命令,使用 LangChain 的工具函数,或通过 Selenium 来编写自定义逻辑以编程方式操作浏览器。最后,我们探讨了如何构建自定义的 ODM 类,并用它来定义文档类层次结构,包括文章、帖子和仓库等实体。

在本章结尾,我们学习了如何使用不同的 YAML 配置文件运行 ZenML 管道,并在仪表板中查看结果。我们还了解了如何通过 ODM 类与 MongoDB 数据仓库进行交互。

下一章将介绍 RAG 特性管道的关键步骤,包括文档分块与嵌入、将这些文档导入向量数据库,以及应用预检索优化来提升性能。我们还将使用 Pulumi 编程搭建必要的基础设施,最终在 AWS 上部署 RAG 导入管道。

参考文献