分类 默认分类 下的文章

在ACGNGAME V1.0正式版本开发时,为了实现推荐游戏的在线召回,我们将数据库正式从MySQL迁移至了PostgreSQL。
同时将Tag,Platform,Company等信息抽离为单独实体,方便了快速筛选。但与此同时,Game表中原本的冗余字段被移除,原本的搜索功能变成了只能搜索标题与文章中的文本信息,给使用带来了不便。

所以我们重新设计了搜索功能,实现了更好的全文搜索

解决方案:PostgreSQL 的 trigram 索引

我们采用 PostgreSQL 的 gist_trgm_ops 操作符类构建 GIST 索引,实现基于 trigram(三元组) 的高效相似度搜索。核心思路是将文本拆分为重叠的三字符片段(如 "abc" → ["abc"]),通过比较这些片段的相似度计算匹配度。
数据库设计:关键表与索引

CREATE TABLE game_keywords (
    game_id INTEGER NOT NULL
        CONSTRAINT game_keywords_pk PRIMARY KEY,
    keywords TEXT
);

CREATE INDEX game_keywords_game_id_index
ON game_keywords USING GIST (keywords GIST_TRGM_OPS);

其中此表的更新依赖于 updateGameSearchIndex 函数

fun updateGameSearchIndex(gameId: Int): Boolean {
    val gameData = getGameById(gameId) ?: throw NotFoundException()
    
    // 关键:构建统一关键词(所有字段 + 大写化)
    val gameKeywords = StringBuilder()
        .append(gameData.title)
        .append(gameData.description)
        .append(gameData.tags.map { "${it.chinese}-${it.english}-${it.japanese}" })
        .append(gameData.companies.companyName + gameData.companies.companyDes)
        .toString()
        .uppercase() // 统一转大写,避免大小写干扰

    // 更新或插入索引
    val rows = database.insertOrUpdate(GameKeywordsTable) {
        set(GameKeywordsTable.gameId, gameData.id)
        set(GameKeywordsTable.keywords, gameKeywords)
        onConflict(GameKeywordsTable.gameId) {
            set(GameKeywordsTable.keywords, gameKeywords)
        }
    }
    return rows >= 1
}

在每次GameTable被update或create时创建聚合数据,同时出于保险起见,设计一个定时任务每天空闲时间全量更新索引

@Scheduled(cron = "0 00 6 * * *")
    fun updateAllGamesSearchIndex() {
        val logger = logger()
        executor.execute {
            val gameIds = gameServices.listAllIds()
            gameIds.chunked(BATCH_SIZE).forEach { batchIts ->
                batchIts.forEach {
                    updateGameSearchIndex(it)
                }
            }
        }
    }