【问题标题】:Fast fuzzy/approximate search in dictionary of strings in RubyRuby中字符串字典中的快速模糊/近似搜索
【发布时间】:2013-11-29 13:01:00
【问题描述】:

我有一个包含 50K 到 100K 字符串的字典(最多可以包含 50+ 个字符),并且我正在尝试查找给定字符串是否在字典中并具有一些“编辑”距离容差。 (例如莱文斯坦)。在进行搜索之前,我可以预先计算任何类型的数据结构。

我的目标是尽可能快地对该字典运行数千个字符串并返回最近的邻居。如果有一个明显更快的算法可以这样做,我会得到一个布尔值来说明给定是否在字典中

为此,我首先尝试计算所有 Levenshtein 距离并取最小值,这显然非常慢。所以我尝试根据这篇文章实现一个Levenshtein Trie http://stevehanov.ca/blog/index.php?id=114

在此处查看我的要点以重现基准:https://gist.github.com/nicolasmeunier/7493947

以下是我在我的机器上获得的一些基准测试:

编辑距离为 0(完美匹配)

Benchmark.measure { 10.times { dictionary.search(random_word, 0) } }
<Benchmark::Tms:0x007fa59bad8908 @label="", @real=0.010889, @cstime=0.0, @cutime=0.0, @stime=0.0, @utime=0.00999999999999801, @total=0.00999999999999801> 

*编辑距离为 2,它变得慢了很多 *

Benchmark.measure { 10.times { dictionary.search(random_word, 2) } }
<Benchmark::Tms:0x007fa58c9ca778 @label="", @real=3.404604, @cstime=0.0, @cutime=0.0, @stime=0.020000000000000018, @utime=3.3900000000000006, @total=3.4100000000000006>

它从那里开始走下坡路,并且对于大于 2 的编辑距离变得非常慢。(每个测试字符串平均 1 秒以上)。

我想知道如何/是否可以显着加快速度。如果已经在 ruby​​/gems 中实现了现有的解决方案,我也不想重新发明轮子......

编辑 1:就我而言,我希望我与字典匹配的大多数字符串都不在其中。因此,如果有任何算法可以快速丢弃字符串,那真的很有帮助。

谢谢, 尼古拉斯

【问题讨论】:

  • Nicolas,我不愿发表评论,因为我不熟悉文献,也不想偏离讨论,但我想起了很多个月前的一个问题,即确定文件是否与远程计算机上大型“库”中的任何文件相同。我在“文件签名”上对文件进行了索引,该签名是一种 CRC(循环冗余校验)计算的结果(一个 Fixnum,但我不记得有多少字节)。它非常快且 100% 准确。想法是计算每个字符串的签名,然后在索引中查找。
  • 您是在寻找 100% 相同的文件还是允许任何误差范围?那是你能控制的吗?我会更多地研究它。感谢您的建议。
  • 你试过thisthis吗?
  • 第一个似乎只计算 2 个单词之间的距离,并且在字典中搜索时没有实现任何索引/树/ngrams 数据结构来优化。到目前为止,我第一次尝试第二次的速度很慢......
  • 尼古拉斯,实际上是 100%。要使用签名来区分两个长度相等的文件,可以肯定的是,签名必须至少与文件一样长,因此什么也得不到。但是,签名不需要很长时间,可能的签名数量就会大大超过世界上所有计算机上的文件数量。我使签名足够长,以至于两个不相等的文件具有相同签名的可能性太小而无需担心。这从来都不是问题。

标签: ruby performance algorithm levenshtein-distance fuzzy-search


【解决方案1】:

我写了一对 gem,fuzzilyblurrily,它们进行基于三元组的模糊匹配。 鉴于您的(低)数据量,Fuzzyly 将更容易集成并且速度差不多,在现代硬件上,您可以在 5-10 毫秒内得到答案。

鉴于两者都是基于 trigrams(可索引),而不是基于编辑距离(不可),您可能需要分两次执行此操作:

  • 首先向任一 gem 询问一组最佳匹配 trigrams-wise
  • 然后使用 Levenstein 将结果与您的输入字符串进行比较
  • 并返回该度量的最小值。

在 Ruby 中(如您所问),使用 Fuzzily + Text gem,获取具有编辑距离阈值的记录如下所示:

MyRecords.find_by_fuzzy_name(input_string).select { |result|
  Text::Levenshtein.distance(input_string, result.name)] < my_distance_threshold
}

这执行了少量优化的数据库查询和一些

注意事项:

  • 如果您要寻找的“最小”编辑距离很高,那么您仍然会做很多 Levenshteins。
  • 使用三元组假定您的输入文本是拉丁文本或接近(基本上是欧洲语言)。
  • 可能存在边缘情况,因为没有任何东西可以保证“匹配三元组的数量”是“编辑距离”的一般近似值。

