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.
data/exe/cjk_index ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "cjk_index/cli"
5
+
6
+ exit CJKIndex::CLI.run(ARGV)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "bridgetown-core"
5
+ require_relative "../cjk_index"
6
+
7
+ module CJKIndex
8
+ # Bridgetown plugin. Add `gem "cjk_index"` to the Gemfile, then in
9
+ # config/initializers.rb:
10
+ #
11
+ # Bridgetown.configure do |config|
12
+ # init :cjk_index,
13
+ # output: "cjk-index", # folder inside the output (default)
14
+ # variants: ["ja"], # variant tables: ja (default), zh-safe, zh, ko, or a file
15
+ # indexes: [
16
+ # { name: "posts", # -> output/cjk-index/posts.json
17
+ # collection: "posts", # a collection (or a list of them), or...
18
+ # fields: ["title", "tags", "content"],
19
+ # boosts: { title: 3 },
20
+ # filters: { category: "value", date: "year" } },
21
+ # { name: "items", data: "metadata", ref: "id", fields: ["title"] } # ...rows of src/_data/metadata.csv
22
+ # ]
23
+ # end
24
+ #
25
+ # After each build the index files and the browser script (cjk-index.js)
26
+ # are written to the output folder. A resource is indexed by its URL; the
27
+ # field "content" is the source text of the resource (Markdown, before
28
+ # rendering), any other field is read from its front matter.
29
+ module Bridgetown
30
+ DEFAULT_OUTPUT = "cjk-index"
31
+ RUNTIME_NAME = "cjk-index.js"
32
+
33
+ module_function
34
+
35
+ # Writes the browser script and every index of the config (a Hash with
36
+ # symbol or string keys) into site's destination folder.
37
+ def write(site, config)
38
+ config = stringify(config)
39
+ output = config.fetch("output", DEFAULT_OUTPUT).to_s.gsub(%r{\A/+|/+\z}, "")
40
+ normalizer = Normalizer.new(tables: Array(config.fetch("variants", Normalizer::DEFAULT_TABLES)), base: site.root_dir)
41
+ put(site, output, RUNTIME_NAME, Runtime.source(normalizer: normalizer))
42
+ Array(config["indexes"]).each do |spec|
43
+ builder = build(site, stringify(spec), normalizer)
44
+ put(site, output, "#{spec['name'] || spec[:name]}.json", builder.to_json)
45
+ ::Bridgetown.logger.info "cjk_index:", "#{output}/#{spec['name'] || spec[:name]}.json (#{builder.size} documents)"
46
+ end
47
+ end
48
+
49
+ def build(site, spec, normalizer)
50
+ builder = Builder.new(fields: Array(spec.fetch("fields")), boosts: spec.fetch("boosts", {}),
51
+ normalizer: normalizer, filters: spec.fetch("filters", {}))
52
+ if spec["collection"]
53
+ Array(spec["collection"]).each do |name|
54
+ collection = site.collections[name] or raise ArgumentError, "cjk_index: no collection #{name}"
55
+ collection.resources.each do |resource|
56
+ next unless resource.write?
57
+
58
+ builder.add(resource.relative_url, resource_fields(resource, builder.fields | builder.filters.keys))
59
+ end
60
+ end
61
+ elsif spec["data"]
62
+ ref = spec.fetch("ref", "id")
63
+ Array(site.data[spec["data"]]).each do |row|
64
+ row = row.to_h.transform_keys(&:to_s)
65
+ builder.add(row[ref], row) unless row[ref].to_s.empty?
66
+ end
67
+ else
68
+ raise ArgumentError, "cjk_index: index #{spec['name']} needs collection or data"
69
+ end
70
+ builder
71
+ end
72
+
73
+ def resource_fields(resource, fields)
74
+ fields.to_h { |f| [f, f == "content" ? resource.untransformed_content : plain(resource.data[f])] }
75
+ end
76
+
77
+ # A Time or Date as "2026-10-01": Builder applies Array() to values, which
78
+ # would turn a Time into its parts [sec, min, hour, ...].
79
+ def plain(value)
80
+ value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d") : value
81
+ end
82
+
83
+ def stringify(hash) = hash.to_h { |k, v| [k.to_s, v] }
84
+
85
+ # Skips the write when the file already has this content.
86
+ def put(site, dir, name, content)
87
+ path = site.in_dest_dir(dir, name)
88
+ return if File.exist?(path) && File.read(path, encoding: "UTF-8") == content
89
+
90
+ FileUtils.mkdir_p(File.dirname(path))
91
+ File.write(path, content)
92
+ end
93
+ end
94
+ end
95
+
96
+ Bridgetown.initializer :cjk_index do |_config, **options|
97
+ Bridgetown::Hooks.register_one :site, :post_write, reloadable: false do |site|
98
+ CJKIndex::Bridgetown.write(site, options)
99
+ end
100
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "digest"
5
+ require "fileutils"
4
6
 
