Redis场景实战:Redisearch文章相似度检查与抄袭判重

806 阅读11分钟

最近工作上一直在写各种文档,无比枯燥乏味。还好群多能水,大佬们说话又好听我天天潜伏在里面。这个需求也是在群里产生的,大概是一个大佬发了文章被别人抄袭然后其他作者们声泪俱下的控诉。刚好因为写Redis场景实战系列了解了一点Redis OMRedisearch,于是就有了这几篇文章——利用多个redis实例进行大规模搜索和去重

本文代码和测试都在git仓库@TempRedisTools

万字长文:从缓存文章聊聊Redis OM是怎么个事儿 · 上

万字长文:从缓存文章聊聊Redis OM是怎么个事儿 · 下

Redis场景实战:Redisearch文章相似度检查与抄袭判重——本篇

本地部署Redisearch,5万+篇文章搜索和判重要多久——今天写,应该周一能发

1. 引言

1.1 项目背景

在当今信息爆炸的时代,海量的文本数据需要高效的存储和检索方案。传统的关系型数据库在处理大规模文本搜索时往往效率不高,而全文搜索引擎如Elasticsearch虽然功能强大,但其复杂的部署和维护成本较高。Redisearch作为Redis的一个模块,提供了高效的全文搜索功能,并且继承了Redis的高性能和简易部署的优点。

此外,在学术和内容创作领域,抄袭检测是一个重要的需求。通过对文本相似度的计算,可以有效地检测出重复或抄袭的内容,保障原创内容的版权和质量。

1.2 项目目标

本项目旨在利用Redisearch实现一个高效的全文搜索引擎,并结合MinHash算法实现抄袭判重功能。具体目标包括:

  1. 搭建Redisearch全文搜索引擎

    • 介绍Redisearch的基本功能和优势。
    • 使用ioredis和redis-om封装一个基础类,简化与Redisearch的交互。
    • 实现索引的创建、数据添加和搜索功能。
  2. 实现抄袭检测功能

    • 介绍抄袭判重的基本概念和原理。
    • 使用MinHash算法计算文本的相似度。
    • 将MinHash与Redisearch结合,实现高效的抄袭检测。
  3. 封装文章去重操作类

    • 提供一个完整的封装和相关测试保证代码质量

通过本项目,读者将能够掌握Redisearch的使用方法,了解如何实现高效的全文搜索和抄袭检测,并学会如何利用Redis的高级功能构建一个功能强大的搜索引擎。

2. Redisearch介绍

2.1 Redisearch概述

Redisearch是Redis Labs开发的一个Redis模块,旨在提供高效的全文搜索和二次索引功能。与传统的Redis不同,Redisearch允许在Redis数据结构上创建复杂的搜索索引,从而实现灵活的查询和高效的文本搜索。Redisearch不仅支持简单的键值对查询,还支持全文搜索、模糊搜索、范围查询、排序和聚合等高级查询功能。

2.2 Redisearch的特点和优势

  1. 高性能

    • Redisearch继承了Redis的高性能特点,能够在极短的时间内完成大量数据的索引和查询操作。
    • 支持实时索引和查询,适用于高并发的应用场景。
  2. 灵活的查询能力

    • 支持全文搜索、模糊搜索、布尔查询、范围查询等多种查询方式。
    • 支持多字段查询和排序,可以根据需要灵活组合查询条件。
  3. 丰富的数据类型支持

    • 支持对字符串、数字、地理位置等多种类型的数据进行索引和查询。
    • 支持对JSON文档的索引和查询,方便处理复杂的数据结构。
  4. 易于集成和使用

    • 与Redis无缝集成,易于部署和维护。
    • 提供多种编程语言的客户端库,开发者可以方便地在各种应用中使用Redisearch。
  5. 可扩展性

    • 支持分布式架构,可以通过水平扩展来处理大规模数据和高并发请求。
    • 提供分片和复制功能,确保数据的高可用性和一致性。

2.3 Redisearch的适用场景

  1. 全文搜索

    • 适用于需要对大量文本数据进行全文搜索的应用场景,如博客、新闻网站、电子商务平台等。
    • 支持复杂的查询条件和排序功能,可以满足各种搜索需求。
  2. 实时数据分析

    • 适用于需要对实时数据进行索引和查询的场景,如日志分析、监控系统等。
    • 支持实时索引和查询,能够快速响应用户的查询请求。
  3. 推荐系统

    • 适用于需要根据用户行为和偏好进行推荐的场景,如电商推荐、内容推荐等。
    • 可以结合全文搜索和排序功能,为用户提供个性化的推荐结果。
  4. 抄袭检测

    • 适用于需要对文本内容进行相似度计算和抄袭检测的场景,如学术论文检测、内容创作平台等。
    • 可以结合MinHash算法和全文搜索功能,实现高效的抄袭检测。
  5. 复杂查询和分析

    • 适用于需要对多字段、多条件进行复杂查询和分析的场景,如数据分析平台、BI工具等。
    • 支持多字段查询、聚合和排序,可以满足各种复杂的查询需求。

