cjk_index 0.2.0 → 1.1.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.
@@ -28,13 +28,25 @@ module CJKIndex
28
28
  # documents, also after later add, remove and update.
29
29
  # prefix: the last Latin word of a query also matches longer words.
30
30
  # typos: Latin words of 5+ letters also match words one edit away.
31
+ #
32
+ # path is an index file, or a split index (its directory or its
33
+ # manifest); the shards of a split index are read as queries need them.
31
34
  def self.load(path, **options)
32
- new(File.read(path, encoding: "UTF-8"), **options)
35
+ path = File.join(path, Builder::MANIFEST) if File.directory?(path)
36
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
37
+ if data.is_a?(Hash) && data["shards"]
38
+ dir = File.dirname(path)
39
+ options = { load_shard: ->(file) { JSON.parse(File.read(File.join(dir, file), encoding: "UTF-8")) } }.merge(options)
40
+ end
41
+ new(data, **options)
33
42
  end
34
43
 
35
- def initialize(index, prefix: true, typos: true, normalizer: nil)
44
+ # load_shard: for the manifest of a split index, a callable that returns
45
+ # a shard (a Hash of token => posting list) given its file name.
46
+ def initialize(index, prefix: true, typos: true, normalizer: nil, load_shard: nil)
36
47
  @prefix = prefix
37
48
  @typos = typos
49
+ @load_shard = load_shard
38
50
  if index.is_a?(Builder)
39
51
  @builder = index
40
52
  data = index.live_data
@@ -45,10 +57,11 @@ module CJKIndex
45
57
  end
46
58
  end
47
59
 
48
- @normalizer = normalizer || Normalizer.new(tables: data["variants"])
49
- if @normalizer.tables != data["variants"]
50
- raise ArgumentError, "cjk_index: index built with variant tables #{data['variants'].inspect} " \
51
- "but this normalizer folds with #{@normalizer.tables.inspect}; rebuild both together"
60
+ @normalizer = normalizer || @builder&.normalizer || Normalizer.new(tables: data["variants"])
61
+ @normalizer.check!(data)
62
+
63
+ if data["shards"] && !@load_shard
64
+ raise ArgumentError, "cjk_index: a split index needs load_shard (or Searcher.load(directory))"
52
65
  end
53
66
 
54
67
  setup(data)
@@ -149,7 +162,7 @@ module CJKIndex
149
162
  docs.each do |doc|
150
163
  value = filter[:docs][doc]
151
164
  if filter[:kind] == "year" then counts[value] += 1 unless value.nil?
152
- else value.each { |key| counts[key] += 1 }
165
+ else value.each { |id| counts[filter[:keys][id]] += 1 }
153
166
  end
154
167
  end
155
168
  counts.sort_by { |key, count| [-count, filter[:rank][key]] }
@@ -169,39 +182,54 @@ module CJKIndex
169
182
  @refs = data["refs"]
170
183
  @lengths = data["lengths"]
171
184
  @tokens = data["tokens"]
185
+ @encoded = data["format"] == Builder::FORMAT
186
+ @decoded = {}
187
+ if data["shards"]
188
+ @shard_first = data["shards"].map(&:first)
189
+ @shard_file = data["shards"].map(&:last)
190
+ @shards = {}
191
+ end
172
192
  @count = data["count"] || @refs.length
173
193
  nf = @fields.length
174
194
  avg = Array.new(nf, 0.0)
175
195
  @lengths.each_with_index { |len, i| avg[i % nf] += len if len }
176
196
  @avg_length = avg.map { |s| (v = s / [1, @count].max).zero? ? 1 : v }
177
- @latin_tokens = @tokens.keys.select { |t| latin?(t) }.sort
197
+ # A split index lists its Latin tokens in a file, read only for typos.
198
+ @latin_file = data["latin"] unless @tokens
199
+ @latin_tokens = @latin_file ? nil : @tokens.keys.select { |t| latin?(t) }.sort
200
+ @latin_set = @latin_tokens&.to_set
178
201
  @slot_of = {}
179
202
  @refs.each_with_index { |ref, doc| @slot_of[ref] ||= doc if ref }
180
203
  @filters = (data["filters"] || {}).to_h { |name, f| [name, setup_filter(f)] }
181
204
  end
182
205
 
183
- # Filter values by document: keys (value filters) or a year, with the
206
+ # Filter values by document: a year, or the ids of values (value
207
+ # filters), with the folded key of each id, the ids of each key, the
184
208
  # form to show for each key and its rank of first appearance.
185
209
  def setup_filter(f)
186
- docs = if f["kind"] == "year" then f["docs"]
187
- elsif f["values"]
188
- keys = f["values"].map { |v| Builder.filter_key(v, @normalizer) }
189
- f["docs"].map { |ids| ids.map { |id| keys[id] } }
190
- else
191
- f["docs"].map { |pairs| pairs&.map(&:first) }
192
- end
193
- display = {}
194
- if f["values"] then f["values"].each { |v| display[Builder.filter_key(v, @normalizer)] ||= v }
195
- elsif f["kind"] == "value" then f["docs"].each { |pairs| pairs&.each { |key, v| display[key] ||= v } }
210
+ if f["kind"] == "year"
211
+ rank = {}
212
+ f["docs"].each_with_index { |year, doc| rank[year] ||= rank.size if @refs[doc] && !year.nil? }
213
+ return { kind: "year", docs: f["docs"], display: rank.keys.to_h { |year| [year, year] }, rank: rank }
196
214
  end
