mongoid_fulltext 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +47 -0
  3. data/.rspec +1 -1
  4. data/.rubocop.yml +6 -0
  5. data/.rubocop_todo.yml +101 -0
  6. data/.travis.yml +11 -3
  7. data/CHANGELOG.md +9 -2
  8. data/Gemfile +19 -9
  9. data/LICENSE +1 -1
  10. data/README.md +12 -9
  11. data/Rakefile +9 -29
  12. data/lib/mongoid/full_text_search/version.rb +5 -0
  13. data/lib/mongoid/full_text_search.rb +372 -0
  14. data/lib/mongoid/indexable.rb +13 -0
  15. data/lib/mongoid/indexes.rb +13 -0
  16. data/lib/mongoid_fulltext.rb +1 -341
  17. data/mongoid_fulltext.gemspec +16 -82
  18. data/spec/models/accentless_artwork.rb +1 -1
  19. data/spec/models/advanced_artwork.rb +1 -1
  20. data/spec/models/basic_artwork.rb +0 -1
  21. data/spec/models/delayed_artwork.rb +1 -2
  22. data/spec/models/external_artist.rb +1 -2
  23. data/spec/models/external_artwork.rb +1 -2
  24. data/spec/models/external_artwork_no_fields_supplied.rb +2 -2
  25. data/spec/models/filtered_artist.rb +4 -4
  26. data/spec/models/filtered_artwork.rb +7 -7
  27. data/spec/models/filtered_other.rb +3 -3
  28. data/spec/models/hidden_dragon.rb +0 -1
  29. data/spec/models/multi_external_artwork.rb +3 -3
  30. data/spec/models/multi_field_artist.rb +1 -1
  31. data/spec/models/multi_field_artwork.rb +1 -1
  32. data/spec/models/partitioned_artist.rb +8 -9
  33. data/spec/models/russian_artwork.rb +2 -2
  34. data/spec/models/short_prefixes_artwork.rb +3 -4
  35. data/spec/models/stopwords_artwork.rb +3 -4
  36. data/spec/mongoid/full_text_search_spec.rb +752 -0
  37. data/spec/spec_helper.rb +11 -7
  38. metadata +27 -68
  39. data/VERSION +0 -1
  40. data/lib/mongoid_indexes.rb +0 -12
  41. data/spec/config/mongoid.yml +0 -6
  42. data/spec/mongoid/fulltext_spec.rb +0 -799
