在 MySQL 中查找最长匹配的 ngram

Posted

技术标签:

【中文标题】在 MySQL 中查找最长匹配的 ngram【英文标题】:Find longest matching ngrams in MySQL 【发布时间】:2014-06-22 04:12:38 【问题描述】:

给定一个包含 VARCHAR 中的 ngram 的列和 utf8mb4_unicode_ci 排序规则:

+---------------------------+
| ngram                     |
+---------------------------+
| stack overflow            |
| stack                     |
| overflow                  |
| stack overflow protection |
| overflow protection       |
| protection                |
+---------------------------+

还有一个查询:

SELECT * FROM ngrams WHERE ngram IN ('stack', 'stack overflow', 'protection', 'overflow')

鉴于此查询返回的行,我怎样才能只保留具有最长 ngram 的行从返回的行

在本例中,我得到 3 行:stackstack overflowprotection

然后,我需要像这样过滤行:

我过滤掉了stack,因为stack overflow存在于返回的行中 我保留stack overflow,因为没有其他返回的行是包含stack overflow 的ngram(表中有stack overflow protection,但它不在返回的行中) 我也保留protection 我过滤掉了overflow,因为stack overflow存在于返回的行中

由于排序规则,必须在 mysql 中完成(在 MySQL 之外的比较不会给出与 MySQL 中相同的结果)。 (除非我不知道某些 MySQL 函数允许公开 collat​​ed 版本的字符串。)


我可以想到以下解决方案:(sql fiddle)

SELECT  ngram
FROM    ngrams n1
WHERE   n1.ngram IN ('stack', 'stack overflow', 'protection')
AND     NOT EXISTS (
    SELECT  1
    FROM    ngrams n2
    WHERE   n2.ngram IN ('stack', 'stack overflow', 'protection')
    AND     LENGTH(n2.ngram) > LENGTH(n1.ngram)
    AND     CONCAT(' ', n2.ngram, ' ') LIKE CONCAT('% ', n1.ngram, ' %')
)

但效率低下,因为子查询将为每个匹配的 ngram 执行。


所以我正在寻找

任何一种使查询高效的方法 或在 MySQL 之外可靠地执行此操作的方法(考虑排序规则)

【问题讨论】:

您希望查询返回什么?目前尚不清楚,人们正在提供多种不同的解决方案。 尽管答案很花哨,NOT EXISTS 可能是outperforms them all,因为无论如何都无法在单个 SELECT 中执行操作。我相信使用 CTE 可能会更快,因为您可以使用递归,但 MySQL 似乎不支持这样的东西。 但是,您可以只检查n2.ngram <> n1.ngram 而不是检查LENGTH(n2.ngram) > LENGTH(n1.ngram),我不确定您为什么要检查CONCAT(' ', n2.ngram, ' ')?你需要LIKE中的空格吗? @plalx 一些答案的查询似乎比问题中的查询更有效。对于 CONCAT,它是为了避免匹配部分单词,例如%foo% 将匹配 foobar,而 % foo % 不会。 您需要多久执行一次此操作?这是一个有向图问题的示例,您可以通过预处理 ngram 表本身来解决该问题。还有,ngram 表有多大,in 列表有多长? 【参考方案1】:

您正在尝试过滤查询本身中的 ngram。 分两步完成可能更有效。 从包含所有可能的 ngram 的表开始:

CREATE TABLE original (ngram varchar(100) NOT NULL)
GO

CREATE TABLE refined (ngram varchar(100) NOT NULL PRIMARY KEY)
GO

INSERT INTO original (ngram)
SELECT DISTINCT ngram
FROM ngrams
WHERE ngram IN ('stack', 'stack overflow', 'protection')
GO

INSERT INTO refined (ngram)
SELECT ngram
FROM original

然后删除你不想要的。 对于每个 ngram,生成所有可能的子字符串。对于每个子字符串,从列表中删除该条目(如果有)。 这需要几个嵌套循环,但除非您的 ngram 包含大量单词,否则不会花费太多时间。