【讨论】:

  • 我目前正在用 Blurrily 做这件事,所以我有内存索引并使用 levenshtein-ffi gem(C 实现 - 比我自己的 Ruby 实现快 4 倍)并且我收到了关于我的数据需要 1 到 2 毫秒,而我的纯 Ruby 实现需要 50 到 200 毫秒。我对这些警告完全满意,所以这对我来说是完美的解决方案。非常感谢这些神奇的宝石!
  • 不客气,尼古拉斯。如果发现任何错误,请打开 Github 问题,我会尽力维护它们!
【解决方案2】:

大约 15 年前,我编写了模糊搜索,它可以找到 N 个近邻。 这是我对 Wilbur 的 trigram 算法的修改,这个修改命名为“Wilbur-Khovayko 算法”。

基本思路:按三元组分割字符串,搜索最大交集分数。

例如,让我们有字符串“hello world”。 该字符串生成三元组:hel ell llo "lo"、"o_w"、e等; 此外,为每个单词生成特殊的前缀/后缀三元组,例如 $he $wo lo$ ld$。

此后,对于每个三元组构建的索引,它存在于哪个术语中。

所以,这是每个三元组的 term_ID 列表。

当用户调用某个字符串时 - 它也拆分为三元组,并程序搜索最大交集分数,并生成 N 大小列表。

它工作得很快:我记得,在旧的 Sun/solaris、256MB 内存、200MHZ CPU 上,它在 0.25 秒内搜索字典中 5,000,000 个术语中的 100 个最接近的术语

您可以从以下位置获取我的旧资源: http://olegh.ftp.sh/wilbur-khovayko.tar.gz

更新:

我创建了新的存档,其中 Makefile 针对现代 Linux/BSD 进行了调整。 您可以在这里下载新版本:http://olegh.ftp.sh/wilbur-khovayko.tgz

创建一些目录,并在此处提取存档:

mkdir F2
cd F2
tar xvfz wilbur-khovayko.tgz
make

转到测试目录,复制术语列表文件(这是固定名称,termlist.txt),然后 制作索引:

 cd test/
 cp /tmp/test/termlist.txt ./termlist.txt
 ./crefdb.exe <termlist.txt

在这个测试中,我使用了大约 380,000 个过期域名:

wc -l termlist.txt
379430 termlist.txt

运行 findtest 应用程序:

./findtest.exe

boking  <-- this is query -- word "booking" with misspeling


0001:Query: [boking]
  1:  287890 (  3.863739) [bokintheusa.com,2009-11-20,$69]
  2:  287906 (  3.569148) [bookingseu.com,2009-11-20,$69]
  3:  257170 (  3.565942) [bokitko.com,2009-11-18,$69]
  4:  302830 (  3.413791) [bookingcenters.com,2009-11-21,$69]
  5:  274658 (  3.408325) [bookingsadept.com,2009-11-19,$69]
  6:  100438 (  3.379371) [bookingresorts.com,2009-11-09,$69]
  7:  203401 (  3.363858) [bookinginternet.com,2009-11-15,$69]
  8:  221222 (  3.361689) [bobokiosk.com,2009-11-16,$69]
  . . . . 
 97:   29035 (  2.169753) [buccupbooking.com,2009-11-05,$69]
 98:  185692 (  2.169047) [box-hosting.net,2009-11-14,$69]
 99:  345394 (  2.168371) [birminghamcookinglessons.com,2009-11-25,$69]
100:  150134 (  2.167372) [bowlingbrain.com,2009-11-12,$69]

【讨论】:

  • 非常有趣!你为什么不把它放在像 f.e. 这样的公共源代码平台上。 GitHub、Gitorious 还是 Bitbucket?
  • 它是在大约 15 年前写的,当时 github 等还不存在。我把它放在我自己的网站上。如果您愿意,您可以获取源代码,并将其转为公共开源项目。
  • 有趣。您能否解释一下我如何在一些示例上运行您的代码(对于不太记得 C 的人... :))谢谢!
  • 尼古拉斯,请参阅我的回答中的更新;我希望,它会很有用。
  • 您好!我在您的源代码中没有看到 termlist.txt。预计会有什么格式?
【解决方案3】:

如果您准备参与机器学习方法,那么 Geoff Hinton 的这篇论文将是一个很好的起点

http://www.cs.toronto.edu/~hinton/absps/sh.pdf

这些方法在谷歌等地方使用。

基本上,您根据相似性对字典字符串进行聚类。当查询字符串到来时,无需计算与整个数据集的编辑距离,您只需比较集群,从而显着减少查询时间。