@@ -0,0 +1,13 @@
1
+ # hook onto model index creation to create related FT indexes
2
+ module Mongoid
3
+ module Indexable
4
+ module ClassMethods
5
+ alias_method :create_fulltext_indexes_hook, :create_indexes
6
+
7
+ def create_indexes
8
+ create_fulltext_indexes if respond_to?(:create_fulltext_indexes)
9
+ create_fulltext_indexes_hook
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # hook onto model index creation to create related FT indexes
2
+ module Mongoid
3
+ module Indexes
4
+ module ClassMethods
5
+ alias_method :create_fulltext_indexes_hook, :create_indexes
6
+
7
+ def create_indexes
8
+ create_fulltext_indexes if respond_to?(:create_fulltext_indexes)
9
+ create_fulltext_indexes_hook
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,341 +1 @@
1
- require 'mongoid_indexes'
2
- require 'unicode_utils'
3
- require 'cgi'
4
-
5
- module Mongoid::FullTextSearch
6
- extend ActiveSupport::Concern
7
-
8
- included do
9
- cattr_accessor :mongoid_fulltext_config
10
- end
11
-
12
- class UnspecifiedIndexError < StandardError; end
13
- class UnknownFilterQueryOperator < StandardError; end
14
-
15
- module ClassMethods
16
-
17
- def fulltext_search_in(*args)
18
- self.mongoid_fulltext_config = {} if self.mongoid_fulltext_config.nil?
19
- options = args.last.is_a?(Hash) ? args.pop : {}
20
- if options.has_key?(:index_name)
21
- index_name = options[:index_name]
22
- else
23
- index_name = 'mongoid_fulltext.index_%s_%s' % [self.name.downcase, self.mongoid_fulltext_config.count]
24
- end
25
-
26
- config = {
27
- :alphabet => 'abcdefghijklmnopqrstuvwxyz0123456789 ',
28
- :word_separators => "-_ \n\t",
29
- :ngram_width => 3,
30
- :max_ngrams_to_search => 6,
31
- :apply_prefix_scoring_to_all_words => true,
32
- :index_full_words => true,
33
- :index_short_prefixes => false,
34
- :max_candidate_set_size => 1000,
35
- :remove_accents => true,
36
- :reindex_immediately => true,
37
- :stop_words => Hash[['i', 'a', 's', 't', 'me', 'my', 'we', 'he', 'it', 'am', 'is', 'be', 'do', 'an', 'if',
38
- 'or', 'as', 'of', 'at', 'by', 'to', 'up', 'in', 'on', 'no', 'so', 'our', 'you', 'him',
39
- 'his', 'she', 'her', 'its', 'who', 'are', 'was', 'has', 'had', 'did', 'the', 'and',
40
- 'but', 'for', 'out', 'off', 'why', 'how', 'all', 'any', 'few', 'nor', 'not', 'own',
41
- 'too', 'can', 'don', 'now', 'ours', 'your', 'hers', 'they', 'them', 'what', 'whom',
42
- 'this', 'that', 'were', 'been', 'have', 'does', 'with', 'into', 'from', 'down', 'over',
43
- 'then', 'once', 'here', 'when', 'both', 'each', 'more', 'most', 'some', 'such', 'only',
44
- 'same', 'than', 'very', 'will', 'just', 'yours', 'their', 'which', 'these', 'those',
45
- 'being', 'doing', 'until', 'while', 'about', 'after', 'above', 'below', 'under',
46
- 'again', 'there', 'where', 'other', 'myself', 'itself', 'theirs', 'having', 'during',
47
- 'before', 'should', 'himself', 'herself', 'because', 'against', 'between', 'through',
48
- 'further', 'yourself', 'ourselves', 'yourselves', 'themselves'].map{ |x| [x,true] }]
49
- }
50
-
51
- config.update(options)
52
-
53
- args = [:to_s] if args.empty?
54
- config[:ngram_fields] = args
55
- config[:alphabet] = Hash[config[:alphabet].split('').map{ |ch| [ch,ch] }]
56
- config[:word_separators] = Hash[config[:word_separators].split('').map{ |ch| [ch,ch] }]
57
- self.mongoid_fulltext_config[index_name] = config
58
-
59
- before_save(:update_ngram_index) if config[:reindex_immediately]
60
- before_destroy :remove_from_ngram_index
61
- end
62
-
63
- def create_fulltext_indexes
64
- return unless self.mongoid_fulltext_config
65
- self.mongoid_fulltext_config.each_pair do |index_name, fulltext_config|
66
- fulltext_search_ensure_indexes(index_name, fulltext_config)
67
- end
68
- end
69
-
70
- def fulltext_search_ensure_indexes(index_name, config)
71
- db = collection.database
72
- coll = db[index_name]
73
-
74
- # The order of filters matters when the same index is used from two or more collections.
75
- filter_indexes = (config[:filters] || []).map do |key,value|
76
- ["filter_values.#{key}", 1]
77
- end.sort_by { |filter_index| filter_index[0] }
78
-
79
- index_definition = [['ngram', 1], ['score', -1]].concat(filter_indexes)
80
-
81
- # Since the definition of the index could have changed, we'll clean up by
82
- # removing any indexes that aren't on the exact.
83
- correct_keys = index_definition.map{ |field_def| field_def[0] }
84
- all_filter_keys = filter_indexes.map{ |field_def| field_def[0] }
85
- coll.indexes.each do |idef|
86
- keys = idef['key'].keys
87
- next if !keys.member?('ngram')
88
- all_filter_keys |= keys.find_all{ |key| key.starts_with?('filter_values.') }
89
- if keys & correct_keys != correct_keys
90
- Mongoid.logger.info "Dropping #{idef['name']} [#{keys & correct_keys} <=> #{correct_keys}]" if Mongoid.logger
91
- coll.indexes.drop(idef['key'])
92
- end
93
- end
94
-
95
- if all_filter_keys.length > filter_indexes.length
96
- filter_indexes = all_filter_keys.map {|key| [key, 1] }.sort_by { |filter_index| filter_index[0] }
97
- index_definition = [['ngram', 1], ['score', -1]].concat(filter_indexes)
98
- end
99
-
100
- Mongoid.logger.info "Ensuring fts_index on #{coll.name}: #{index_definition}" if Mongoid.logger
101
- coll.indexes.create(Hash[index_definition], { :name => 'fts_index' })
102
-
103
- Mongoid.logger.info "Ensuring document_id index on #{coll.name}" if Mongoid.logger
104
- coll.indexes.create('document_id' => 1) # to make removes fast
105
- end
106
-
107
- def fulltext_search(query_string, options={})
108
- max_results = options.has_key?(:max_results) ? options.delete(:max_results) : 10
109
- return_scores = options.has_key?(:return_scores) ? options.delete(:return_scores) : false
110
- if self.mongoid_fulltext_config.count > 1 and !options.has_key?(:index)
111
- error_message = '%s is indexed by multiple full-text indexes. You must specify one by passing an :index_name parameter'
112
- raise UnspecifiedIndexError, error_message % self.name, caller
113
- end
114
- index_name = options.has_key?(:index) ? options.delete(:index) : self.mongoid_fulltext_config.keys.first
115
-
116
- # Options hash should only contain filters after this point
117
-
118
- ngrams = all_ngrams(query_string, self.mongoid_fulltext_config[index_name])
119
- return [] if ngrams.empty?
120
-
121
- # For each ngram, construct the query we'll use to pull index documents and
122
- # get a count of the number of index documents containing that n-gram
123
- ordering = {'score' => -1}
124
- limit = self.mongoid_fulltext_config[index_name][:max_candidate_set_size]
125
- coll = collection.database[index_name]
126
- cursors = ngrams.map do |ngram|
127
- query = {'ngram' => ngram[0]}
128
- query.update(map_query_filters options)
129
- count = coll.find(query).count
130
- {:ngram => ngram, :count => count, :query => query}
131
- end.sort!{ |record1, record2| record1[:count] <=> record2[:count] }
132
-
133
- # Using the queries we just constructed and the n-gram frequency counts we
134
- # just computed, pull in about *:max_candidate_set_size* candidates by
135
- # considering the n-grams in order of increasing frequency. When we've
136
- # spent all *:max_candidate_set_size* candidates, pull the top-scoring
137
- # *max_results* candidates for each remaining n-gram.
138
- results_so_far = 0
139
- candidates_list = cursors.map do |doc|
140
- next if doc[:count] == 0
141
- query_result = coll.find(doc[:query])
142
- if results_so_far >= limit
143
- query_result = query_result.sort(ordering).limit(max_results)
144
- elsif doc[:count] > limit - results_so_far
145
- query_result = query_result.sort(ordering).limit(limit - results_so_far)
146
- end
147
- results_so_far += doc[:count]
148
- ngram_score = ngrams[doc[:ngram][0]]
149
- Hash[query_result.map do |candidate|
150
- [candidate['document_id'],
151
- {:clazz => candidate['class'], :score => candidate['score'] * ngram_score}]
152
- end]
153
- end.compact
154
-
155
- # Finally, score all candidates by matching them up with other candidates that are
156
- # associated with the same document. This is similar to how you might process a
157
- # boolean AND query, except that with an AND query, you'd stop after considering
158
- # the first candidate list and matching its candidates up with candidates from other
159
- # lists, whereas here we want the search to be a little fuzzier so we'll run through
160
- # all candidate lists, removing candidates as we match them up.
161
- all_scores = []
162
- while !candidates_list.empty?
163
- candidates = candidates_list.pop
164
- scores = candidates.map do |candidate_id, data|
165
- {:id => candidate_id,
166
- :clazz => data[:clazz],
167
- :score => data[:score] + candidates_list.map{ |others| (others.delete(candidate_id) || {:score => 0})[:score] }.sum
168
- }
169
- end
170
- all_scores.concat(scores)
171
- end
172
- all_scores.sort!{ |document1, document2| -document1[:score] <=> -document2[:score] }
173
- instantiate_mapreduce_results(all_scores[0..max_results-1], { :return_scores => return_scores })
174
- end
175
-
176
- def instantiate_mapreduce_result(result)
177
- result[:clazz].constantize.find(result[:id])
178
- end
179
-
180
- def instantiate_mapreduce_results(results, options)
181
- if (options[:return_scores])
182
- results.map { |result| [ instantiate_mapreduce_result(result), result[:score] ] }.find_all { |result| ! result[0].nil? }
183
- else
184
- results.map { |result| instantiate_mapreduce_result(result) }.compact
185
- end
186
- end
187
-
188
- def all_ngrams(str, config, bound_number_returned = true)
189
- return {} if str.nil?
190
-
191
- if config[:remove_accents]
192
- if defined?(UnicodeUtils)
193
- str = UnicodeUtils.nfkd(str)
194
- elsif defined?(DiacriticsFu)
195
- str = DiacriticsFu::escape(str)
196
- end
197
- end
198
-
199
- # Remove any characters that aren't in the alphabet and aren't word separators
200
- filtered_str = str.mb_chars.downcase.to_s.split('').find_all{ |ch| config[:alphabet][ch] or config[:word_separators][ch] }.join('')
201
-
202
- # Figure out how many ngrams to extract from the string. If we can't afford to extract all ngrams,
203
- # step over the string in evenly spaced strides to extract ngrams. For example, to extract 3 3-letter
204
- # ngrams from 'abcdefghijk', we'd want to extract 'abc', 'efg', and 'ijk'.
205
- if bound_number_returned
206
- step_size = [((filtered_str.length - config[:ngram_width]).to_f / config[:max_ngrams_to_search]).ceil, 1].max
207
- else
208
- step_size = 1
209
- end
210
-
211
- # Create an array of records of the form {:ngram => x, :score => y} for all ngrams that occur in the
212
- # input string using the step size that we just computed. Let score(x,y) be the score of string x
213
- # compared with string y - assigning scores to ngrams with the square root-based scoring function
214
- # below and multiplying scores of matching ngrams together yields a score function that has the
215
- # property that score(x,y) > score(x,z) for any string z containing y and score(x,y) > score(x,z)
216
- # for any string z contained in y.
217
- ngram_array = (0..filtered_str.length - config[:ngram_width]).step(step_size).map do |i|
218
- if i == 0 or (config[:apply_prefix_scoring_to_all_words] and \
219
- config[:word_separators].has_key?(filtered_str[i-1].chr))
220
- score = Math.sqrt(1 + 1.0/filtered_str.length)
221
- else
222
- score = Math.sqrt(2.0/filtered_str.length)
223
- end
224
- {:ngram => filtered_str[i..i+config[:ngram_width]-1], :score => score}
225
- end
226
-
227
- # If an ngram appears multiple times in the query string, keep the max score
228
- ngram_array = ngram_array.group_by{ |h| h[:ngram] }.map{ |key, values| {:ngram => key, :score => values.map{ |v| v[:score] }.max} }
229
-
230
- if config[:index_short_prefixes] or config[:index_full_words]
231
- split_regex_def = config[:word_separators].keys.map{ |k| Regexp.escape(k) }.join
232
- split_regex = Regexp.compile("[#{split_regex_def}]")
233
- all_words = filtered_str.split(split_regex)
234
- end
235
-
236
- # Add 'short prefix' records to the array: prefixes of the string that are length (ngram_width - 1)
237
- if config[:index_short_prefixes]
238
- prefixes_seen = {}
239
- all_words.each do |word|
240
- next if word.length < config[:ngram_width]-1
241
- prefix = word[0...config[:ngram_width]-1]
242
- if prefixes_seen[prefix].nil? and (config[:stop_words][word].nil? or word == filtered_str)
243
- ngram_array << {:ngram => prefix, :score => 1 + 1.0/filtered_str.length}
244
- prefixes_seen[prefix] = true
245
- end
246
- end
247
- end
248
-
249
- # Add records to the array of ngrams for each full word in the string that isn't a stop word
250
- if config[:index_full_words]
251
- full_words_seen = {}
252
- all_words.each do |word|
253
- if word.length > 1 and full_words_seen[word].nil? and (config[:stop_words][word].nil? or word == filtered_str)
254
- ngram_array << {:ngram => word, :score => 1 + 1.0/filtered_str.length}
255
- full_words_seen[word] = true
256
- end
257
- end
258
- end
259
-
260
- # If an ngram appears as any combination of full word, short prefix, and ngram, keep the sum of the two scores
261
- Hash[ngram_array.group_by{ |h| h[:ngram] }.map{ |key, values| [key, values.map{ |v| v[:score] }.sum] }]
262
- end
263
-
264
- def remove_from_ngram_index
265
- self.mongoid_fulltext_config.each_pair do |index_name, fulltext_config|
266
- coll = collection.database[index_name]
267
- coll.find({'class' => self.name}).remove_all
268
- end
269
- end
270
-
271
- def update_ngram_index
272
- self.all.each do |model|
273
- model.update_ngram_index
274
- end
275
- end
276
-
277
- private
278
- # Take a list of filters to be mapped so they can update the query
279
- # used upon the fulltext search of the ngrams
280
- def map_query_filters filters
281
- Hash[filters.map {|key,value|
282
- case value
283
- when Hash then
284
- if value.has_key? :any then format_query_filter('$in',key,value[:any])
285
- elsif value.has_key? :all then format_query_filter('$all',key,value[:all])
286
- else raise UnknownFilterQueryOperator, value.keys.join(","), caller end
287
- else format_query_filter('$all',key,value)
288
- end
289
- }]
290
- end
291
- def format_query_filter operator, key, value
292
- ['filter_values.%s' % key, {operator => [value].flatten}]
293
- end
294
- end
295
-
296
- def update_ngram_index
297
- self.mongoid_fulltext_config.each_pair do |index_name, fulltext_config|
298
- if condition = fulltext_config[:update_if]
299
- case condition
300
- when Symbol; next unless self.send condition
301
- when String; next unless instance_eval condition
302
- when Proc; next unless condition.call self
303
- else; next
304
- end
305
- end
306
-
307
- # remove existing ngrams from external index
308
- coll = collection.database[index_name.to_sym]
309
- coll.find({'document_id' => self._id}).remove_all
310
- # extract ngrams from fields
311
- field_values = fulltext_config[:ngram_fields].map { |field| self.send(field) }
312
- ngrams = field_values.inject({}) { |accum, item| accum.update(self.class.all_ngrams(item, fulltext_config, false))}
313
- return if ngrams.empty?
314
- # apply filters, if necessary
315
- filter_values = nil
316
- if fulltext_config.has_key?(:filters)
317
- filter_values = Hash[fulltext_config[:filters].map do |key,value|
318
- begin
319
- [key, value.call(self)]
320
- rescue
321
- # Suppress any exceptions caused by filters
322
- end
323
- end.compact]
324
- end
325
- # insert new ngrams in external index
326
- ngrams.each_pair do |ngram, score|
327
- index_document = {'ngram' => ngram, 'document_id' => self._id, 'score' => score, 'class' => self.class.name}
328
- index_document['filter_values'] = filter_values if fulltext_config.has_key?(:filters)
329
- coll.insert(index_document)
330
- end
331
- end
332
- end
333
-
334
- def remove_from_ngram_index
335
- self.mongoid_fulltext_config.each_pair do |index_name, fulltext_config|
336
- coll = collection.database[index_name]
337
- coll.find({'document_id' => self._id}).remove_all
338
- end
339
- end
340
-
341
- end
1
+ require 'mongoid/full_text_search'
@@ -1,85 +1,19 @@
1
- # Generated by jeweler
2
- # DO NOT EDIT THIS FILE DIRECTLY
3
- # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
- # -*- encoding: utf-8 -*-
1
+ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
2
+ require 'mongoid/full_text_search/version'
5
3
 