CREATE PROCEDURE refine()
BEGIN
    DECLARE done INT DEFAULT FALSE;
    DECLARE words varchar(100);
    DECLARE posFrom, posTo int;
    DECLARE cur CURSOR FOR SELECT ngram FROM original;
    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;

    OPEN cur;

    read_loop: LOOP
        FETCH cur INTO words;
        IF done THEN
            LEAVE read_loop;
        END IF;

        SET posFrom = 1;
        REPEAT
            SET posTo = LOCATE(' ', words, posFrom);
            WHILE posTo > 0 DO
                DELETE FROM refined WHERE ngram = SUBSTRING(words, posFrom, posTo - posFrom);
                SET posTo = LOCATE(' ', words, posTo + 1);
            END WHILE;
            IF posFrom > 1 THEN
                DELETE FROM refined WHERE ngram = SUBSTRING(words, posFrom);
            END IF;
            SET posFrom = LOCATE(' ', words, posFrom) + 1;
        UNTIL posFrom = 1 END REPEAT;
    END LOOP;

    CLOSE cur;
END

剩下的是一张只有最长 ngram 的表格:

CALL refine;

SELECT ngram FROM refined;

SQL 小提琴:http://sqlfiddle.com/#!2/029dc/1/1


编辑:我在表 refined 上添加了一个索引;现在它应该在 O(n) 时间内运行。

【讨论】:

为什么这个更复杂的实现比单个查询语句更有效? @zinking:经验法则:不要使用游标。但在某些情况下,游标是必不可少的。到目前为止,我看到的所有声明性方法似乎都在 O(n*n) 时间内运行。基于游标的方法应该能够在 O(n) 时间内运行(假设表 refined 已编入索引;请参阅我的编辑)。拥有大量记录,预计性能会大幅提升。【参考方案2】:

以下查询只扫描一次数据并提供正确的结果 (fiddle):

SELECT my_ngrams.ngram
  FROM (SELECT CASE WHEN @v LIKE CONCAT('%',n1.ngram,'%') THEN 1 ELSE 0 END AS ngram_match
             , @v:=concat(@v,',',n1.ngram) AS ngram_concat
             , n1.ngram
          FROM    ngrams n1, (SELECT @v := '') r
         WHERE   n1.ngram IN ('stack', 'stack overflow', 'overflow', 'protection', 'overflow protection')
      ORDER BY length(n1.ngram) DESC) my_ngrams
 WHERE my_ngrams.ngram_match <> 1
;