通过Redisearch,开发者可以在Redis的基础上实现高效的全文搜索和复杂查询,满足各种应用场景的需求。

3. 环境准备

在开始实现Redisearch全文搜索和抄袭判重功能之前,我们需要准备好开发环境。以下是详细的步骤,包括如何安装Redis和Redisearch模块,以及如何安装Node.js及相关依赖包(ioredis,redis-om)。

3.1 安装Redis和Redisearch模块

我们将使用Docker来安装和运行Redis和Redisearch模块。以下是一个Docker Compose文件示例,可以帮助我们快速搭建Redis和Redisearch的环境。

Docker Compose 文件

创建一个名为 docker-compose.yml 的文件,并将以下内容粘贴进去:

version: "3.8"

services:
  redis:
    image: redis:latest
    container_name: redis
    restart: always
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

  redisearch:
    image: redislabs/redisearch:latest
    container_name: redisearch
    restart: always
    ports:
      - "6380:6379"
    volumes:
      - redisearch-data:/data

volumes:
  redis-data:
    driver: local
  redisearch-data:
    driver: local

启动服务

在终端中导航到 docker-compose.yml 文件所在的目录,然后运行以下命令来启动Redis和Redisearch服务:

docker-compose up -d

这将启动两个容器:一个运行Redis,另一个运行Redisearch。

3.2 安装Node.js及相关依赖包

接下来,我们需要安装Node.js和相关的依赖包(ioredis,redis-om)。以下是安装步骤:

安装Node.js

如果你还没有安装Node.js,可以从Node.js官网下载并安装最新版本。安装完成后,可以在终端中运行以下命令来验证安装:

node -v
npm -v

初始化Node.js项目

在你的项目目录中,运行以下命令来初始化一个新的Node.js项目:

npm init -y

安装TypeScript及相关依赖包

运行以下命令来安装 TypeScript 和一些常用的依赖包:

npm install typescript ts-node @types/node --save-dev

创建 TypeScript 配置文件

在项目根目录下创建一个 tsconfig.json 文件,并添加以下内容:

{
  "compilerOptions": {
    "target": "ES2020", // 指定 ECMAScript 目标版本为 ES2020,以支持现代 JavaScript 特性
    "module": "commonjs", // 将模块解析设置为 CommonJS,以便与 Node.js 兼容
    "strict": true, // 启用所有严格类型检查选项,以增强代码的类型安全性
    "esModuleInterop": true, // 启用对 ES 模块的互操作性,以便使用 `import` 语句导入 CommonJS 模块
    "skipLibCheck": true, // 跳过对声明文件(.d.ts 文件)的类型检查,以加快编译速度
    "forceConsistentCasingInFileNames": true, // 强制文件名一致大小写,以避免在不同操作系统上的文件名大小写问题
    "outDir": "./dist", // 指定编译输出目录为 `./dist`
    "rootDir": "./src" // 指定输入文件的根目录为 `./src`
  },
  "include": ["src"] // 指定要包含在编译中的文件或目录
}

安装依赖包

运行以下命令来安装 ioredisredis-om 依赖包:

npm install ioredis redis-om

所有文章内涉及的封装 npm i temp-redis-tools中都有

创建项目目录结构

创建一个 src 目录,并在其中创建一个 index.ts 文件。你的项目目录结构可能如下所示:

my-project/
├── node_modules/
├── src/
│   └── index.ts
├── package.json
├── package-lock.json
└── tsconfig.json

src/index.ts 中添加代码

import { createClient } from "redis";
import IoRedisClient from "ioredis";
import { Entity, Schema, Repository } from "redis-om";

// 创建 Redis 客户端
const redisClient = createClient({ url: 'redis://localhost:6380' }); // 注意需要连接redisearch模块的docker容器
const ioRedisClient = new IoRedisClient(6380);

redisClient.on("error", (err) => console.log("Redis 客户端错误", err));
ioRedisClient.on("error", (err) => console.log("ioredis 客户端错误", err));