5
7
  module CJKIndex
6
8
  # Builds a prebuilt inverted index that the browser runtime loads as JSON.
@@ -8,37 +10,67 @@ module CJKIndex
8
10
  # builder = CJKIndex::Builder.new(fields: %w[title creator], boosts: { "title" => 3 },
9
11
  # filters: { "type" => "value", "date" => "year" })
10
12
  # builder.add("item1.html", "title" => "東京帝國大學", "creator" => "…", "type" => "地図", "date" => "1900")
11
- # File.write("search-index.json", builder.to_json)
13
+ # builder.write("search-index.json") # one file (the same as File.write(path, builder.to_json))
14
+ # builder.write_split("search-index") # or a manifest and shards, loaded as a query needs them
12
15
  #
13
- # Format (version 3):
16
+ # Format (version 4), one file:
14
17
  # {
15
- # "format": "cjk_index/3",
18
+ # "format": "cjk_index/4",
16
19
  # "variants": ["ja"],
17
20
  # "fields": ["title", "creator"],
18
21
  # "boosts": [3, 1],
19
22
  # "refs": ["item1.html", ...],
20
23
  # "lengths": [len(doc0.title), len(doc0.creator), len(doc1.title), ...],
21
- # "tokens": { "東京": [doc, field, n, pos1, ..., posn, doc, field, n, ...], ... },
24
+ # "tokens": { "東京": "#$#'+…", ... },
22
25
  # "filters": {
23
26
  # "type": { "kind": "value", "values": ["地図", ...], "docs": [[0], [], ...] },
24
27
  # "date": { "kind": "year", "docs": [1900, null, ...] }
25
28
  # }
26
29
  # }
27
- # doc and field are positions in "refs" and "fields"; n is the number of
28
- # occurrences and pos are character offsets in the normalized field text.
30
+ # Each token maps to its posting list: for each document and field where it
31
+ # occurs, the number of occurrences and their character offsets in the
32
+ # normalized field text, written compactly as a string (see Postings).
29
33
  # Offsets let the runtime require the bigrams of a query to be adjacent
30
34
  # (exact substring match); lengths feed BM25 ranking.
31
35
  # "filters" is present only when filters were configured. A value filter
32
36
  # stores each distinct value once (by its folded form, as first written)
33
37
  # and lists the values of each document by number; a year filter stores one
34
- # year or null per document. Version 2 is the same without "filters".
38
+ # year or null per document.
39
+ #
40
+ # Split (write_split): a directory with the manifest index.json, which is
41
+ # the same without "tokens" and adds
42
+ # "latin": "9c1d…07.json", file of the Latin tokens
43
+ # "shards": [["一", "3f2a…e1.json"], ...] first token and file of each shard
44
+ # the shard files, each a JSON object of tokens => posting lists, and the
45
+ # file of Latin tokens: a JSON array of every token without CJK characters,
46
+ # sorted, that lets the runtime expand Latin prefixes and typos. The
47
+ # tokens are sorted by code point and cut into shards of about shard_size
48
+ # bytes, so a token is in the last shard whose first token is not greater.
49
+ # A shard file is named after a digest of its content, so a browser may
50
+ # cache it: a changed shard gets a new name. The runtime loads the manifest
51
+ # (refs, lengths, filters: what a blank query and facets need), and fetches
52
+ # only the shards of the tokens of a query, and the Latin tokens when the
53
+ # query has a Latin word (on a technical blog they are most of the
54
+ # manifest's size otherwise).
55
+ #
56
+ # Older formats, still read by Builder.load, Searcher and the runtime:
57
+ # version 3 writes each posting list as a JSON array
58
+ # [doc, field, n, pos1, ..., posn, doc, field, n, ...] (doc and field are
59
+ # positions in "refs" and "fields"); version 2 is version 3 without "filters".
35
60
  #