6
4
  Gem::Specification.new do |s|
7
- s.name = "mongoid_fulltext"
8
- s.version = "0.6.1"
9
-
10
- s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
- s.authors = ["Aaron Windsor"]
12
- s.date = "2013-04-03"
13
- s.description = "Full-text search for the Mongoid ORM, using n-grams extracted from text"
14
- s.email = "aaron.windsor@gmail.com"
15
- s.extra_rdoc_files = [
16
- "LICENSE",
17
- "README.md"
18
- ]
19
- s.files = [
20
- ".document",
21
- ".rspec",
22
- ".travis.yml",
23
- "CHANGELOG.md",
24
- "Gemfile",
25
- "LICENSE",
26
- "README.md",
27
- "Rakefile",
28
- "VERSION",
29
- "lib/mongoid_fulltext.rb",
30
- "lib/mongoid_indexes.rb",
31
- "mongoid_fulltext.gemspec",
32
- "spec/config/mongoid.yml",
33
- "spec/models/accentless_artwork.rb",
34
- "spec/models/advanced_artwork.rb",
35
- "spec/models/basic_artwork.rb",
36
- "spec/models/delayed_artwork.rb",
37
- "spec/models/external_artist.rb",
38
- "spec/models/external_artwork.rb",
39
- "spec/models/external_artwork_no_fields_supplied.rb",
40
- "spec/models/filtered_artist.rb",
41
- "spec/models/filtered_artwork.rb",
42
- "spec/models/filtered_other.rb",
43
- "spec/models/gallery/basic_artwork.rb",
44
- "spec/models/hidden_dragon.rb",
45
- "spec/models/multi_external_artwork.rb",
46
- "spec/models/multi_field_artist.rb",
47
- "spec/models/multi_field_artwork.rb",
48
- "spec/models/partitioned_artist.rb",
49
- "spec/models/russian_artwork.rb",
50
- "spec/models/short_prefixes_artwork.rb",
51
- "spec/models/stopwords_artwork.rb",
52
- "spec/mongoid/fulltext_spec.rb",
53
- "spec/spec_helper.rb"
54
- ]
55
- s.homepage = "http://github.com/aaw/mongoid_fulltext"
56
- s.licenses = ["MIT"]
57
- s.require_paths = ["lib"]
58
- s.rubygems_version = "1.8.25"
59
- s.summary = "Full-text search for the Mongoid ORM"
60
-
61
- if s.respond_to? :specification_version then
62
- s.specification_version = 3
63
-
64
- if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
65
- s.add_runtime_dependency(%q<mongoid>, ["~> 3.0"])
66
- s.add_runtime_dependency(%q<unicode_utils>, ["~> 1.0.0"])
67
- s.add_development_dependency(%q<bundler>, [">= 0"])
68
- s.add_development_dependency(%q<rspec>, ["~> 2.10.0"])
69
- s.add_development_dependency(%q<jeweler>, ["~> 1.8.3"])
70
- else
71
- s.add_dependency(%q<mongoid>, ["~> 3.0"])
72
- s.add_dependency(%q<unicode_utils>, ["~> 1.0.0"])
73
- s.add_dependency(%q<bundler>, [">= 0"])
74
- s.add_dependency(%q<rspec>, ["~> 2.10.0"])
75
- s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
76
- end
77
- else
78
- s.add_dependency(%q<mongoid>, ["~> 3.0"])
79
- s.add_dependency(%q<unicode_utils>, ["~> 1.0.0"])
80
- s.add_dependency(%q<bundler>, [">= 0"])
81
- s.add_dependency(%q<rspec>, ["~> 2.10.0"])
82
- s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
83
- end
5
+ s.name = 'mongoid_fulltext'
6
+ s.version = Mongoid::FullTextSearch::VERSION
7
+ s.authors = ['Aaron Windsor']
8
+ s.email = 'aaron.windsor@gmail.com'
9
+ s.platform = Gem::Platform::RUBY
10
+ s.required_rubygems_version = '>= 1.3.6'
11
+ s.files = `git ls-files`.split("\n")
12
+ s.require_paths = ['lib']
13
+ s.homepage = 'https://github.com/artsy/mongoid_fulltext'
14
+ s.licenses = ['MIT']
15
+ s.summary = 'Full-text search for the Mongoid ORM, using n-grams extracted from text.'
16
+ s.add_dependency 'mongoid', '>= 3.0'
17
+ s.add_dependency 'mongoid-compatibility'
18
+ s.add_dependency 'unicode_utils'
84
19
  end
85
-