// 定义实体类
const albumSchema = new Schema(
  "album",
  {
    artist: { type: "string" },
    title: { type: "string" },
    year: { type: "number" },
  },
  {
    dataStructure: "HASH",
  }
);

// 初始化函数
async function initialize() {
  console.log("正在连接到 Redis...");
  await redisClient.connect();
  console.log("Redis 连接成功");

  // 创建仓库
  console.log("正在创建仓库...");
  const repository = new Repository(albumSchema, redisClient);
  console.log("仓库创建成功");

  // 创建索引
  console.log("正在创建索引...");
  await repository.createIndex();
  console.log("索引创建成功");

  return repository;
}

// 添加新专辑
async function addAlbum(repository: Repository<Record<string, any>>, newAlbum: { artist: any; title: any; year?: number; }) {
  console.log(`正在检查专辑是否存在: ${newAlbum.artist} - ${newAlbum.title}`);
  // 使用 redis-om 提供的搜索功能来检查专辑是否存在
  const existingAlbums = await repository.search()
    .where('artist').eq(newAlbum.artist)
    .and('title').eq(newAlbum.title)
    .return.all();

  if (existingAlbums.length === 0) {
    // 保存实体
    console.log("专辑不存在,正在保存...");
    const savedAlbum = await repository.save(newAlbum);
    console.log("专辑保存成功:", savedAlbum);
  } else {
    console.log("专辑已存在,不再保存。");
  }
}

// 搜索特定艺术家的专辑
async function searchAlbumsByArtist(repository: Repository<Record<string, any>>, artist: string) {
  console.log(`正在搜索艺术家 ${artist} 的专辑...`);
  const albums = await repository.search().where("artist").eq(artist).return.all();
  console.log(`找到艺术家 ${artist} 的专辑:`, albums);
}

// 主函数
async function main() {
  console.log("程序开始...");
  const repository = await initialize();

  // 添加新专辑
  const newAlbum = {
    artist: "老马",
    title: "闪电五连鞭 & 形意太极",
    year: 2014,
  };
  await addAlbum(repository, newAlbum);

  // 搜索特定艺术家的专辑
  await searchAlbumsByArtist(repository, "老马");

  // 断开连接
  console.log("正在断开 Redis 连接...");
  await redisClient.quit();
  await ioRedisClient.quit();
  console.log("程序结束");
}

main().catch((err) => {
  console.error("发生错误:", err);
});

注意!!!

写文章之初是想用ioredis手撸原生查询来着,后来写不动就换回了node-redis

代码的主要逻辑和功能:

  1. 创建 Redis 客户端

    • 使用 redisioredis 库分别创建两个 Redis 客户端,用于连接到本地的 Redis 服务器。
  2. 定义实体类和模式

    • 使用 redis-om 库定义一个专辑的模式(Schema),包括艺术家(artist)、标题(title)和年份(year)三个字段,并指定数据结构为 HASH
  3. 初始化函数

    • 连接到 Redis 服务器。
    • 创建一个 Repository 对象,用于管理专辑实体。
    • 创建索引,以便在 Redis 中高效地搜索专辑。
  4. 添加新专辑

    • 检查给定的专辑是否已经存在(通过艺术家和标题)。
    • 如果不存在,则保存新的专辑到 Redis 数据库中。
  5. 搜索特定艺术家的专辑

    • 根据艺术家的名字搜索并返回所有匹配的专辑。
  6. 主函数

    • 调用初始化函数连接到 Redis 并创建仓库和索引。
    • 添加一个新的专辑示例。
    • 搜索并打印特定艺术家的所有专辑。
    • 最后,关闭 Redis 连接。

测试运行效果

  1. 启动程序
    • 程序开始执行,调用 main 函数。
  2. 初始化
    • 连接到 Redis,创建仓库和索引。
  3. 添加专辑
    • 添加一个新的专辑(如果不存在)。
  4. 搜索专辑
    • 搜索并打印特定艺术家的专辑。
  5. 关闭连接
    • 断开 Redis 连接,程序结束。

通过这些步骤,代码展示了如何使用 redis-om 库来管理 Redis 数据库中的对象,并执行基本的增删查操作。

image.png

4. 实现抄袭检测功能

在大规模数据处理中,抄袭检测是一个常见且重要的任务。为了高效地进行抄袭检测,我们可以使用MinHash算法和Locality-Sensitive Hashing (LSH)。以下是详细的实现步骤和代码示例。

image.png

4.1 抄袭判重的概念和原理

抄袭判重的定义

抄袭判重是通过计算文本之间的相似度来检测重复或抄袭内容的过程。其目的是保障原创内容的版权和质量。