P.S 我做了一些谷歌搜索,发现了另一种类似方法的 Ruby 实现,称为 Locality Sensitive Hashing 这里 https://github.com/bbcrd/ruby-lsh

【讨论】:

    【解决方案4】:

    这是原始的类似 Trie 的实现。它完全没有优化,只是一个概念证明。纯 Ruby 实现。

    为了测试它,我从http://www.infochimps.com/datasets/word-list-100000-official-crossword-words-excel-readable/downloads/195488这里拿了 100_000 个字

    这里有一个要点 https://gist.github.com/fl00r/7542994

    class TrieDict
      attr_reader :dict
    
      def initialize
        @dict = {}
      end
    
      def put(str)
        d = nil
        str.chars.each do |c|
          d && (d = (d[1][c] ||= [nil, {}])) || d = (@dict[c] ||= [nil, {}])
        end
        d[0] = true
      end
    
      def fetch(prefix, fuzzy = 0)
        storage = []
        str = ""
        error = 0
        recur_fetch(prefix, fuzzy, @dict, storage, str, error)
        storage
      end
    
      def recur_fetch(prefix, fuzzy, dict, storage, str, error)
        dict.each do |k, vals|
          e = error
          if prefix[0] != k
            e += 1
            next  if e > fuzzy
          end
          s = str + k
          storage << s  if vals[0] && (prefix.size - 1) <= (fuzzy - e)
          recur_fetch(prefix[1..-1] || "", fuzzy, vals[1], storage, s, e)
        end
      end
    end
    
    def bench
      t = Time.now.to_f
      res = nil
      10.times{ res = yield }
      e = Time.now.to_f - t
      puts "Elapsed for 10 times: #{e}"
      puts "Result: #{res}"
    end
    
    trie = TrieDict.new
    File.read("/home/petr/code/tmp/words.txt").each_line do |word|
      trie.put(word.strip)
    end; :ok
    # Elapsed for 10 times: 0.0006465911865234375
    # Result: ["hello"]
    bench{ trie.fetch "hello", 1 }
    # Elapsed for 10 times: 0.013643741607666016
    # Result: ["cello", "hallo", "helio", "hell", "hello", "hellos", "hells", "hillo", "hollo", "hullo"]
    bench{ trie.fetch "hello", 2 }
    # Elapsed for 10 times: 0.08267641067504883
    # Result: ["bell", "belle", "bellow", "bells", "belly", "cell", "cella", "celli", "cello", "cellos", "cells", "dell", "dells", "delly", "fell", "fella", "felloe", "fellow", "fells", "felly", "hall", "hallo", "halloa", "halloo", "hallos", "hallot", "hallow", "halls", "heal", "heals", "heel", "heels", "heil", "heils", "held", "helio", "helios", "helix", "hell", "helled", "heller", "hello", "helloed", "helloes", "hellos", "hells", "helm", "helms", "helot", "help", "helps", "helve", "herl", "herls", "hill", "hillo", "hilloa", "hillos", "hills", "hilly", "holla", "hollo", "holloa", "holloo", "hollos", "hollow", "holly", "hull", "hullo", "hulloa", "hullos", "hulls", "jell", "jells", "jelly", "mell", "mellow", "mells", "sell", "selle", "sells", "tell", "tells", "telly", "well", "wells", "yell", "yellow", "yells"]
    bench{ trie.fetch "engineer", 2 }
    # Elapsed for 10 times: 0.04654884338378906
    # Result: ["engender", "engine", "engined", "engineer", "engineered", "engineers", "enginery", "engines"]
    bench{ trie.fetch "engeneer", 1 }
    # Elapsed for 10 times: 0.005484580993652344
    # Result: ["engender", "engineer"]
    

    【讨论】:

    • 有趣!比我的实现快。您介意解释一下您的底层数据结构,尤其是这个疯狂的 put 方法的作用吗?
    • 底层数据结构是一种Trie。 Put 方法是将字符放入其父节点的节点中。并且节点数组的第一项(bool)是一个标志,这是一个完整的单词(从顶部到这个节点)
    • 它可以被重写为带有 C 扩展的 gem 以获得更好的性能(我相信 2-5 倍提升)
    • 阅读本文的人注意:该算法的准确性不是很好。如果你尝试:trie.fetch("ello", 1),它不会返回"hello",因为它没有考虑插入和删除成本,只考虑替换。它速度很快,但如果您正在寻找可以在现实生活中出现拼写错误的类似单词,它就不是很有用。
    猜你喜欢
    • 2015-11-27
    • 2014-03-15
    • 1970-01-01
    • 2015-02-07
    • 2014-09-19
    • 2013-01-20
    • 2011-03-01
    • 2013-01-06
    • 2017-11-11
    相关资源
    最近更新 更多