"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 的“模拟数据生成”技术——其内部机制、核心部件以及整个流程!
当我们开始这个项目时,我们心中有五个要求:
- 它应该是多样的,意味着它可以与我们支持的所有三种数据源一起工作:MongoDB、MySQL 和 Postgres。
- 它应基于模式生成现实的数据,无需任何外部用户输入——这是一个“一键式”解决方案,摩擦最小。
- 成本应该尽可能低,在可扩展性与准确性之间取得平衡。
- 响应时间应该非常快。
- 我们应该完全使用 Rust 来完成这项工作。
要了解为什么在这个项目中使用 Rust 对我们来说至关重要,让我们看看 Neurelo 底层架构的简化视图:
Neurelo 的管理层托管我们整个前端,并主要负责将各种由用户指定的操作传达给我们的运营平面。而运营平面,则是实际执行作业的地方。在许多情况下,它将这些作业委托给查询处理器执行。
查询处理器是 Neurelo 的心脏。其主要职责包括:
- 翻译传入的数据 API,并向数据库生成查询。
- 内省现有数据库模式。
- 使用我们的迁移工具管理随时间变化的模式等。
“模拟数据生成”作为运营平面上的另一个“作业”,最终被委托给查询处理器。这一点非常重要,因为这使得我们可以利用现有的写入 API 路径对数据库执行查询,而无需构建新的路径。这种方法不仅提高了性能,还有助于维护。由于我们整个查询处理器都是用 Rust 编写的,因此我们需要一个基于 Rust 的模拟数据生成器。
语言模型能解救我们吗?
从一开始,我们就认为大型语言模型(LLMs)非常适合这项任务,并询问自己如何能利用这些模型来实现这一目标。
我们的初步方法涉及使用 LLMs 生成 Rust 代码以动态创建原始 INSERT
查询。然而,尽管多次尝试调优提示,我们仍未能在多个迭代中达到可再现的准确性。该方法面临两个主要问题:首先,生成的 Rust 代码并不总是可编译的;其次,即使最终编译成功,合成数据的质量也不佳——它们倾向于退化成像“Movie1”,“Movie2”这样的通用格式。
我们可以尝试超越零样本学习的方法进一步优化提示,但我们很快意识到这种方法只是在原地踏步,并且需要一个更确定性的基础。
但好奇心让我们想知道,我们是否可以用 Python 来实现同样的目标?根据我们的理解,LLMs 特别擅长编写可执行的 Python 代码。此外,通过提示它们利用 Python 的“faker”第三方模块还可以帮助我们解决第二个问题。
这时,我们遇到了一个大问题!当你模拟数据库模式中的多张表时,插入顺序是很重要的!
疯狂的帽子匠提出“插入顺序”
假设你有一个包含三张表 A
、B
和 C
的数据库模式。表 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 算法。其思路如下:
- 首先计算图中每个节点的入度(即进入该节点的边数)。
- 接下来,队列中所有入度为
0
的节点,即没有任何进入边的节点。 - 当队列非空时:
- 将节点
N
出队,并将其加入拓扑排序的结果中。 - 对于
N
的每个邻接节点,将其入度减一。 - 如果某个邻接节点的入度变为
0
,则将其加入队列。 - 继续直到队列为空。
- 将节点
添加到拓扑排序结果中的节点顺序代表了有向无环图(DAG)的拓扑排序。
以下是一个示例来说明这一点:
“但如果我的模式中包含递归怎么办?”乔治好奇地问。正如你所看到的,环路会使得卡恩算法变复杂。想象一下,如果在涉及作者的场景中存在一个循环,在这种情况下,没有入度为零的节点。此外,如果循环是在书籍上,则在将邻近节点的入度减一时可能会产生问题,导致无法遍历所有节点。
如果你仔细思考一下,忽略循环关系实际上可能是一个隐性的福音。在处理完所有入度为 0
的节点之后,如果图中仍然存在节点(即队列不为空),我们可以确定图中存在循环。
如果循环关系是必需的,一种处理方法是使用 NULL
值来打断循环。第一步是识别循环发生的位置,这可以通过Tarjan的强连通分量算法来完成。当插入模拟数据时,可以在外键字段中暂时插入 NULL
值。在每个强连通分量的所有模拟数据插入完毕后,可以更新这些 NULL
外键为实际的模拟数据值。请注意,这种方法假定外键字段可以为空。还有其他的问题,例如表如果有依赖于外键关系的数据库触发器(如 AFTER INSERT
)会产生什么问题。在这种情况下,插入 NULL
值不应触发错误的行为。组合键会使问题更加复杂。这些都是我们所需处理的事情。
踏入生成模拟数据的地雷区
那么,我们应该如何创建我们的“模拟数据生成”技术呢?正如我们在实验中所见,使用LLM自动生成代码以动态生成原始的 INSERT
查询似乎不是解决方法——无论是什么语言!但是从这次实验中,我们也获得了一些积极的结果:
- 利用类似 "faker" 的模块可以帮助我们生成更高质量的模拟数据,从“Name1”提升至“SpongeBob SquarePants”。
- 我们现在有了从模式推导出插入顺序的方法。
回到起点,我们想出了一个有趣的点子:与其让LLM生成数据,不如让它们从类似 "faker" 模块暴露的一系列方法中选择需要使用的方法。例如,(Column, ColumnType)
为 (hashval, string)
可以映射为“md5()
”,而 (language, string)
可以映射为“language_name()
”。这种方法有其缺点,但却是很好的起点。这是一个快速且低成本的解决方案。
要实现这一点,我们利用了Neurelo的JSON模式规范,称之为Neurelo模式语言 (NSL)。用户通过Neurelo内省他们的数据源后,整个过程均使用NSL规范表示其模式。