传统方法的局限性

传统的抄袭检测方法通常依赖于逐字对比或简单的字符串匹配,这些方法在面对大量数据时效率较低,且容易受到文本格式和小幅修改的影响。

MinHash算法的优势

MinHash算法通过哈希函数将文档映射为固定长度的签名,并使用这些签名计算Jaccard相似度,从而高效地进行相似度检测。其优势包括:

  • 高效处理大规模数据
  • 对文本的小幅修改具有鲁棒性
  • 计算复杂度低

4.2 使用MinHash算法实现抄袭判重

MinHash算法介绍

MinHash的定义和用途

MinHash是一种基于哈希函数的技术,用于近似计算集合之间的Jaccard相似度。它通过将集合映射为固定长度的签名来实现高效的相似度计算。

Jaccard相似度的定义

Jaccard相似度用于衡量两个集合的相似度,其定义为两个集合的交集大小除以并集大小。

Jaccard 相似度=ABAB\text{Jaccard 相似度} = \frac{|A \cap B|}{|A \cup B|}
举个例子

假设你有两个集合 𝐴A 和 𝐵B

  • 集合 𝐴A:{apple, banana, cherry}
  • 集合 𝐵B:{banana, cherry, date}

我们选择两个简单的哈希函数 ℎ1h1 和 ℎ2h2:

  • ℎ1h1:计算字符串的长度
  • ℎ2h2:计算字符串的第一个字符的ASCII值

计算每个集合的最小哈希值:

  • 对于集合 𝐴A
    • ℎ1h1:{5 (apple), 6 (banana), 6 (cherry)} -> 最小值是 5
    • ℎ2h2:{97 (apple), 98 (banana), 99 (cherry)} -> 最小值是 97
  • 对于集合 𝐵B
    • ℎ1h1:{6 (banana), 6 (cherry), 4 (date)} -> 最小值是 4
    • ℎ2h2:{98 (banana), 99 (cherry), 100 (date)} -> 最小值是 98

签名:

  • 集合 𝐴A 的签名是 {5, 97}
  • 集合 𝐵B 的签名是 {4, 98}

比较签名:

  • 这两个签名没有相同的元素,所以相似度是0。

Locality-Sensitive Hashing (LSH) 的介绍

LSH的定义和用途

Locality-Sensitive Hashing (LSH) 是一种哈希技术,用于高效地查找相似项。LSH通过将相似的输入映射到相同的桶中,从而提高查找相似项的效率。

LSH的工作原理
  1. 哈希函数族:选择一组哈希函数,每个哈希函数将输入映射到一个桶中。
  2. 桶分配:将输入数据通过多个哈希函数映射到多个桶中。
  3. 相似项查找:对于查询项,通过相同的哈希函数映射到桶中,查找相同桶中的项作为候选相似项。
LSH的详细步骤
  1. 选择哈希函数:选择一组哈希函数 ℎ1,ℎ2,…,ℎ𝑘h1,h2,…,h**k,这些哈希函数用于将输入数据映射到桶中。
  2. 构建签名矩阵:使用MinHash算法为每个文档生成签名矩阵。假设我们有 𝑛n 个文档,每个文档的签名长度为 𝑘k,则签名矩阵的大小为 𝑘×𝑛k×n
  3. 分段哈希:将签名矩阵分成 𝑏b 个段,每段包含 𝑟r 个签名。每个段对应一个哈希函数。
  4. 桶分配:对于每个段,使用对应的哈希函数将签名映射到桶中。相同桶中的文档被认为是候选相似项。
  5. 相似项查找:对于查询文档,将其签名通过相同的哈希函数映射到桶中,查找相同桶中的文档作为候选相似项。
举个例子

假设我们有以下签名矩阵,其中每列对应一个文档的签名:

文档签名1签名2签名3签名4签名5
文档A13245
文档B12235
文档C23145

我们选择 𝑏=2b=2 段,每段包含 𝑟=2r=2 个签名。假设我们有两个哈希函数 ℎ1h1 和 ℎ2h2:

  • 段1:签名1和签名2
  • 段2:签名3和签名4

将签名映射到桶中:

  • 段1:
    • ℎ1h1:{(1, 3), (1, 2), (2, 3)} -> 桶1: {文档A, 文档B}, 桶2: {文档C}
  • 段2:
    • ℎ2h2:{(2, 4), (2, 3), (1, 4)} -> 桶1: {文档A, 文档C}, 桶2: {文档B}