36
61
  # Documents can be removed and replaced (remove, update), also in an index
37
- # read back with Builder.load. to_h is then the same as for an index built
38
- # from scratch with the remaining documents in the same order.
62
+ # read back with Builder.load or Builder.read. to_h is then the same as for
63
+ # an index built from scratch with the remaining documents in the same order.
39
64
  class Builder
40
- FORMAT = "cjk_index/3"
41
- FORMATS = ["cjk_index/2", FORMAT].freeze
65
+ FORMAT = "cjk_index/4"
66
+ FORMATS = ["cjk_index/2", "cjk_index/3", FORMAT].freeze
67
+
68
+ # Name of the manifest in a split index.
69
+ MANIFEST = "index.json"
70
+ # Default size of a shard in bytes (of JSON, before compression).
71
+ SHARD_SIZE = 32_000
72
+ # Names of shard files: a digest of the content.
73
+ SHARD_NAME = /\A\h{16}\.json\z/
42
74
 
43
75
  # Gap between the values of a multi-valued field, so that a phrase cannot
44
76
  # match across two values.
@@ -63,13 +95,19 @@ module CJKIndex
63
95
  @fields = fields.map(&:to_s)
64
96
  @boosts = @fields.map { |f| (boosts[f] || boosts[f.to_sym] || 1).to_f }
65
97
  @filters = Builder.filter_specs(filters)
66
- # Indexed by slot; a removed document leaves nil until to_h renumbers.
98
+ # Indexed by slot; a removed document leaves nil until the slots are
99
+ # renumbered (compact).
67
100
  @refs = []
68
101
  @lengths = []
69
102
  @filter_values = []
70
- @doc_tokens = []
71
- @doc_tokens_complete = true
72
- @postings = Hash.new { |h, k| h[k] = [] }
103
+ # token => posting list in the encoding of Postings, with slots for
104
+ # documents (the same as the format while there are no removed slots).
105
+ @postings = {}
106
+ # token => key of its last entry, filled as entries are appended.
107
+ @last_keys = {}
108
+ # Tokens of each slot, kept only once a document has been removed or
109
+ # replaced (nil until then): a build that only adds does not need them.
110
+ @doc_tokens = nil
73
111
  @slots = Hash.new { |h, k| h[k] = [] }
74
112
  @count = 0
75
113
  @version = 0
@@ -89,20 +127,45 @@ module CJKIndex
89
127
  end
90
128
 
91
129
  # Reads an index written by to_json (String) or to_h back, to update it.
130
+ # Reads formats 2, 3 and 4; a split index is read with read.
92
131
  # A value filter keeps the form of each value that the index stored.
93
- def self.load(index)
132
+ # normalizer: needed when the index uses a table file of your own and
133
+ # the current folder is not the one it was built from.
134
+ def self.load(index, normalizer: nil)
94
135
  data = index.is_a?(String) ? JSON.parse(index) : index
95
136
  unless data.is_a?(Hash) && FORMATS.include?(data["format"])
96
137
  raise ArgumentError, "cjk_index: unsupported index format #{data.is_a?(Hash) ? data['format'].inspect : data.class}"
97
138
  end
139
+ raise ArgumentError, "cjk_index: a split index is read with Builder.read(directory)" unless data["tokens"]
98
140
 
99
141
  filters = (data["filters"] || {}).transform_values { |f| f.slice("kind", "separator") }
142
+ normalizer ||= Normalizer.new(tables: data["variants"])
143
+ normalizer.check!(data)
100
144
  builder = new(fields: data["fields"], boosts: data["fields"].zip(data["boosts"]).to_h,
101
- normalizer: Normalizer.new(tables: data["variants"]), filters: filters)
145
+ normalizer: normalizer, filters: filters)
102
146
  builder.send(:restore, data)
103
147
  builder
104
148
  end
105
149
 