197
- rank = {}
198
- docs.each_with_index do |value, doc|
199
- next unless @refs[doc]
200
215
 
201
- Array(value).each { |key| rank[key] ||= rank.size }
216
+ values = f["values"]
217
+ docs = f["docs"]
218
+ unless values
219
+ id_of = {}
220
+ values = []
221
+ docs = docs.map { |pairs| (pairs || []).map { |key, v| id_of[key] ||= (values << v).size - 1 } }
202
222
  end
203
- display = rank.keys.to_h { |year| [year, year] } if f["kind"] == "year"
204
- { kind: f["kind"], docs: docs, display: display, rank: rank }
223
+ keys = values.map { |v| Builder.filter_key(v, @normalizer) }
224
+ ids = Hash.new { |h, key| h[key] = [] }
225
+ keys.each_with_index { |key, id| ids[key] << id }
226
+ display = {}
227
+ values.each_with_index { |v, id| display[keys[id]] ||= v }
228
+ rank = {}
229
+ docs.each_with_index do |list, doc|
230
+ list.each { |id| rank[keys[id]] ||= rank.size } if @refs[doc]
231
+ end
232
+ { kind: f["kind"], docs: docs, keys: keys, ids: ids, display: display, rank: rank }
205
233
  end
206
234
 
207
235
  def refresh
@@ -222,8 +250,9 @@ module CJKIndex
222
250
  end
223
251
  [f, ->(year) { !year.nil? && range.cover?(year) }]
224
252
  else
225
- keys = Array(condition).map { |v| Builder.filter_key(v, @normalizer) }.to_set
226
- [f, ->(values) { values.any? { |key| keys.include?(key) } }]
253
+ # Compares value ids, not folded strings.
254
+ ids = Array(condition).flat_map { |v| f[:ids].fetch(Builder.filter_key(v, @normalizer), []) }.to_set
255
+ [f, ->(list) { list.any? { |id| ids.include?(id) } }]
227
256
  end
228
257
  end
229
258
  end
@@ -237,7 +266,7 @@ module CJKIndex
237
266
  nf = @fields.length
238
267
  map = {}
239
268
  tokens.each do |token|
240
- postings = @tokens.fetch(token, nil) or next
269
+ postings = postings(token) or next
241
270
  i = 0
242
271
  while i < postings.length
243
272
  count = postings[i + 2]
@@ -248,21 +277,56 @@ module CJKIndex
248
277
  map
249
278
  end
250
279
 
280
+ # Number of the shard that holds token (split index), or -1.
281
+ def shard_index(token) = (@shard_first.bsearch_index { |first| first > token } || @shard_first.length) - 1
282
+
283
+ def shard(i) = @shards[@shard_file[i]] ||= @load_shard.call(@shard_file[i])
284
+
285
+ # The Hash that holds token's posting list if it is in the index, or nil.
286
+ def table(token)
287
+ return @tokens unless @shard_first
288
+
289
+ i = shard_index(token)
290
+ i.negative? ? nil : shard(i)
291
+ end
292
+
293
+ def token?(token) = !!table(token)&.key?(token)
294
+
295
+ # The posting list of a token as [doc, field, n, pos, ...], or nil.
296
+ def postings(token)
297
+ list = table(token)&.[](token)
298
+ return list unless list && @encoded
299
+
300
+ @decoded[token] ||= Postings.decode(list, @fields.length)
301
+ end
302
+
303
+ # Index words that start with token, token itself left out.
304
+ def with_prefix(token)
305
+ unless @shard_first
306
+ from = @latin_tokens.bsearch_index { |k| k >= token } || @latin_tokens.length
307
+ return @latin_tokens[from..].take_while { |k| k.start_with?(token) }.reject { |k| k == token }
308
+ end
309
+
310
+ # In token's shard and the next ones that start with it (tokens are sorted).
311
+ first = [shard_index(token), 0].max
312
+ last = first
313
+ last += 1 while last + 1 < @shard_first.length && @shard_first[last + 1].start_with?(token)
314
+ (first..last).flat_map { |i| shard(i).keys.select { |k| k != token && k.start_with?(token) } }
315
+ end
316
+
317
+ def latin_tokens
318
+ @latin_tokens ||= @load_shard.call(@latin_file).sort
319
+ end
320
+
251
321
  # Alternatives for one query token: itself, and for Latin words the index
252
322
  # words it is a prefix of (last word only) or one edit away from.
253
323
  def alternatives(token, last)
254
324
  return [[token], false] unless latin?(token)
255
325
 