相似项查找:

  • 对于查询文档,将其签名通过相同的哈希函数映射到桶中,查找相同桶中的文档作为候选相似项。例如,如果查询文档的签名为 {1, 3, 2, 4},则在段1中映射到桶1,在段2中映射到桶1,因此候选相似项为文档A和文档C。

算法实现步骤

image.png

1. 数据预处理

将文本数据拆分为shingles(k-shingles),即连续的k个字符或单词。

/**
 * 生成文本的k-shingles
 * @param text - 输入的文本
 * @param k - shingle的长度
 * @returns 由k-shingles组成的数组
 */
function shingles(text: string, k: number): string[] {
    // 创建一个Set来存储唯一的shingles
    const shinglesSet = new Set<string>();

    // 遍历文本,生成k-shingles
    for (let i = 0; i <= text.length - k; i++) {
        // 从当前位置i开始,截取长度为k的子字符串
        const shingle = text.slice(i, i + k);
        
        // 将生成的shingle添加到Set中(Set会自动去重)
        shinglesSet.add(shingle);
    }

    // 将Set转换为数组并返回
    return Array.from(shinglesSet);
}

这个函数将输入文本拆分为长度为k的shingles,并返回一个包含所有shingles的数组。

2. 计算哈希值

对每个shingle计算多个哈希值。

import { createHash } from 'crypto';

/**
 * 对shingle进行哈希处理
 * @param shingle - 输入的shingle字符串
 * @param numHashes - 需要生成的哈希数量
 * @returns 由哈希值组成的数组
 */
function hashShingle(shingle: string, numHashes: number): number[] {
    // 创建一个数组来存储生成的哈希值
    const hashes: number[] = [];

    // 遍历,生成numHashes个哈希值
    for (let i = 0; i < numHashes; i++) {
        // 使用SHA-256哈希函数,将shingle和当前索引i进行哈希
        const hash = createHash('sha256').update(shingle + i).digest('hex');
        
        // 将生成的16进制哈希值转换为整数,并添加到hashes数组中
        hashes.push(parseInt(hash, 16));
    }

    // 返回生成的哈希值数组
    return hashes;
}

这个函数对每个shingle计算多个哈希值,并返回一个包含所有哈希值的数组。

3. 选择最小值

从每组哈希值中选择最小值,形成MinHash签名。

/**
 * 生成MinHash签名
 * @param shingles - 输入的shingles数组
 * @param numHashes - 需要生成的哈希数量
 * @returns 由MinHash签名值组成的数组
 */
function minHashSignature(shingles: string[], numHashes: number): number[] {
    // 创建一个数组来存储MinHash签名,初始值为Infinity
    const signature = Array(numHashes).fill(Infinity);

    // 遍历每个shingle
    for (const shingle of shingles) {
        // 对当前shingle进行哈希处理,生成numHashes个哈希值
        const hashes = hashShingle(shingle, numHashes);

        // 遍历每个哈希值
        for (let i = 0; i < numHashes; i++) {
            // 如果当前哈希值小于签名中的对应值,则更新签名值
            if (hashes[i] < signature[i]) {
                signature[i] = hashes[i];
            }
        }
    }

    // 返回生成的MinHash签名
    return signature;
}
4. 使用LSH进行签名存储和检索

LSH通过将相似的签名映射到相同的桶中,从而高效地进行相似度检索。

import { createClient } from 'redis';

// 创建Redis客户端
const redisClient = createClient({ url: 'redis://localhost:6380' });

/**
 * 将MinHash签名存储到LSH桶中
 * @param bucketPrefix - LSH桶的前缀
 * @param signature - MinHash签名
 * @param numBands - 分段数量
 * @param rowsPerBand - 每段的行数
 */
async function storeSignatureLSH(bucketPrefix: string, signature: number[], numBands: number, rowsPerBand: number) {
    for (let i = 0; i < numBands; i++) {
        // 获取当前段的哈希值
        const band = signature.slice(i * rowsPerBand, (i + 1) * rowsPerBand);
        // 生成Redis键名
        const bandKey = `${bucketPrefix}:${i}:${band.join(',')}`;
        // 将签名存储到Redis集合中
        await redisClient.sAdd(bandKey, JSON.stringify(signature));
    }
}

/**
 * 从LSH桶中检索候选签名
 * @param bucketPrefix - LSH桶的前缀
 * @param signature - MinHash签名
 * @param numBands - 分段数量
 * @param rowsPerBand - 每段的行数
 * @returns 候选签名数组
 */
