[博客翻译]在LLM的土地上,我们能做得更好的模拟数据生成吗?


原文地址:https://neurelo.substack.com/p/in-the-land-of-llms-can-we-do-better


"Lorem ipsum 是拉丁文,略带混乱,源自西塞罗的《论善恶的终结》1.10.32 段落,其开头为 'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit...' *[世上没有一个人会爱痛苦本身,追求它并想要拥有它,仅仅因为它就是痛苦。]。《论善恶的终结》写于公元前 45 年,是一部在文艺复兴时期非常流行的伦理学论文。"

“让我觉得令人惊叹的是,这段文字自 16 世纪某印刷商将一排排字模打乱以制作样本册以来,就一直是业内标准的填充文本;它不仅经过了四个世纪逐字重排的考验,甚至在跃入电子排版时代后也几乎未变,除了偶尔添加的一个‘ing’或‘y’。有趣的是,当可以理解的拉丁文被打乱时,它变得和希腊语一样晦涩;‘这对我来说是希腊语’和‘打乱排版’这两个短语有共同的语义根源!”

  • Richard McClintock 在 1994 年致《Before & After 杂志》编辑的一封信中描述其发现 Lorem Ipsum 的起源

模拟数据或合成数据并不是真实数据的替代品,但它是一项在测试和开发中具有显著效用的技术。高保真的模拟数据可以大幅加速软件开发周期。然而,如果你回顾生成模拟数据的历史,你会发现它在几十年内几乎停滞不前,并且仍然需要大量的工作才能使其正确运行。虽然我们看到了逐步的进步,但革命尚未到来。

那么,这种革命会是什么模样呢?“高保真的模拟数据,一键搞定!”一位随机程序员抱着一个 Quackles 橡皮鸭喊道。但在这种背景下,“高保真”究竟是什么意思呢?我认为我们真正想要的是一种能够深入理解用户模式及其底层目标的技术,以便为测试数据库自动生成高保真度的合成数据。

在 Neurelo,我们的目标是让使用数据库变得更加简单。许多用户最初通过连接到一个空数据源来评估我们的平台。还有一些人在通过更改现有模式、进行测试并在对其模式及其相应的 Neurelo 自动生成和定制 API 满意后提交这些更改的方式下开发新功能。为了测试这些更改,他们通常也是从空数据库开始的。早期,我们就意识到要让用户使用这些 API 时获得类似于生产环境的体验,我们必须智能地为他们的空数据源模拟数据,而这正是我们所做的一切。

在这篇博客文章中,我将讨论我们是如何构建 Neurelo 的“模拟数据生成”技术——其内部机制、核心部件以及整个流程!

当我们开始这个项目时,我们心中有五个要求:

  1. 它应该是多样的,意味着它可以与我们支持的所有三种数据源一起工作:MongoDB、MySQL 和 Postgres。
  2. 它应基于模式生成现实的数据,无需任何外部用户输入——这是一个“一键式”解决方案,摩擦最小。
  3. 成本应该尽可能低,在可扩展性与准确性之间取得平衡。
  4. 响应时间应该非常快。
  5. 我们应该完全使用 Rust 来完成这项工作。

要了解为什么在这个项目中使用 Rust 对我们来说至关重要,让我们看看 Neurelo 底层架构的简化视图:

Neurelo 的管理层托管我们整个前端,并主要负责将各种由用户指定的操作传达给我们的运营平面。而运营平面,则是实际执行作业的地方。在许多情况下,它将这些作业委托给查询处理器执行。

查询处理器是 Neurelo 的心脏。其主要职责包括:

  1. 翻译传入的数据 API,并向数据库生成查询。
  2. 内省现有数据库模式。
  3. 使用我们的迁移工具管理随时间变化的模式等。

“模拟数据生成”作为运营平面上的另一个“作业”,最终被委托给查询处理器。这一点非常重要,因为这使得我们可以利用现有的写入 API 路径对数据库执行查询,而无需构建新的路径。这种方法不仅提高了性能,还有助于维护。由于我们整个查询处理器都是用 Rust 编写的,因此我们需要一个基于 Rust 的模拟数据生成器。