150
+ # Reads an index from a file (written by write or to_json) or from the
151
+ # directory of a split index (written by write_split), to update it.
152
+ def self.read(path, normalizer: nil)
153
+ load(Builder.read_data(path), normalizer: normalizer)
154
+ end
155
+
156
+ # The index at path as one Hash with "tokens", for a file or a split
157
+ # index (its directory or its manifest).
158
+ def self.read_data(path) # :nodoc:
159
+ path = File.join(path, MANIFEST) if File.directory?(path)
160
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
161
+ return data unless data.is_a?(Hash) && data["shards"]
162
+
163
+ dir = File.dirname(path)
164
+ tokens = {}
165
+ data["shards"].each { |_, file| tokens.merge!(JSON.parse(File.read(File.join(dir, file), encoding: "UTF-8"))) }
166
+ data.reject { |k, _| %w[latin shards].include?(k) }.merge("tokens" => tokens)
167
+ end
168
+
106
169
  # Folded form of a value filter's value, used to compare values.
107
170
  def self.filter_key(value, normalizer)
108
171
  normalizer.normalize(value.to_s).gsub(WHITESPACE, " ").delete_prefix(" ").delete_suffix(" ")
@@ -111,6 +174,7 @@ module CJKIndex
111
174
  # The first four-digit number in a value (after folding full-width digits).
112
175
  def self.year(value, normalizer)
113
176
  return value.to_i if value.is_a?(Numeric)
177
+ return value.year if value.respond_to?(:year) # Time, Date (YAML front matter)
114
178
 
115
179
  normalizer.normalize(value.to_s)[/(?<!\d)\d{4}(?!\d)/]&.to_i
116
180
  end
@@ -155,17 +219,10 @@ module CJKIndex
155
219
  end
156
220
 
157
221
  def to_h
158
- live = holes? ? @refs.each_index.reject { |slot| @refs[slot].nil? } : @refs.each_index.to_a
159
- h = {
160
- "format" => FORMAT,
161
- "variants" => @normalizer.tables,
162
- "fields" => @fields,
163
- "boosts" => @boosts.map { |b| b == b.to_i ? b.to_i : b },
164
- "refs" => live.map { |slot| @refs[slot] },
165
- "lengths" => live.flat_map { |slot| @lengths[slot * @fields.length, @fields.length] },
166
- "tokens" => holes? ? renumbered_postings(live) : @postings.sort.to_h
167
- }
168
- h["filters"] = @filters.to_h { |name, spec| [name, filter_to_h(name, spec, live)] } unless @filters.empty?
222
+ compact
223
+ h = header
224
+ h["tokens"] = @postings.keys.sort!.to_h { |token| [token, @postings[token].dup] }
225
+ h["filters"] = filters_h unless @filters.empty?
169
226
  h
170
227
  end
171
228
 
@@ -173,10 +230,91 @@ module CJKIndex
173
230
  JSON.generate(to_h)
174
231
  end
175
232
 