async function retrieveCandidatesLSH(bucketPrefix: string, signature: number[], numBands: number, rowsPerBand: number): Promise<number[][]> {
    // 使用Set来存储候选签名,避免重复
    const candidates = new Set<string>();

    for (let i = 0; i < numBands; i++) {
        // 获取当前段的哈希值
        const band = signature.slice(i * rowsPerBand, (i + 1) * rowsPerBand);
        // 生成Redis键名
        const bandKey = `${bucketPrefix}:${i}:${band.join(',')}`;
        // 从Redis集合中获取候选签名
        const bandCandidates = await redisClient.sMembers(bandKey);
        bandCandidates.forEach(candidate => candidates.add(candidate));
    }

    // 将候选签名转换为数组并解析为数字数组
    return Array.from(candidates).map(candidate => JSON.parse(candidate));
}
5. 计算签名相似度

计算两个签名的Jaccard相似度。

/**
 * 计算两个MinHash签名的Jaccard相似性
 * @param signature1 - 第一个MinHash签名
 * @param signature2 - 第二个MinHash签名
 * @returns 两个签名之间的Jaccard相似性
 */
function jaccardSimilarity(signature1: number[], signature2: number[]): number {
    // 初始化交集计数器
    let intersection = 0;

    // 遍历两个签名的每一个元素
    for (let i = 0; i < signature1.length; i++) {
        // 如果两个签名在相同位置的元素相等,则计数器加1
        if (signature1[i] === signature2[i]) {
            intersection++;
        }
    }

    // 计算并返回Jaccard相似性
    return intersection / signature1.length;
}

4.3 实现抄袭检测的完整代码

以下是完整的代码示例,包括数据预处理、计算MinHash签名、使用LSH进行签名存储和检索,以及计算签名相似度的全部步骤。

import { createClient } from "redis";
import { createHash } from "crypto";

// 创建Redis客户端
const redisClient = createClient({ url: "redis://localhost:6380" });

async function main() {
  await redisClient.connect(); // 连接Redis服务器

  /**
   * 将文本拆分为shingles
   * @param text 输入文本
   * @param k shingle的长度
   * @returns shingle数组
   */
  function shingles(text: string, k: number): string[] {
    const shinglesSet = new Set<string>();
    for (let i = 0; i <= text.length - k; i++) {
      shinglesSet.add(text.slice(i, i + k));
    }
    return Array.from(shinglesSet);
  }

  /**
   * 计算shingle的哈希值
   * @param shingle 输入shingle
   * @param numHashes 哈希函数的数量
   * @returns 哈希值数组
   */
  function hashShingle(shingle: string, numHashes: number): number[] {
    const hashes: number[] = [];
    for (let i = 0; i < numHashes; i++) {
      const hash = createHash("sha256")
        .update(shingle + i)
        .digest("hex");
      hashes.push(parseInt(hash, 16));
    }
    return hashes;
  }

  /**
   * 计算shingle集合的MinHash签名
   * @param shingles shingle数组
   * @param numHashes 哈希函数的数量
   * @returns MinHash签名数组
   */
  function minHashSignature(shingles: string[], numHashes: number): number[] {
    const signature = Array(numHashes).fill(Infinity);
    for (const shingle of shingles) {
      const hashes = hashShingle(shingle, numHashes);
      for (let i = 0; i < numHashes; i++) {
        if (hashes[i] < signature[i]) {
          signature[i] = hashes[i];
        }
      }
    }
    return signature;
  }

  /**
   * 使用LSH存储签名
   * @param bucketPrefix 桶的前缀
   * @param signature MinHash签名
   * @param numBands band的数量
   * @param rowsPerBand 每个band的行数
   */
  async function storeSignatureLSH(
    bucketPrefix: string,
    signature: number[],
    numBands: number,
    rowsPerBand: number
  ) {
    for (let i = 0; i < numBands; i++) {
      const band = signature.slice(i * rowsPerBand, (i + 1) * rowsPerBand);
      const bandKey = `${bucketPrefix}:${i}:${band.join(",")}`;
      await redisClient.sAdd(bandKey, JSON.stringify(signature));
    }
  }

  /**
   * 使用LSH检索候选签名
   * @param bucketPrefix 桶的前缀
   * @param signature MinHash签名
   * @param numBands band的数量
   * @param rowsPerBand 每个band的行数
   * @returns 候选签名数组
   */
  async function retrieveCandidatesLSH(
    bucketPrefix: string,
    signature: number[],
    numBands: number,
    rowsPerBand: number
  ): Promise<number[][]> {
    const candidates = new Set<string>();
    for (let i = 0; i < numBands; i++) {
      const band = signature.slice(i * rowsPerBand, (i + 1) * rowsPerBand);
      const bandKey = `${bucketPrefix}:${i}:${band.join(",")}`;
      const bandCandidates = await redisClient.sMembers(bandKey);
      bandCandidates.forEach((candidate) => candidates.add(candidate));
    }
    return Array.from(candidates).map((candidate) => JSON.parse(candidate));
  }

  /**
   * 计算两个签名的Jaccard相似度
   * @param signature1 签名1
   * @param signature2 签名2
   * @returns Jaccard相似度
   */
  function jaccardSimilarity(
    signature1: number[],
    signature2: number[]
  ): number {
    let intersection = 0;
    for (let i = 0; i < signature1.length; i++) {
      if (signature1[i] === signature2[i]) {
        intersection++;
      }
    }
    return intersection / signature1.length;
  }

  // 示例文本
  const text1 = "这是一个测试文本,用于演示MinHash算法的抄袭检测功能。";
  const text2 = "这是一个测试文本,用于展示MinHash算法的抄袭检测功能。";

  // 参数设置
  const k = 5; // shingle的长度
  const numHashes = 100; // 使用的哈希函数数量
  const numBands = 20; // LSH的band数量
  const rowsPerBand = numHashes / numBands; // 每个band包含的行数

  // 计算shingles
  const shingles1 = shingles(text1, k);
  const shingles2 = shingles(text2, k);

  // 计算MinHash签名
  const signature1 = minHashSignature(shingles1, numHashes);
  const signature2 = minHashSignature(shingles2, numHashes);

  // 存储签名到LSH
  await storeSignatureLSH("articles", signature1, numBands, rowsPerBand);
  await storeSignatureLSH("articles", signature2, numBands, rowsPerBand);

  // 检索候选签名
  const candidates = await retrieveCandidatesLSH(
    "articles",
    signature1,
    numBands,
    rowsPerBand
  );

  // 计算相似度
  for (const candidate of candidates) {
    const similarity = jaccardSimilarity(signature1, candidate);
    console.log(`相似度为: ${similarity}`);
  }

  await redisClient.quit(); // 关闭Redis连接
}