语言模型能解救我们吗?

从一开始,我们就认为大型语言模型(LLMs)非常适合这项任务,并询问自己如何能利用这些模型来实现这一目标。

我们的初步方法涉及使用 LLMs 生成 Rust 代码以动态创建原始 INSERT 查询。然而,尽管多次尝试调优提示,我们仍未能在多个迭代中达到可再现的准确性。该方法面临两个主要问题:首先,生成的 Rust 代码并不总是可编译的;其次,即使最终编译成功,合成数据的质量也不佳——它们倾向于退化成像“Movie1”,“Movie2”这样的通用格式。

我们可以尝试超越零样本学习的方法进一步优化提示,但我们很快意识到这种方法只是在原地踏步,并且需要一个更确定性的基础。

但好奇心让我们想知道,我们是否可以用 Python 来实现同样的目标?根据我们的理解,LLMs 特别擅长编写可执行的 Python 代码。此外,通过提示它们利用 Python 的“faker”第三方模块还可以帮助我们解决第二个问题。

这时,我们遇到了一个大问题!当你模拟数据库模式中的多张表时,插入顺序是很重要的

疯狂的帽子匠提出“插入顺序”

假设你有一个包含三张表 ABC 的数据库模式。表 A 包含一个外键 B_fk,对应表 B 的主键。类似地,表 B 包含一个外键 C_fk,对应表 C 的主键。在这种情况下,遵循正确的插入顺序以维持由外键强制的参照完整性是非常关键的。

为此,我们应该首先模拟表 C,因为它没有任何外键,意味着没有依赖它的参照完整性约束。然后,我们可以使用这些数据来模拟表 B,现在这是可能的,因为我们已经确保了通过 C_fk 引用的表 C 中的记录存在。最后,我们可以模拟表 A

正如上例所示,为每个表生成 INSERT 语句必须尊重它们的关系,顺序是很重要的。正如同法语谚语所说,“Il ne faut pas mettre la charrue avant les bœufs”(意译为“不要把马车放在马前面”)。

那么,让我们暂时撇开 LLMs 不谈,问一下自己:给出一个 DB 模式,我们如何确保插入顺序总是正确的?或者换句话说,我们如何从 DB 模式中推导出准确的插入顺序?

答案是 拓扑排序

第一步是从我们数据库模式中的关系创建一个有向无环图(简称 DAG)。DAG 是一种图结构,其边有方向并且没有环路。这意味着你不能从一个节点出发经过一条路径最后又回到同一个节点。

请注意“无环”的含义。只有在图没有有向环的情况下才能进行拓扑排序!

最简单的拓扑排序方法是使用Kahn 算法。其思路如下:

  1. 首先计算图中每个节点的入度(即进入该节点的边数)。
  2. 接下来,队列中所有入度为 0 的节点,即没有任何进入边的节点。
  3. 当队列非空时:
    1. 将节点 N 出队,并将其加入拓扑排序的结果中。
    2. 对于 N 的每个邻接节点,将其入度减一。
    3. 如果某个邻接节点的入度变为 0,则将其加入队列。
    4. 继续直到队列为空。

添加到拓扑排序结果中的节点顺序代表了有向无环图(DAG)的拓扑排序。

以下是一个示例来说明这一点:

“但如果我的模式中包含递归怎么办?”乔治好奇地问。正如你所看到的,环路会使得卡恩算法变复杂。想象一下,如果在涉及作者的场景中存在一个循环,在这种情况下,没有入度为零的节点。此外,如果循环是在书籍上,则在将邻近节点的入度减一时可能会产生问题,导致无法遍历所有节点。

如果你仔细思考一下,忽略循环关系实际上可能是一个隐性的福音。在处理完所有入度为 0 的节点之后,如果图中仍然存在节点(即队列不为空),我们可以确定图中存在循环。

