02 BIRD 数据集全量解析与导入代码全拆解
在上一篇文章中,我们设计了 BIRD-SQL 数据集对应的 Jimmer 领域模型。今天,我们将实打实地编写代码,把 dev.json(问答知识)和dev_tables.json(表结构元数据)读取出来,并持久化到我们的关系型数据库中。
BIRD 数据集的 JSON 结构为了极致压缩体积,大量使用了“索引指针”和“异构数组”。这给我们的解析工作带来了不小的麻烦。本文将带大家逐段拆解导入代码,看看我们是如何优雅化解这些难题的。
1. DTO 设计:应对“异构数组”
在正式解析前,我们需要定义与 JSON 结构对应的 DTO (Data Transfer Object)。
BIRD 数据集的“奇葩”设计
如果你观察 dev_tables.json,会发现它的 column_names_original 和 primary_keys 字段包含了不同类型的数据。例如,列信息是[0, "CDSCode"](包含 Int 和 String),而主键可能是单纯的 [1, 3] 也可能是嵌套的 [[1, 2], 3]。
为了应对这种异构数据,在 Kotlin 中我们可以果断使用 List<Any> 来接收,后续再在代码里做类型判断:
data class BirdTable(
@get:JsonProperty("db_id") val dbId: String,
@get:JsonProperty("table_names_original") val tableNamesOriginal: List<String>,
@get:JsonProperty("table_names") val tableNames: List<String>,
// 异构数组:[表索引(Int), 列名(String)]
@get:JsonProperty("column_names_original") val columnNamesOriginal: List<List<Any>>,
@get:JsonProperty("column_names") val columnNames: List<List<Any>>,
@get:JsonProperty("column_types") val columnTypes: List<String>,
// 异构主键:可能是 Int,也可能是 List<Int>
@get:JsonProperty("primary_keys") val primaryKeys: List<Any>,
// 外键:[源列索引(Int), 目标列索引(Int)]
@get:JsonProperty("foreign_keys") val foreignKeys: List<List<Int>>
)
data class BirdQuestion(
@get:JsonProperty("question_id") val questionId: Int,
@get:JsonProperty("db_id") val dbId: String,
@get:JsonProperty("question") val question: String,
@get:JsonProperty("evidence") val evidence: String,
@get:JsonProperty("SQL") val sql: String,
@get:JsonProperty("difficulty") val difficulty: String
)2. 知识库导入:去重与批量插入
我们先看相对简单的 dev.json 解析(即 createKnowledgeTest 方法)。这个文件包含了自然语言问题(Question)、SQL
答案以及业务上下文(Evidence)。
@Test
fun createKnowledgeTest() {
val objectMapper = jacksonObjectMapper()
val birdQuestionDtos: List<BirdQuestion> = objectMapper.readValue(
ClassPathResource("dev_20240627/dev.json").inputStream,
object : TypeReference<List<BirdQuestion>>() {}
)
// 1. 清理历史数据(为了支持重复测试)
glossaryKnowledgeRepository.deleteAll()
questionKnowledgeRepository.deleteAll()
// 2. 抽取并去重业务知识 (Evidence)
birdQuestionDtos.filter { it.evidence.isNotEmpty() }
.distinctBy { it.evidence } // 核心:使用 distinctBy 按照业务内容去重
.forEach { question ->
glossaryKnowledgeRepository.save(GlossaryKnowledge {
databaseId = question.dbId
term = "" // 预留字段,后续可通过大模型提取名词
description = question.evidence
}, mode = SaveMode.INSERT_ONLY) // Jimmer 优化:忽略 SELECT 检查,直接强制 INSERT
}
// 3. 存储问答数据 (Few-Shot 语料)
birdQuestionDtos.forEach { question ->
questionKnowledgeRepository.save(QuestionKnowledge {
databaseId = question.dbId
this.question = question.question
this.answer = question.sql
}, mode = SaveMode.INSERT_ONLY)
}
}性能优化细节
注意代码中使用的 mode = SaveMode.INSERT_ONLY。一般 ORM(包括 Jimmer 和 JPA)在执行 Save 时会先去数据库里 SELECT
确认数据是否存在,以决定是 INSERT 还是 UPDATE。由于我们是全量导入新数据,显式指定 INSERT_ONLY 可以省去大量无谓的查询,显著提升性能。
3. Schema 导入:拆解外键与依赖
这部分是重头戏(createSchemeTest 方法)。表(Table)、列(Column)和外键(ForeignKey)之间存在严格的依赖。如果用框架自带的级联保存,处理外键会极其痛苦。
我们采用**“分离解析,分步保存”**的策略:
3.1 解析表并“挂起”列的构建逻辑
通过 Kotlin 的 Lambda 闭包,我们把列(Column)的构建过程存入一个 List<(DbTable) -> List<DbColumn>> 集合中。这意味着:“等
Table 保存完、有了数据库分配的 ID 之后,再把这个 Table 传给我来构建 Column。”
val tablesToSave = mutableListOf<DbTable>()
val columnBuilders = mutableListOf<(DbTable) -> List<DbColumn>>()
birdTableDtos.forEach { databaseTable ->
databaseTable.tableNamesOriginal.forEachIndexed { index, tableName ->
// 1. 仅构建表对象
tablesToSave.add(
DbTable {
name = tableName
description = databaseTable.tableNames[index]
databaseId = databaseTable.dbId
}
)
// 2. 将列的构建逻辑作为闭包保存
columnBuilders.add { savedTable ->
databaseTable.columnNamesOriginal
.mapIndexedNotNull { columnIndex, list ->
// list[0] 是表索引,用来判断这个列属不属于当前的表
if (list[0] == index && (list[1] as String).isNotEmpty()) {
DbColumn {
name = list[1] as String
description = databaseTable.columnNames[columnIndex][1] as String
type = databaseTable.columnTypes[columnIndex]
isPrimaryKey = isPrimaryKey(databaseTable, columnIndex)
// 关键:主动将生成的 Table 实例赋给多对一关联
dbTable = savedTable
}
} else null
}
}
}其中,isPrimaryKey 函数巧妙地解决了上面提到的主键“异构数组”问题:
fun isPrimaryKey(birdTableDto: BirdTable, columnIndex: Int): Boolean {
// 将单一 Int 或 List<Int> 统统展平(flatMap)成一个普通的 List<Int>
return birdTableDto.primaryKeys.flatMap {
if (it is Int) listOf(it)
else if (it is List<*>) it
else listOf()
}.map { it as Int }.contains(columnIndex)
}3.2 翻译“外键索引指针”
在 JSON 中,外键被记录为 [3, 1],意思是全局第 3 列指向全局第 1 列。我们需要将其翻译为真实的“数据库ID + 表名 + 列名”:
val dbForeignKeys = databaseTable.foreignKeys.mapNotNull { pair ->
val sourceColumnInfo = databaseTable.columnNamesOriginal.getOrNull(pair[0])
val targetColumnInfo = databaseTable.columnNamesOriginal.getOrNull(pair[1])
// ... (略去非空判断)
val sourceTableIndex = sourceColumnInfo[0] as Int
val targetTableIndex = targetColumnInfo[0] as Int
val sourceTableName = databaseTable.tableNamesOriginal.getOrNull(sourceTableIndex)
val targetTableName = databaseTable.tableNamesOriginal.getOrNull(targetTableIndex)
// 封装为一个中间对象,等待列全部保存完毕后再进行匹配
PendingForeignKey(
databaseId = databaseTable.dbId,
sourceTableName = sourceTableName!!,
sourceColumnName = sourceColumnInfo[1] as String,
targetTableName = targetTableName!!,
targetColumnName = targetColumnInfo[1] as String
)
}
pendingForeignKeys.addAll(dbForeignKeys)
}3.3 分步入库与内存映射 (Memory Map)
解析完毕后,我们严格按照依赖顺序进行持久化操作。最精彩的地方在于使用 associateBy
构建的内存映射表,它把数据库的外键匹配变成了极速的 $O(1)$ 查找。
// 步骤 A: 独立保存所有的表
val savedTables = dbTableRepository.saveAll(tablesToSave)
// 步骤 B: 消费之前挂起的闭包,传入 savedTable 生成包含真实表引用的列集合
val columnsToSave = savedTables.flatMapIndexed { index, savedTable ->
columnBuilders[index](savedTable)
}
// 步骤 C: 独立保存所有的列 (此时列对象获取了 UUID)
val savedColumns = dbColumnRepository.saveAll(columnsToSave)
// 步骤 D: 构建三元组映射 Map
// 结构为: (dbId, tableName, columnName) -> 实体 DbColumn
val columnMap = savedColumns.associateBy { column ->
Triple(column.dbTable.databaseId, column.dbTable.name, column.name)
}
// 步骤 E: 在内存中极速匹配外键两端的列实体,并保存
val foreignKeys = pendingForeignKeys.mapNotNull { pending ->
val source = columnMap[Triple(pending.databaseId, pending.sourceTableName, pending.sourceColumnName)]
val target = columnMap[Triple(pending.databaseId, pending.targetTableName, pending.targetColumnName)]
if (source != null && target != null) {
DbForeignKey {
sourceColumn = source
targetColumn = target
}
} else null
}
dbForeignKeyRepository.saveAll(foreignKeys)4. 总结
看完这段代码拆解,你会发现把“脏数据”转换为“结构化数据”并非易事。我们通过:
List<Any>兼容异构结构- Lambda 闭包延迟构建依赖关系
SaveMode.INSERT_ONLY加速入库- 内存 Triple 映射解决 $O(1)$ 外键匹配
完美地将 BIRD 数据集转换为了系统中的领域实体。
在我们的后台数据库中,这些冰冷的 JSON 数据终于变成了一条条鲜活的关系型记录。在下一篇文章中,我们将踏入 Agent 开发的核心环节——
结合 Spring AI,将这些结构化数据进行向量化转换(Embedding),并写入向量数据库!