// 运行主函数
main().catch(console.error);

总结

本文探讨了如何利用Redisearch实现高效的全文搜索引擎和抄袭检测功能。通过Redisearch的高性能和灵活查询能力,结合MinHash算法和Locality-Sensitive Hashing (LSH),实现了对文本数据的高效存储、检索和相似度计算。本文详细介绍了项目背景、目标、环境准备以及具体实现步骤,展示了Redisearch在处理大规模文本搜索和抄袭检测中的优势和应用。通过结合MinHash算法,Redisearch能够快速处理大规模文本数据并进行相似度计算,适用于需要高效全文搜索和抄袭检测的场景。然而,具体效果和性能仍需根据实际应用环境和数据规模进行评估。

附录——ArticlePlagiarismDetector文章判重类封装

import { createClient, RedisClientType } from "redis";
import { createHash } from "crypto";

/**
 * 文章抄袭检测类
 */
export class ArticlePlagiarismDetector {
  private redisClient: RedisClientType;
  private k: number;
  private numHashes: number;
  private numBands: number;
  private rowsPerBand: number;

  constructor(
    redisUrl: string,
    k: number,
    numHashes: number,
    numBands: number
  ) {
    this.redisClient = createClient({ url: redisUrl });
    this.k = k;
    this.numHashes = numHashes;
    this.numBands = numBands;
    this.rowsPerBand = numHashes / numBands;
  }

  async connect() {
    await this.redisClient.connect();
  }

  async disconnect() {
    await this.redisClient.quit();
  }

  /**
   * 将文本拆分为shingles
   * @param text 输入文本
   * @returns shingle数组
   */
  private shingles(text: string): string[] {
    const shinglesSet = new Set<string>();
    for (let i = 0; i <= text.length - this.k; i++) {
      shinglesSet.add(text.slice(i, i + this.k));
    }
    return Array.from(shinglesSet);
  }

  /**
   * 计算shingle的哈希值
   * @param shingle 输入shingle
   * @returns 哈希值数组
   */
  private hashShingle(shingle: string): number[] {
    const hashes: number[] = [];
    for (let i = 0; i < this.numHashes; i++) {
      const hash = createHash("sha256")
        .update(shingle + i)
        .digest("hex");
      hashes.push(parseInt(hash, 16));
    }
    return hashes;
  }