如果循环关系是必需的,一种处理方法是使用 NULL 值来打断循环。第一步是识别循环发生的位置,这可以通过Tarjan的强连通分量算法来完成。当插入模拟数据时,可以在外键字段中暂时插入 NULL 值。在每个强连通分量的所有模拟数据插入完毕后,可以更新这些 NULL 外键为实际的模拟数据值。请注意,这种方法假定外键字段可以为空。还有其他的问题,例如表如果有依赖于外键关系的数据库触发器(如 AFTER INSERT)会产生什么问题。在这种情况下,插入 NULL 值不应触发错误的行为。组合键会使问题更加复杂。这些都是我们所需处理的事情。

踏入生成模拟数据的地雷区

那么,我们应该如何创建我们的“模拟数据生成”技术呢?正如我们在实验中所见,使用LLM自动生成代码以动态生成原始的 INSERT 查询似乎不是解决方法——无论是什么语言!但是从这次实验中,我们也获得了一些积极的结果:

  1. 利用类似 "faker" 的模块可以帮助我们生成更高质量的模拟数据,从“Name1”提升至“SpongeBob SquarePants”。
  2. 我们现在有了从模式推导出插入顺序的方法。

回到起点,我们想出了一个有趣的点子:与其让LLM生成数据,不如让它们从类似 "faker" 模块暴露的一系列方法中选择需要使用的方法。例如,(Column, ColumnType)(hashval, string) 可以映射为“md5()”,而 (language, string) 可以映射为“language_name()”。这种方法有其缺点,但却是很好的起点。这是一个快速且低成本的解决方案。

要实现这一点,我们利用了Neurelo的JSON模式规范,称之为Neurelo模式语言 (NSL)。用户通过Neurelo内省他们的数据源后,整个过程均使用NSL规范表示其模式。

该模式现在成为了我们构建模拟数据生成逻辑的基础。以下是我们的1.0版“模拟数据生成”逻辑:

LLM提示本身会对形式为 (column name, column type) 的元组数组进行分类,并将这些分类结果放入一个JSON字典:

{
    table_name_1: {
        column_name_1: mapped_method_1,
        column_name_2: mapped_method_2,
    },
    table_name_2: {
        column_name_1: mapped_method_1
    },
    .....
}

由于没有找到适合替代 Python's faker module 的Rust库,我们自己写了一个Rust版本。

在实现这个基于Rust的faker模块时,我们遇到了一个有趣的问题。出现了一个突然panic的情况,在不确定原因的情况下,我们开始缩小受影响的数据源及其版本,发现只有运行MySQL版本低于5.6.4的系统受到影响。这引导我们找到了与32位系统相关的千年虫问题。通过将模拟时间戳限制在 1970-01-012038-01-19 之间解决了这个问题。正如同事乔治所说,处理任何与时间戳相关的工作都几乎等同于精神失常。

参照关系怎么处理?

这是另一个工程问题:如何确保外键和主键正确映射?此外,如何使这些工作适用于像MongoDB(带有 ObjectId 引用)这样的NoSQL数据库?

一种方法是维护一个全局索引计数器,其值对应正在模拟的行号。当启用主键自动增长时,这将非常有用。

对于大多数情况,为了确保在外键和主键匹配的情况下生成模拟数据,可以遵循以下步骤:

  1. 跟踪模拟数据:使用一个字典来跟踪当前正在处理的行所关联所有表的模拟数据。
  2. 模拟每一张表:
    1. 对于每张表,根据其列和类型生成模拟数据。
    2. 确定并在字典中设置外键和主键关系以保持一致。
  3. 存储模拟数据:保存每张表生成的模拟数据到字典中。

然而,这种方法无法直接应用于MongoDB,因为它支持隐式引用。为此,我们采用了另一层LLM!对于每个与数据库对应的MongoDB集合,我们识别包含 ObjectId 的属性,默认将其视为“关系”的一部分。然后LLM提示将这些:{collection_name1: [relation1, relation2, ….., relationN], …..} 转化成一个 {relationX: referencing_collection_nameY, relationA: referencing_collection_nameB, …..} 形式的JSON字典。如果某条关系无法准确映射到一个集合名称,则简单地将其赋值为JSON中的 null。然后使用这些输出引用以确保 ObjectId 值相互匹配。