但是,它依赖于 MySQL (http://dev.mysql.com/doc/refman/5.5/en/user-variables.html) 中用户定义变量的行为,因此应谨慎使用。

“排序依据”对于解决方案很重要,因为它会影响如何逐行评估用户定义的变量,这会影响哪些行被案例匹配并随后被过滤。

它还将所有结果连接在一起以在过滤之前搜索 ngram 匹配项,因此您应该注意,您最终可能会得到一个比 MySQL 允许的最大值 (http://dev.mysql.com/doc/refman/5.5/en/char.html) 更宽的连接字符串。

只要列被正确索引,即使对于大型表,这也应该非常有效。

【讨论】:

看起来不错,但只有当两个 ngram 共享相同的前缀时才有效。例如。使用IN('stack overflow', 'overflow'),我应该只得到stack overflow,但我也得到overflow:sqlfiddle.com/#!2/f8be79/78 已更新以处理这种情况。但是如果还有一个词是另一个词的子串呢?例如。 IN('stack', 'stack overflow', 'stac')?那么结果应该是什么?现在,stac 被删除为重复项。见sqlfiddle.com/#!2/86a21/2【参考方案3】:

在没有先查看其他解决方案的情况下执行此操作后,我发现它与您现有的最佳解决方案相似,但阅读起来稍微简单一些,并且可能更高效一些;

SELECT n1.ngram
FROM ngrams n1
LEFT JOIN ngrams n2
  ON n2.ngram IN ('stack', 'stack overflow', 'protection', 'overflow')
 AND n1.ngram <> n2.ngram
 AND INSTR(n2.ngram, n1.ngram) > 0
WHERE n1.ngram IN ('stack', 'stack overflow', 'protection', 'overflow')
 AND n2.ngram IS NULL;

An SQLfiddle to test with.

由于AND n1.ngram &lt;&gt; n2.ngram 行上没有计算,查询应该能够更有效地使用索引。

【讨论】:

【参考方案4】:

使用用户变量试试这个查询

select 
  ngram
from 
  (select 
    ngram, 
    @t:=if(@prev=rank, @t+1, 1) as num,
    @prev:=rank
  from 
    (select 
      ngram,
      @rank:=if(@prev like concat(ngram,'%'), @rank, @rank+1) as rank,
      CHAR_LENGTH(ngram) as size,
      @prev:=ngram
    from 
      tbl 
    join 
      (select 
         @prev:='', 
         @rank:=1) t 
    where 
       ngram in ('stack overflow', 'stack', 'protection')
    order by 
       rank, size desc
   )t
  join 
    (select 
       @t:=0, 
       @prev:=0) t1
    ) t 
  where 
    num =1

Fiddle

|          NGRAM |
|----------------|
| stack overflow |
|     protection |

【讨论】:

【参考方案5】:

对您的查询稍作修改:

SELECT  ngram
FROM    ngrams n1
WHERE   n1.ngram IN ('stack', 'stack overflow', 'protection') AND
        NOT EXISTS (SELECT  1
                    FROM    ngrams n2
                    WHERE   n2.ngram IN ('stack', 'stack overflow', 'protection') AND
                            n2.ngram <> n1.ngram AND
                            n2.ngram LIKE CONCAT('% ', n1.ngram, ' %')
                   );

使用ngrams(ngram) 上的索引应该非常快。请注意,这简化了like 条件。我认为您没有理由担心单词边界。 “堆栈”不是“堆栈”的更长版本吗? (虽然 n-gram 所指的项目可以是单词,但除非另有说明,否则我将它们与字母联系起来。)

使用索引,这在性能上应该与使用join 的其他解决方案相当。

如果我必须这样做无数次并且 ngram 表不是太大,我会对其进行预处理以获得所有“概括”对——ngram_pairs。这会将上述内容更改为

SELECT  ngram
FROM    ngrams n1
WHERE   n1.ngram IN ('stack', 'stack overflow', 'protection') AND
        NOT EXISTS (SELECT  1
                    FROM    ngram_pairs np
                    WHERE   np.ngram1 = n1.ngram and
                            np.ngram2 in ('stack', 'stack overflow', 'protection') 
                   )

这应该比在ngram_pairs(ngram1, ngram2) 上具有索引的like 执行得更好。以下是生成ngram_pairs的代码:

create table ngram_pairs as
    select n1.ngram as ngram1, n2.ngram as ngram2
    from ngrams n1 join
         ngrams n2
         on length(n1.ngram) < length(n2.ngram) and
            n2.ngram like concat('%', n1.ngram, '%');

create index ngram_pairs_ngram1_ngram2 on ngram_pairs(ngram1, ngram2);

【讨论】:

【参考方案6】:

如果我正确理解你的逻辑,这个查询应该会给你正确的结果:

SELECT n1.ngram
FROM
  ngrams n1 LEFT JOIN ngrams n2
  ON
    n2.ngram IN ('stack', 'stack overflow', 'protection')
    AND n2.ngram LIKE CONCAT('%', n1.ngram, '%')
    AND CHAR_LENGTH(n1.ngram) < CHAR_LENGTH(n2.ngram)
WHERE
  n1.ngram IN ('stack', 'stack overflow', 'protection')
  AND n2.ngram IS NULL;

请看小提琴here。但是既然我希望你的表可能有很多记录,而你的单词列表肯定是有限的,为什么不在执行实际查询之前从这个列表中删除最短的 ngram 呢?我的想法是减少列表

('stack', 'stack overflow', 'protection')

('stack overflow', 'protection')

这个查询应该可以解决问题:

SELECT *
FROM
  ngrams
WHERE
  ngram IN (
    SELECT s1.ngram
    FROM (
      SELECT DISTINCT ngram
      FROM ngrams
      WHERE ngram IN ('stack','stack overflow','protection')
    ) s1 LEFT JOIN (
      SELECT DISTINCT ngram
      FROM ngrams
      WHERE ngram IN ('stack','stack overflow','protection')
    ) s2
      ON s2.ngram LIKE CONCAT('%', s1.ngram, '%')
         AND CHAR_LENGTH(s1.ngram) < CHAR_LENGTH(s2.ngram)
    WHERE
      s2.ngram IS NULL
  );

是的,我在再次将结果连接回ngrams 之前查询了表ngrams 两次,因为我们必须确保表中确实存在最长的值,但是如果您在ngram 列使用 DISTINCT 的两个派生查询应该非常高效:

ALTER TABLE ngrams ADD INDEX idx_ngram (ngram);

小提琴是here。

编辑:

正如 samuil 正确指出的那样,如果您只需要找到最短的 ngram 而不是与其关联的整行,那么您不需要外部查询,您可以只执行内部查询。使用适当的索引,两个 SELECT DISTINCT 查询将非常有效,即使 JOIN 无法优化(n2.ngram LIKE CONCAT('%', n1.ngram, '%') 无法利用索引),它也只会在一些已经过滤的记录上执行,并且应该相当快。

【讨论】:

@fthiella 是否需要三个参考?据我了解,在外部选择中,您正在选择与子查询中匹配的 ngram 匹配的所有 ngram。为什么不能简单地将这个子查询用作整个查询?【参考方案7】:

试试这个:Fiddle

SELECT * 
FROM   tab 
WHERE  ngram NOT IN (SELECT DISTINCT b.ngram 
                     FROM   tab a, 
                            tab b 
                     WHERE  a.ngram != b.ngram 
                            AND a.ngram LIKE Concat('%', b.ngram, '%'));

如果您只想包含列表中存在于表中的那些,请尝试以下查询:-

SELECT b.ngram ab 
FROM   (SELECT * 
        FROM   tab 
        WHERE  ngram IN ( 'stack', 'stack overflow', 'protection' )) a, 
       (SELECT * 
        FROM   tab 
        WHERE  ngram IN ( 'stack', 'stack overflow', 'protection' )) b 
WHERE  a.ngram LIKE Concat('%', b.ngram, '%') 
GROUP  BY b.ngram 
HAVING Count(*) = 1 

Demo2

【讨论】:

它似乎不接受 ngram 列表作为参数。【参考方案8】:

这是使用 LEFT JOIN 的替代方法。

表是自联接的,条件是不存在包含在另一个 ngram 中的 ngram,并且它不等于自联接表中的 ngram。考虑到性能,避免了子查询。

编辑

添加过滤条件。

SELECT n1.ngram
FROM ngrams n1
LEFT JOIN 
(
  SELECT ngram
  FROM ngrams
  WHERE ngram IN ('stack', 'stack overflow', 'protection')) n2
ON n2.ngram like Concat('%', n1.ngram, '%') and n1.ngram <> n2.ngram
WHERE n2.ngram IS NULL
AND n1.ngram IN ('stack', 'stack overflow', 'protection');

如果您正在检查是否只有 ngram 的开头包含在另一个 ngram 中,您可以将 JOIN 条件替换为 ON n2.ngram like Concat(n1.ngram, '%') and n1.ngram &lt;&gt; n2.ngram.

我在 SQL Fiddle 中添加了更多值:

    'xyz'(不包含在任何其他 ngram 中) '堆栈溢出异常'(这是'堆栈溢出'的另一个父级) '堆栈溢出异常处理'(它是'堆栈溢出的父级 例外')

SQL Fiddle demo

参考

JOIN syntax on MySQL Reference Manual

【讨论】:

当您在WHERE 子句中检查IS NULL 时,使用LEFT JOIN 有什么意义?有什么区别吗? @samuil 是的,有。 LEFT JOIN 获取满足 JOIN 条件的行加上 n1 中的所有行。我们只需要不满足 JOIN 条件的行(类似于不存在/不在具有连接条件的子查询中的行)。因此,我们检查 n2 值是否为 NULL。 这里有一篇博客讨论了各种性能方面的方法:explainextended.com/2009/09/18/… 抱歉——我的问题错过了我将LEFT JOIN 与进行比较的内容。我的意思是在这种情况下INNER JOIN 应该是等价的。 @samuil 不用担心。 INNER JOIN 将为您提供满足条件的行(即存在另一个包含当前 ngram 的 ngram)。另一方面,我们想要不满足条件的 ngram。因此,这是一个反连接。【参考方案9】:

试试

 ORDER BY LENGTH(ngram) DESC and use LIMIT 1

编辑:

试试看:

  SELECT n1.ngram
  FROM ngrams n1 
  INNER JOIN ngrams n2
  ON LENGTH(n2.ngram) < LENGTH(n1.ngram)
  WHERE   n2.ngram IN ('stack', 'stack overflow', 'protection')
  GROUP BY n1.ngram

【讨论】:

不回答问题【参考方案10】:
SELECT  a.ngram FROM ngram a  CROSS JOIN (SELECT ngram AS ngram1 FROM ngram) b 
ON b.ngram1 LIKE CONCAT('%', a.ngram, '%') 
WHERE length(a.ngram) <= length(b.ngram1) 
GROUP BY a.ngram HAVING COUNT(a.ngram) = 1 ORDER BY LENGTH(b.ngram1) DESC

【讨论】:

【参考方案11】:
SELECT * FROM   ngrams a WHERE  a.n NOT IN (SELECT DISTINCT a.n 
                 FROM   ngrams b
                 WHERE b.n != a.n 
                    AND b.n LIKE CONCAT('%', a.n, '%'));

【讨论】:

【参考方案12】:

我认为您可以在 LIKE %original string% 上使用自内连接,并仅选择那些 ngram 长度等于最长连接 ngram 长度的行。

SELECT n1.* FROM ngrams n1
  INNER JOIN ngrams n2 ON
    n2.ngram LIKE CONCAT('%', `n1`.`ngram`, '%')
    AND n2.ngram IN ('stack overflow', 'stack')
  WHERE n1.ngram IN ('stack overflow', 'stack')
  GROUP BY n1.ngram
  HAVING MAX(CHAR_LENGTH(n2.ngram)) = CHAR_LENGTH(n1.ngram);

此解决方案的缺点是您需要提供两次字符串列表。


事实证明,您不需要提供两次列表:

SELECT n1.*
  FROM ngrams n1
  INNER JOIN ngrams n2 ON
    n2.ngram LIKE CONCAT('%', `n1`.`ngram`, '%')
    AND n2.ngram IN ('stack overflow', 'stack')
  GROUP BY n1.ngram
  HAVING MAX(CHAR_LENGTH(n2.ngram)) = CHAR_LENGTH(n1.ngram);

【讨论】:

dont work with AND n2.ngram IN ('stack', 'stack overflow', 'protection')` Strage,我也用'protection' 进行了检查。加了'protection'有什么问题? 没有。据我了解原始问题,只有当它被明确列出时,你才应该得到 stack overflow protection 字符串。 同意.. 只是为了“保护”,它应该是 stack overflow,因为没有像 protection 这样的字符串 工作,但我希望看到一个有效的解决方案(有一个大表和最多 20 纳克)。

以上是关于在 MySQL 中查找最长匹配的 ngram的主要内容,如果未能解决你的问题,请参考以下文章

路由器根据IP报文中的目的IP地址还是源IP地址进行路由的查找,掩码最长匹配还掩码最短匹配

如何使更短(更接近)的令牌匹配更相关? (edge_ngram)

ngram 匹配对不太相关的文档给出相同的分数

MySQL - 获取最长字符串匹配的记录

[Elasticsearch] 部分匹配 - 索引期间优化ngrams及索引期间的即时搜索

MySQL匹配连接表中的最长前缀