  /**
   * 计算shingle集合的MinHash签名
   * @param shingles shingle数组
   * @returns MinHash签名数组
   */
  private minHashSignature(shingles: string[]): number[] {
    const signature = Array(this.numHashes).fill(Infinity);
    for (const shingle of shingles) {
      const hashes = this.hashShingle(shingle);
      for (let i = 0; i < this.numHashes; i++) {
        if (hashes[i] < signature[i]) {
          signature[i] = hashes[i];
        }
      }
    }
    return signature;
  }

  /**
   * 使用LSH存储签名
   * @param bucketPrefix 桶的前缀
   * @param articleId 文章ID
   * @param signature MinHash签名
   */
  private async storeSignatureLSH(
    bucketPrefix: string,
    articleId: string,
    signature: number[]
  ): Promise<void> {
    if (signature.length !== this.numHashes) {
      throw new Error(
        `Invalid signature length: expected ${this.numHashes}, got ${signature.length}`
      );
    }
    for (let i = 0; i < this.numBands; i++) {
      const band = signature.slice(
        i * this.rowsPerBand,
        (i + 1) * this.rowsPerBand
      );
      const bandKey = `${bucketPrefix}:${i}:${band.join(",")}`;
      await this.redisClient.sAdd(
        bandKey,
        JSON.stringify({ articleId, signature })
      );
    }
  }

  /**
   * 使用LSH检索候选签名
   * @param bucketPrefix 桶的前缀
   * @param signature MinHash签名
   * @returns 候选签名数组
   */
  private async retrieveCandidatesLSH(
    bucketPrefix: string,
    signature: number[]
  ): Promise<{ articleId: string; signature: number[] }[]> {
    const candidates = new Set<string>();
    for (let i = 0; i < this.numBands; i++) {
      const band = signature.slice(
        i * this.rowsPerBand,
        (i + 1) * this.rowsPerBand
      );
      const bandKey = `${bucketPrefix}:${i}:${band.join(",")}`;
      const bandCandidates = await this.redisClient.sMembers(bandKey);
      bandCandidates.forEach((candidate: string) => candidates.add(candidate));
    }
    const result = Array.from(candidates)
      .map((candidate) => {
        try {
          const parsedCandidate = JSON.parse(candidate);
          if (
            !parsedCandidate.articleId ||
            !Array.isArray(parsedCandidate.signature) ||
            parsedCandidate.signature.length !== this.numHashes
          ) {
            return null;
          }
          return parsedCandidate;
        } catch (e) {
          console.error("Failed to parse candidate:", candidate, e);
          return null;
        }
      })
      .filter((candidate) => candidate !== null);

    return result;
  }

  /**
   * 计算两个签名的Jaccard相似度
   * @param signature1 签名1
   * @param signature2 签名2
   * @returns Jaccard相似度
   */
  private jaccardSimilarity(
    signature1: number[],
    signature2: number[]
  ): number {
    if (!signature1 || !signature2) {
      throw new Error("Signatures cannot be null or undefined");
    }
    if (signature1.length !== signature2.length) {
      throw new Error("Signatures must be of the same length");
    }

    let intersection = 0;
    for (let i = 0; i < signature1.length; i++) {
      if (signature1[i] === signature2[i]) {
        intersection++;
      }
    }
    return intersection / signature1.length;
  }

  /**
   * 检查文章相似度
   * @param bucketPrefix 桶的前缀
   * @param text 文章文本
   * @returns 与缓存中文章的相似度和对应文章ID
   */
  async checkSimilarity(
    bucketPrefix: string,
    text: string
  ): Promise<{ articleId: string; similarity: number }[]> {
    const shingles = this.shingles(text);
    const signature = this.minHashSignature(shingles);

    // 检索候选签名
    const candidates = await this.retrieveCandidatesLSH(
      bucketPrefix,
      signature
    );

    // 计算相似度
    return candidates.map((candidate) => {
      if (
        !candidate.signature ||
        candidate.signature.length !== this.numHashes
      ) {
        console.error("Invalid candidate signature:", candidate);
        return { articleId: candidate.articleId, similarity: 0 };
      }
      return {
        articleId: candidate.articleId,
        similarity: this.jaccardSimilarity(signature, candidate.signature),
      };
    });
  }

  /**
   * 添加文章并存储签名
   * @param bucketPrefix 桶的前缀
   * @param articleId 文章ID
   * @param text 文章文本
   */
  async addArticle(bucketPrefix: string, articleId: string, text: string) {
    const shingles = this.shingles(text);
    const signature = this.minHashSignature(shingles);

    // 存储签名
    await this.storeSignatureLSH(bucketPrefix, articleId, signature);
  }
}