如此一来!引用就有了Ray-Bans墨镜!;)

唔哼……唯一约束?

我们采用了一种类似的方式来确保唯一约束的实现。但是在针对具有唯一约束的具体外键生成大量数据时,遇到了一个问题。因为LLM会使用某个方法来填充外键,这可能导致这些方法有时会创建重复项,从而违反唯一约束。例如,如果“邮编”作为外键,并且通过伪随机方法生成邮编,则即使在1,000行也会发生重复(因为邮编不长),导致约束失败。

这一问题引发连锁反应。由于当当前表没有创建成功,依赖它的后续表也将失败。根本原因是并非所有列都适合填充特定数量的行。此问题同样延伸到表层面。一种解决方法是从未生生成的独特数据池中选择唯一数据而不是实时生成。然而,这种方法可能会由于内存消耗而不理想。

我们已经很大程度上解决了这些问题,但仍有很多工作要做,这是我们正在进行的。

再好的裁缝也会出错

所有的这些工作都是一个良好的开端,并且我们确实将一个非常不错的模拟数据生成器推向了生产环境,并用早期用户进行了测试。然而不久之后,我们开始遇到曾经多次毒害AI模型的一个熟悉问题——“过拟合”!

你看到,这个实现的一个缺点是模拟数据的质量与我们的LLM模型的分类管道直接相关。很多时候我们会遇到 (Column, ColumnType)(name, string) 的情况,这被分类为“名字”。然而,这可能会误导——这一列可能属于“电影”表、“媒体类型”表,甚至是“品牌”表。所以我们学到的下一个教训是将表名集成到管道中是绝对必要的。

然而,即使集成了表名,仍然存在无法将这些表映射到我们某个伪造模块的情况。虽然我们之前在Rust中实现了大约217个伪造模块,但这证明只是杯水车薪。客户可能会提出像 (film, description, string)(posts, title, string) 这样的情景,并期望我们能够超越传统的“Lorem Ipsum”。为了应对这个问题,我们开发了一种称为“创世点策略”的新方法。

创世点策略的基本动机在于,尽管我们希望能利用LLM生成智能的假数据,但这样做可能会既耗费时间又耗费成本。那么,我们如何以便宜、快速且智能的方式实现呢?

这时“笛卡尔积”的力量就发挥作用了!这个想法是,如果我们想模拟1000行数据,约束条件是所有行都必须唯一,我们不需要从GPT生成1000个唯一值。相反,我们需要的只是 ceil(sqrt(1000)) = 32 个伪造数据元素。然后我们可以将这些元素复制为两组,AB,并使用交叉积 A×B 来生成最终的模拟数据元素。尽管这可能不完全现实,但对于大多数实际用途来说应该是足够的。

需要注意的是,我们并不会丢弃旧的管道。相反,我们使用零样本学习来训练我们的LLM将“容易过拟合”的列分类为“NoneOfTheAbove”类别。随后我们只需要解析和生成对应于这个“NoneOfTheAbove”分类的列的模拟数据。

以下是2.0版“模拟数据生成”逻辑的修订迭代:

这就完成了!这是我们模拟数据生成流水线的总结。现在用户可以享受一键高保真的模拟数据生成功能,基于其开发和测试需求的模式。

未来充满着令人兴奋的可能性。通过这一过程中的经验教训,我们期待解决更多复杂的挑战,无论是进一步优化独特的约束条件,支持复合类型和多模式,还是集成更多成本驱动的LLM策略。有一点是很明确的:模拟数据生成的革命正在进行,我们很兴奋能够引领这一潮流。

我们鼓励你访问 dashboard.neurelo.com 探索这一特性及其他功能。有关如何使用我们的平台的信息,请务必查看 我们的网站入门教程