256
- out = @tokens.key?(token) ? [token] : []
257
- if @prefix && last
258
- from = @latin_tokens.bsearch_index { |k| k >= token } || @latin_tokens.length
259
- @latin_tokens[from..].each do |k|
260
- break unless k.start_with?(token)
261
-
262
- out << k unless k == token
263
- end
264
- end
265
- out.concat(@latin_tokens.select { |k| one_edit?(token, k) }) if @typos && !@tokens.key?(token) && token.length >= 5
326
+ has = @shard_first ? token?(token) : @latin_set.include?(token)
327
+ out = has ? [token] : []
328
+ out.concat(with_prefix(token)) if @prefix && last
329
+ out.concat(latin_tokens.select { |k| one_edit?(token, k) }) if @typos && !has && token.length >= 5
266
330
  [out, !out.empty? && out.first != token]
267
331
  end
268
332
 
@@ -68,6 +68,36 @@ module CJKIndex
68
68
  phrases(text, unigrams: unigrams, normalizer: normalizer).flatten(1)
69
69
  end
70
70
 
71
+ # A run of separators, of CJK characters, or of other characters. A
72
+ # separator is never part of a run (・ is in the katakana block).
73
+ CJK_WORD = /[#{CJK.source[1..-2]}&&[^#{SEPARATOR.source[1..-2]}]]/
74
+ RUN = /(#{SEPARATOR.source}+)|(#{CJK_WORD.source}+)|((?:(?!#{SEPARATOR.source})(?!#{CJK.source}).)+)/m
75
+
76
+ # Yields each token of already normalized text with its offset: the
77
+ # tokens of tokens_with_offsets, without building the phrases. Used to
78
+ # build the index, where it is several times faster.
79
+ def each_token(normalized, unigrams: false)
80
+ offset = 0
81
+ normalized.scan(RUN) do |_, cjk, other|
82
+ run = Regexp.last_match(0)
83
+ if cjk
84
+ chars = run.chars
85
+ if chars.length == 1 || unigrams
86
+ chars.each_with_index { |c, i| yield c, offset + i }
87
+ end
88
+ i = 0
89
+ last = chars.length - 1
90
+ while i < last
91
+ yield chars[i] + chars[i + 1], offset + i
92
+ i += 1
93
+ end
94
+ elsif other
95
+ yield run, offset
96
+ end
97
+ offset += run.length
98
+ end
99
+ end
100
+
71
101
  # Splits at separators (spaces, punctuation) into phrases, each a list of
72
102
  # [token, offset]. A query matches when every phrase occurs somewhere in
73
103
  # the document, with the tokens of each phrase at the same relative offsets.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CJKIndex
4
- VERSION = "0.2.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/cjk_index.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "cjk_index/version"
4
4
  require_relative "cjk_index/normalizer"
5
5
  require_relative "cjk_index/tokenizer"
6
+ require_relative "cjk_index/postings"
6
7
  require_relative "cjk_index/builder"
7
8
  require_relative "cjk_index/runtime"
8
9
  require_relative "cjk_index/searcher"
metadata CHANGED
@@ -1,33 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cjk_index
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Satoru Nakamura
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: Builds a bigram search index in Ruby (with kana, old/new kanji and NFKC
13
13
  normalization), and queries it in Ruby or with a small browser runtime that returns
14
- the same results. Includes a Jekyll plugin and a CollectionBuilder preset.
15
- executables: []
14
+ the same results. Includes a command-line tool, a Rake task, a Jekyll plugin (with
15
+ a CollectionBuilder preset) and a Bridgetown plugin.
16
+ executables:
17
+ - cjk_index
16
18
  extensions: []
17
19
  extra_rdoc_files: []
18
20
  files:
19
21
  - CHANGELOG.md
20
22
  - LICENSE
21
23
  - LICENSE-OpenCC
24
+ - LICENSE-Unicode
22
25
  - NOTICE
23
26
  - README.md
24
27
  - data/ja-kyujitai.tsv
28
+ - data/ko-hanyang-pua.tsv
29
+ - data/zh-hant-hans-safe.tsv
25
30
  - data/zh-hant-hans.tsv
31
+ - exe/cjk_index
26
32
  - lib/cjk_index.rb
33
+ - lib/cjk_index/bridgetown.rb
27
34
  - lib/cjk_index/builder.rb
35
+ - lib/cjk_index/cli.rb
28
36
  - lib/cjk_index/index_file.rb
29
37
  - lib/cjk_index/jekyll.rb
30
38
  - lib/cjk_index/normalizer.rb
39
+ - lib/cjk_index/postings.rb
40
+ - lib/cjk_index/rake_task.rb
31
41
  - lib/cjk_index/runtime.js
32
42
  - lib/cjk_index/runtime.rb
33
43
  - lib/cjk_index/searcher.rb
@@ -37,6 +47,8 @@ homepage: https://github.com/nakamura196/cjk_index
37
47
  licenses:
38
48
  - MIT
39
49
  - Apache-2.0
50
+ - Unicode-3.0
51
+ - Unlicense
40
52
  metadata:
41
53
  source_code_uri: https://github.com/nakamura196/cjk_index
42
54
  documentation_uri: https://github.com/nakamura196/cjk_index#readme