233
+ # Writes the index to one file, the same as File.write(path, to_json),
234
+ # without first making the whole JSON in memory. The file is written to a
235
+ # temporary file and renamed, so a reader never sees it partly written.
236
+ def write(path)
237
+ compact
238
+ atomic_write(path) do |io|
239
+ io << JSON.generate(header).delete_suffix("}") << ',"tokens":{'
240
+ @postings.keys.sort!.each_with_index do |token, i|
241
+ io << "," if i.positive?
242
+ io << JSON.generate(token) << ':"' << @postings[token] << '"'
243
+ end
244
+ io << "}"
245
+ io << ',"filters":' << JSON.generate(filters_h) unless @filters.empty?
246
+ io << "}"
247
+ end
248
+ path
249
+ end
250
+
251
+ # Writes a split index to the directory dir: the manifest index.json and
252
+ # shard files of about shard_size bytes (see the format above). Shard
253
+ # files are written first and the manifest last (renamed into place), and
254
+ # shard files that the new manifest does not list are then removed.
255
+ # Returns the path of the manifest.
256
+ def write_split(dir, shard_size: SHARD_SIZE)
257
+ FileUtils.mkdir_p(dir)
258
+ names = []
259
+ manifest = nil
260
+ each_split_file(shard_size: shard_size) do |name, content|
261
+ if name == MANIFEST
262
+ manifest = content
263
+ else
264
+ path = File.join(dir, name)
265
+ atomic_write(path) { |io| io << content } unless File.exist?(path) && File.size(path) == content.bytesize
266
+ names << name
267
+ end
268
+ end
269
+ atomic_write(File.join(dir, MANIFEST)) { |io| io << manifest }
270
+ Dir.children(dir).each do |name|
271
+ File.delete(File.join(dir, name)) if name.match?(SHARD_NAME) && !names.include?(name)
272
+ end
273
+ File.join(dir, MANIFEST)
274
+ end
275
+
276
+ # Yields [name, content] for each file of a split index: the shards, the
277
+ # Latin tokens, then the manifest (MANIFEST). For writing them somewhere other than a
278
+ # directory on disk (the Jekyll plugin uses it).
279
+ def each_split_file(shard_size: SHARD_SIZE)
280
+ return enum_for(:each_split_file, shard_size: shard_size) unless block_given?
281
+
282
+ compact
283
+ shards = []
284
+ latin = []
285
+ part = []
286
+ bytes = 0
287
+ first = nil
288
+ flush = lambda do
289
+ content = "{#{part.join(',')}}"
290
+ name = "#{Digest::SHA256.hexdigest(content)[0, 16]}.json"
291
+ yield name, content
292
+ shards << [first, name]
293
+ part = []
294
+ bytes = 0
295
+ end
296
+ @postings.keys.sort!.each do |token|
297
+ latin << token unless token.match?(Tokenizer::CJK)
298
+ pair = "#{JSON.generate(token)}:\"#{@postings[token]}\""
299
+ flush.call if bytes.positive? && bytes + pair.bytesize > shard_size
300
+ first = token if part.empty?
301
+ part << pair
302
+ bytes += pair.bytesize + 1
303
+ end
304
+ flush.call unless part.empty?
305
+ content = JSON.generate(latin)
306
+ latin_name = "#{Digest::SHA256.hexdigest(content)[0, 16]}.json"
307
+ yield latin_name, content
308
+ manifest = header.merge("latin" => latin_name, "shards" => shards)
309
+ manifest["filters"] = filters_h unless @filters.empty?
310
+ yield MANIFEST, JSON.generate(manifest)
311
+ end
312
+
176
313
  # For Searcher: the current documents by slot, with nil for removed ones.
177
314
  def live_data # :nodoc:
178
315
  {
179
- "variants" => @normalizer.tables, "fields" => @fields, "boosts" => @boosts,
316
+ "format" => FORMAT, "variants" => @normalizer.tables, "variant_digest" => @normalizer.digest,
317
+ "fields" => @fields, "boosts" => @boosts,
180
318
  "refs" => @refs, "lengths" => @lengths, "tokens" => @postings, "count" => @count,
181
319
  "filters" => @filters.to_h do |name, spec|
182
320
  [name, { "kind" => spec["kind"], "docs" => @filter_values.map { |v| v && v[name] } }]
@@ -186,46 +324,71 @@ module CJKIndex
186
324
 
187
325
  private
188
326
 
327
+ def header
328
+ {
329
+ "format" => FORMAT,
330
+ "variants" => @normalizer.tables,
331
+ "variant_digest" => @normalizer.digest,
332
+ "fields" => @fields,
333
+ "boosts" => @boosts.map { |b| b == b.to_i ? b.to_i : b },
334
+ "refs" => @refs.dup,
335
+ "lengths" => @lengths.dup
336
+ }
337
+ end
338
+
339
+ def filters_h = @filters.to_h { |name, spec| [name, filter_to_h(name, spec)] }
340
+
341
+ def atomic_write(path)
342
+ tmp = "#{path}.#{Process.pid}.tmp"
343
+ File.open(tmp, "wb") { |io| yield io }
344
+ File.rename(tmp, path)
345
+ ensure
346
+ File.delete(tmp) if tmp && File.exist?(tmp)
347
+ end
348
+
189
349
  def index(slot, doc)
350
+ nf = @fields.length
351
+ # A document replaced in place goes before the later slots, so that
352
+ # every list stays in the order a fresh build writes.
190
353
  in_place = slot < @refs.length - 1
191
- tokens = []
354
+ tokens = @doc_tokens ? [] : nil
192
355
  @fields.each_with_index do |field, field_index|
193
356
  positions = Hash.new { |h, k| h[k] = [] }
194
357
  base = 0
195
358
  Array(value_of(doc, field)).each do |v|
196
- text = v.to_s
197
- Tokenizer.tokens_with_offsets(text, unigrams: true, normalizer: @normalizer)
198
- .each { |t, offset| positions[t] << (base + offset) }
199
- base += @normalizer.normalize(text).length + VALUE_GAP
359
+ text = @normalizer.normalize(v.to_s)
360
+ Tokenizer.each_token(text, unigrams: true) { |t, offset| positions[t] << (base + offset) }
361
+ base += text.length + VALUE_GAP
200
362
  end
201
- @lengths[(slot * @fields.length) + field_index] = [base - VALUE_GAP, 0].max
363
+ @lengths[(slot * nf) + field_index] = [base - VALUE_GAP, 0].max
364
+ key = (slot * nf) + field_index
202
365
  positions.each do |token, offsets|
203
- postings = @postings[token]
204
- # A document replaced in place goes before the later slots, so that
205
- # every list stays in the order a fresh build writes.
206
- at = in_place ? insertion_point(postings, slot, field_index) : postings.length
207
- postings.insert(at, slot, field_index, offsets.length, *offsets)
366
+ if in_place
367
+ @postings[token] = Postings.insert(@postings[token] || +"", key, offsets)
368
+ @last_keys.delete(token)
369
+ else
370
+ list = (@postings[token] ||= +"")
371
+ last = @last_keys.fetch(token) { Postings.last_key(list) }
372
+ Postings.append(list, key - last - 1, offsets)
373
+ @last_keys[token] = key
374
+ end
208
375
  end
209
- tokens.concat(positions.keys)
376
+ tokens&.concat(positions.keys)
210
377
  end
211
- @doc_tokens[slot] = tokens.uniq
378
+ @doc_tokens[slot] = tokens.uniq if tokens
212
379
  @filter_values[slot] = @filters.to_h { |name, spec| [name, filter_value(spec, value_of(doc, name))] }
213
380
  @version += 1
214
381
  end
215
382
 
216
383
  def unindex(slot)
384
+ nf = @fields.length
385
+ first = slot * nf
217
386
  doc_tokens(slot).each do |token|
218
- postings = @postings[token]
219
- kept = []
220
- i = 0
221
- while i < postings.length
222
- n = 3 + postings[i + 2]
223
- kept.concat(postings[i, n]) unless postings[i] == slot
224
- i += n
225
- end
387
+ kept = Postings.delete(@postings[token], first..(first + nf - 1))
226
388
  if kept.empty? then @postings.delete(token)
227
389
  else @postings[token] = kept
228
390
  end
391
+ @last_keys.delete(token)
229
392
  end
230
393
  @doc_tokens[slot] = nil
231
394
  @version += 1
@@ -235,26 +398,28 @@ module CJKIndex
235
398
 
236
399
  # Value filter: [[key, value as written], ...]; year filter: Integer or nil.
237
400
  def filter_value(spec, raw)
401
+ # Array() would split a Time into its parts
402
+ raw = raw.is_a?(Array) ? raw : [raw].compact
238
403
  if spec["kind"] == "year"
239
- Array(raw).each do |v|
404
+ raw.each do |v|
240
405
  year = Builder.year(v, @normalizer)
241
406
  return year if year
242
407
  end
243
408
  return nil
244
409
  end
245
410
 
246
- Array(raw).flat_map { |v| spec["separator"] ? v.to_s.split(spec["separator"]) : [v.to_s] }
411
+ raw.flat_map { |v| spec["separator"] ? v.to_s.split(spec["separator"]) : [v.to_s] }
247
412
  .map { |v| v.gsub(WHITESPACE, " ").strip }.reject(&:empty?)
248
413
  .map { |v| [Builder.filter_key(v, @normalizer), v] }.uniq(&:first)
249
414
  end
250
415
 
251
- def filter_to_h(name, spec, live)
252
- return { "kind" => "year", "docs" => live.map { |slot| @filter_values[slot][name] } } if spec["kind"] == "year"
416
+ def filter_to_h(name, spec)
417
+ return { "kind" => "year", "docs" => @filter_values.map { |v| v[name] } } if spec["kind"] == "year"
253
418
 
254
419
  ids = {}
255
420
  values = []
256
- docs = live.map do |slot|
257
- @filter_values[slot][name].map do |key, value|
421
+ docs = @filter_values.map do |v|
422
+ v[name].map do |key, value|
258
423
  ids.fetch(key) do
259
424
  values << value
260
425
  ids[key] = values.length - 1
@@ -266,45 +431,37 @@ module CJKIndex
266
431
 
267
432
  def holes? = @count < @refs.length
268
433
 
269
- # Where an entry for slot and field goes in a list ordered by slot, then field.
270
- def insertion_point(postings, slot, field_index)
271
- i = 0
272
- while i < postings.length && (postings[i] < slot || (postings[i] == slot && postings[i + 1] < field_index))
273
- i += 3 + postings[i + 2]
274
- end
275
- i
276
- end
434
+ # Renumbers the slots to positions among the live documents, dropping the
435
+ # removed ones. Renumbering keeps the order, so sorted lists stay sorted.
436
+ def compact
437
+ return unless holes?
277
438
 
278
- # Postings with slots renumbered to positions among the live documents.
279
- # Removed slots appear in no list any more, and renumbering keeps the
280
- # order, so sorted lists stay sorted.
281
- def renumbered_postings(live)
439
+ nf = @fields.length
440
+ live = @refs.each_index.reject { |slot| @refs[slot].nil? }
282
441
  number = []
283
442
  live.each_with_index { |slot, doc| number[slot] = doc }
284
- @postings.sort.to_h do |token, postings|
285
- postings = postings.dup
286
- i = 0
287
- while i < postings.length
288
- postings[i] = number[postings[i]]
289
- i += 3 + postings[i + 2]
290
- end
291
- [token, postings]
292
- end
443
+ @postings.each_key { |token| @postings[token] = Postings.renumber(@postings[token], nf, number) }
444
+ @last_keys.clear
445
+ @refs = live.map { |slot| @refs[slot] }
446
+ @lengths = live.flat_map { |slot| @lengths[slot * nf, nf] }
447
+ @filter_values = live.map { |slot| @filter_values[slot] }
448
+ @doc_tokens = live.map { |slot| @doc_tokens[slot] } if @doc_tokens
449
+ @slots.each_value { |slots| slots.map! { |slot| number[slot] } }
450
+ @version += 1
293
451
  end
294
452
 
295
- # Tokens of a slot. An index read with load does not record them, so the
296
- # first change scans the postings once.
453
+ # Tokens of a slot. They are not recorded until a document is first
454
+ # removed or replaced; that first change scans the postings once.
297
455
  def doc_tokens(slot)
298
- unless @doc_tokens_complete
299
- @postings.each do |token, postings|
300
- i = 0
301
- while i < postings.length
302
- (@doc_tokens[postings[i]] ||= []) << token
303
- i += 3 + postings[i + 2]
456
+ unless @doc_tokens
457
+ nf = @fields.length
458
+ @doc_tokens = []
459
+ @postings.each do |token, list|
460
+ Postings.each_key(list) do |key|
461
+ tokens = (@doc_tokens[key / nf] ||= [])
462
+ tokens << token unless tokens.last.equal?(token)
304
463
  end
305
464
  end
306
- @doc_tokens.each { |tokens| tokens&.uniq! }
307
- @doc_tokens_complete = true
308
465
  end
309
466
  @doc_tokens[slot] || []
310
467
  end
@@ -318,7 +475,12 @@ module CJKIndex
318
475
  raise ArgumentError, "cjk_index: index has #{@lengths.length} lengths for #{@count} documents"
319
476
  end
320
477
 
321
- data["tokens"].each { |token, postings| @postings[token] = postings.dup }
478
+ nf = @fields.length
479
+ if data["format"] == FORMAT
480
+ data["tokens"].each { |token, list| @postings[token] = +list }
481
+ else
482
+ data["tokens"].each { |token, flat| @postings[token] = Postings.encode(flat, nf) }
483
+ end
322
484
  filters = data["filters"] || {}
323
485
  @filter_values = Array.new(@count) do |slot|
324
486
  filters.to_h do |name, f|
@@ -328,8 +490,6 @@ module CJKIndex
328
490
  [name, value.map { |id| [Builder.filter_key(f["values"][id], @normalizer), f["values"][id]] }]
329
491
  end
330
492
  end
331
- @doc_tokens = []
332
- @doc_tokens_complete = false
333
493
  end
334
494
  end
335
495
  end