cjk_index 0.2.0 → 1.0.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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "rake/tasklib"
5
+ require "fileutils"
6
+ require_relative "cli"
7
+
8
+ module CJKIndex
9
+ # A Rake task that builds an index (and the browser script) from a CSV,
10
+ # JSON or JSON Lines file, usable from any Ruby build (Rails, Sinatra, a
11
+ # plain Rakefile). It does what `cjk_index build` does.
12
+ #
13
+ # require "cjk_index/rake_task"
14
+ #
15
+ # CJKIndex::RakeTask.new(:search_index) do |t|
16
+ # t.source = "data/items.csv"
17
+ # t.output = "public/search/items.json"
18
+ # t.runtime = "public/search/cjk-index.js" # optional
19
+ # t.ref = "id"
20
+ # t.fields = %w[title creator]
21
+ # t.boosts = { "title" => 3 }
22
+ # t.filters = { "type" => "value", "date" => "year" }
23
+ # end
24
+ #
25
+ # rake search_index
26
+ #
27
+ # records can be set instead of source: an Array of Hashes, or an object
28
+ # that responds to call and returns one (e.g. a lambda reading the database).
29
+ class RakeTask < Rake::TaskLib
30
+ attr_accessor :name, :source, :records, :output, :runtime, :ref, :fields, :boosts, :filters, :variants, :format,
31
+ :description
32
+
33
+ def initialize(name = :cjk_index)
34
+ super()
35
+ @name = name
36
+ @boosts = {}
37
+ @filters = {}
38
+ @variants = Normalizer::DEFAULT_TABLES
39
+ @description = "Build the CJK search index"
40
+ yield self if block_given?
41
+ define
42
+ end
43
+
44
+ # Builds the index now; the task calls this.
45
+ def build
46
+ raise CLI::Error, "#{name}: set output" unless output
47
+
48
+ data = records.respond_to?(:call) ? records.call : records
49
+ data ||= source ? CLI.read_records(source.to_s, format || CLI.format_of(source)) : raise(CLI::Error, "#{name}: set source or records")
50
+ builder = CLI.build_index(records: data, ref: ref, fields: fields, boosts: boosts, filters: filters, variants: variants)
51
+ write(output, builder.to_json)
52
+ write(runtime, Runtime.source(normalizer: builder.normalizer)) if runtime
53
+ builder
54
+ end
55
+
56
+ private
57
+
58
+ def define
59
+ desc description
60
+ task(name) do
61
+ builder = build
62
+ puts "#{output}: #{builder.size} records"
63
+ end
64
+ end
65
+
66
+ def write(path, content)
67
+ FileUtils.mkdir_p(File.dirname(path.to_s))
68
+ File.write(path.to_s, content)
69
+ end
70
+ end
71
+ end
@@ -9,9 +9,49 @@
9
9
 
10
10
  var VARIANTS = /*@VARIANTS@*/{};
11
11
  var TABLES = /*@TABLES@*/[];
12
+ var DIGEST = /*@DIGEST@*/'';
12
13
  var CJK = new RegExp(/*@CJK@*/'', 'u');
13
14
  var SEPARATOR = new RegExp(/*@SEPARATOR@*/'', 'u');
14
- var FORMATS = ['cjk_index/2', 'cjk_index/3'];
15
+ var FORMATS = ['cjk_index/2', 'cjk_index/3', 'cjk_index/4'];
16
+ var COMPACT = 'cjk_index/4';
17
+ var hasOwn = Object.prototype.hasOwnProperty;
18
+
19
+ // Posting lists of format 4 (CJKIndex::Postings in Ruby): numbers written
20
+ // with 90 printable characters, '#' to '}' without '\'. The first 60 are the
21
+ // last digit of a number (base 60), the other 30 lead it (base 30).
22
+ var DIGIT = [];
23
+ for (var code = 0x23, digit = 0; code <= 0x7d; code++) if (code !== 0x5c) DIGIT[code] = digit++;
24
+
25
+ // A posting list of format 4 as the flat list of format 3:
26
+ // [doc, field, n, pos, ..., doc, field, n, ...].
27
+ function decodePostings(s, nf) {
28
+ var out = [];
29
+ var key = -1, state = 0, left = 0, pos = 0, acc = 0;
30
+ for (var i = 0; i < s.length; i++) {
31
+ var d = DIGIT[s.charCodeAt(i)];
32
+ if (d >= 60) { acc = acc * 30 + d - 60; continue; }
33
+ var v = acc * 60 + d;
34
+ acc = 0;
35
+ if (state === 0) { key += v + 1; out.push(Math.floor(key / nf), key % nf); state = 1; }
36
+ else if (state === 1) { out.push(v); left = v; pos = 0; state = 2; }
37
+ else { pos += v; out.push(pos); if (--left === 0) state = 0; }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // Compares strings by code point, as Ruby compares UTF-8 strings (the
43
+ // order of the shards). JavaScript's < compares UTF-16 code units, which
44
+ // puts characters beyond U+FFFF before U+E000-U+FFFF.
45
+ function compareCodePoints(a, b) {
46
+ var i = 0, j = 0;
47
+ while (i < a.length && j < b.length) {
48
+ var x = a.codePointAt(i), y = b.codePointAt(j);
49
+ if (x !== y) return x < y ? -1 : 1;
50
+ i += x > 0xffff ? 2 : 1;
51
+ j += y > 0xffff ? 2 : 1;
52
+ }
53
+ return (i < a.length ? 1 : 0) - (j < b.length ? 1 : 0);
54
+ }
15
55
 
16
56
  // BM25 parameters (the usual defaults, as in Lucene and Pagefind).
17
57
  var K1 = 1.2;
@@ -119,6 +159,10 @@
119
159
  throw new Error('cjk_index: index built with variant tables ' + JSON.stringify(data.variants) +
120
160
  ' but this script folds with ' + JSON.stringify(TABLES) + '; rebuild both together');
121
161
  }
162
+ if (data.variant_digest !== undefined && data.variant_digest !== DIGEST) {
163
+ throw new Error('cjk_index: index built with other contents of the variant tables ' +
164
+ JSON.stringify(TABLES) + ' (an older cjk_index?); rebuild it');
165
+ }
122
166
  options = options || {};
123
167
  this.prefix = options.prefix !== false; // last Latin word matches as a prefix
124
168
  this.typos = options.typos !== false; // Latin words of 5+ letters allow one edit
@@ -126,15 +170,30 @@
126
170
  this.boosts = data.boosts;
127
171
  this.refs = data.refs;
128
172
  this.lengths = data.lengths;
129
- this.tokens = data.tokens;
173
+ this.tokens = data.tokens || null;
174
+ this.encoded = data.format === COMPACT;
175
+ this.decoded = new Map();
176
+ if (data.shards) {
177
+ // Split index: the manifest lists the first token and file of each shard.
178
+ this.loadShard = options.loadShard;
179
+ if (!this.loadShard) throw new Error('cjk_index: a split index needs options.loadShard (CJKIndex.load sets it)');
180
+ this.shardFirst = data.shards.map(function (s) { return s[0]; });
181
+ this.shardFile = data.shards.map(function (s) { return s[1]; });
182
+ this.shards = new Map(); // file -> tokens, once loaded
183
+ this.pending = new Map(); // file -> Promise while loading
184
+ }
130
185
  var nf = this.fields.length;
131
186
  var avg = new Array(nf).fill(0);
132
187
  for (var i = 0; i < this.lengths.length; i++) avg[i % nf] += this.lengths[i];
133
188
  this.avgLength = avg.map(function (s) { return s / Math.max(1, data.refs.length) || 1; });
134
- this.latinTokens = Object.keys(this.tokens).filter(isLatin).sort();
189
+ // A split index lists its Latin tokens in a file, fetched by prepare
190
+ // only for typo expansion.
191
+ this.latinFile = this.tokens ? null : data.latin;
192
+ if (this.tokens) this._setLatin(Object.keys(this.tokens).filter(isLatin));
135
193
  this.slotOf = new Map();
136
194
  for (var d = 0; d < this.refs.length; d++) if (!this.slotOf.has(this.refs[d])) this.slotOf.set(this.refs[d], d);
137
- // Filter values by document: keys (value filters) or a year, with the
195
+ // Filter values by document: a year, or the ids of values (value
196
+ // filters), with the folded key of each id, the ids of each key, the
138
197
  // form to show for each key and its rank of first appearance.
139
198
  this.filters = {};
140
199
  var filters = data.filters || {};
@@ -142,19 +201,22 @@
142
201
  var f = filters[name];
143
202
  var docs = f.docs;
144
203
  var display = new Map();
204
+ var keys = null;
205
+ var ids = new Map();
145
206
  if (f.kind !== 'year') {
146
- var keys = f.values.map(filterKey);
207
+ keys = f.values.map(filterKey);
147
208
  f.values.forEach(function (v, i) { if (!display.has(keys[i])) display.set(keys[i], v); });
148
- docs = f.docs.map(function (ids) { return ids.map(function (id) { return keys[id]; }); });
209
+ keys.forEach(function (key, id) { if (!ids.has(key)) ids.set(key, []); ids.get(key).push(id); });
149
210
  }
150
211
  var rank = new Map();
151
212
  docs.forEach(function (value) {
152
- (f.kind === 'year' ? (value === null ? [] : [value]) : value).forEach(function (key) {
213
+ (f.kind === 'year' ? (value === null ? [] : [value]) : value).forEach(function (v) {
214
+ var key = f.kind === 'year' ? v : keys[v];
153
215
  if (!rank.has(key)) rank.set(key, rank.size);
154
216
  });
155
217
  });
156
218
  if (f.kind === 'year') rank.forEach(function (r, year) { display.set(year, year); });
157
- this.filters[name] = { kind: f.kind, docs: docs, display: display, rank: rank };
219
+ this.filters[name] = { kind: f.kind, docs: docs, keys: keys, ids: ids, display: display, rank: rank };
158
220
  }, this);
159
221
  }
160
222
 
@@ -175,8 +237,12 @@
175
237
  (max === null || max === undefined || year <= max);
176
238
  }]);
177
239
  } else {
178
- var keys = new Set((Array.isArray(c) ? c : [c]).map(filterKey));
179
- out.push([f, function (values) { return values.some(function (k) { return keys.has(k); }); }]);
240
+ // Compares value ids, not folded strings.
241
+ var ids = new Set();
242
+ (Array.isArray(c) ? c : [c]).forEach(function (v) {
243
+ (f.ids.get(filterKey(v)) || []).forEach(function (id) { ids.add(id); });
244
+ });
245
+ out.push([f, function (list) { return list.some(function (id) { return ids.has(id); }); }]);
180
246
  }
181
247
  });
182
248
  return out;
@@ -206,7 +272,8 @@
206
272
  var counts = new Map();
207
273
  docs.forEach(function (doc) {
208
274
  var v = f.docs[doc];
209
- (f.kind === 'year' ? (v === null ? [] : [v]) : v).forEach(function (key) {
275
+ (f.kind === 'year' ? (v === null ? [] : [v]) : v).forEach(function (x) {
276
+ var key = f.kind === 'year' ? x : f.keys[x];
210
277
  counts.set(key, (counts.get(key) || 0) + 1);
211
278
  });
212
279
  });
@@ -215,12 +282,109 @@
215
282
  return out.map(function (e) { return { value: f.display.get(e[0]), count: e[1] }; });
216
283
  };
217
284
 
285
+ Index.prototype._setLatin = function (tokens) {
286
+ this.latinTokens = tokens.slice().sort();
287
+ this.latinSet = new Set(this.latinTokens);
288
+ };
289
+
290
+ // Number of the shard that holds token (split index), or -1.
291
+ Index.prototype._shardIndex = function (token) {
292
+ var lo = 0, hi = this.shardFirst.length;
293
+ while (lo < hi) {
294
+ var mid = (lo + hi) >> 1;
295
+ if (compareCodePoints(this.shardFirst[mid], token) <= 0) lo = mid + 1; else hi = mid;
296
+ }
297
+ return lo - 1;
298
+ };
299
+
300
+ function notLoaded(what) {
301
+ return new Error('cjk_index: ' + what + ' not loaded; use searchAsync(query) or await prepare(query)');
302
+ }
303
+
304
+ // Tokens of shard i, or undefined when it is not loaded: then its file is
305
+ // added to missing (prepare), or an error is thrown (search).
306
+ Index.prototype._shard = function (i, missing) {
307
+ var tokens = this.shards.get(this.shardFile[i]);
308
+ if (tokens) return tokens;
309
+ if (!missing) throw notLoaded('the shard ' + this.shardFile[i] + ' is');
310
+ missing.files.add(this.shardFile[i]);
311
+ return undefined;
312
+ };
313
+
314
+ // The object that holds token's posting list if it is in the index; null
315
+ // when it cannot be; undefined when its shard is not loaded (see _shard).
316
+ Index.prototype._table = function (token, missing) {
317
+ if (this.tokens) return this.tokens;
318
+ var i = this._shardIndex(token);
319
+ return i < 0 ? null : this._shard(i, missing);
320
+ };
321
+
322
+ // true or false, or undefined when not known yet (prepare).
323
+ Index.prototype._has = function (token, missing) {
324
+ var table = this._table(token, missing);
325
+ return table === undefined ? undefined : !!table && hasOwn.call(table, token);
326
+ };
327
+
328
+ // The posting list of a token as [doc, field, n, pos, ...], or null.
329
+ Index.prototype._postings = function (token) {
330
+ var table = this._table(token);
331
+ if (!table || !hasOwn.call(table, token)) return null;
332
+ if (!this.encoded) return table[token];
333
+ var p = this.decoded.get(token);
334
+ if (!p) { p = decodePostings(table[token], this.fields.length); this.decoded.set(token, p); }
335
+ return p;
336
+ };
337
+
338
+ Index.prototype._load = function (file, done) {
339
+ var self = this;
340
+ var p = this.pending.get(file);
341
+ if (!p) {
342
+ p = Promise.resolve(this.loadShard(file)).then(done);
343
+ p.catch(function () { self.pending.delete(file); });
344
+ this.pending.set(file, p);
345
+ }
346
+ return p;
347
+ };
348
+
349
+ // Fetches what a search for query needs from a split index, so that
350
+ // search(query) can then run: the shards of its tokens, of the words a
351
+ // last Latin word is a prefix of, and, for a Latin word of 5+ letters not
352
+ // in the index, the list of Latin tokens (once) and the shards of the
353
+ // words one edit away. Resolves to the index; at once for one file.
354
+ Index.prototype.prepare = function (query) {
355
+ var self = this;
356
+ if (!this.shardFirst) return Promise.resolve(this);
357
+ var missing = { files: new Set(), latin: false };
358
+ var qp = phrases(query, false);
359
+ qp.forEach(function (phrase, q) {
360
+ phrase.forEach(function (tok, i) {
361
+ self._alternatives(tok[0], q === qp.length - 1 && i === phrase.length - 1, missing).tokens.forEach(function (t) {
362
+ self._table(t, missing);
363
+ });
364
+ });
365
+ });
366
+ var loads = Array.from(missing.files).map(function (file) {
367
+ return self._load(file, function (tokens) { self.shards.set(file, tokens); });
368
+ });
369
+ if (missing.latin) loads.push(this._load(this.latinFile, function (tokens) { self._setLatin(tokens); }));
370
+ if (!loads.length) return Promise.resolve(this);
371
+ // What was loaded may show more to load (typo candidates).
372
+ return Promise.all(loads).then(function () { return self.prepare(query); });
373
+ };
374
+
375
+ // search, after fetching the shards it needs. Works for every index, so
376
+ // a page can use it whether or not the index is split.
377
+ Index.prototype.searchAsync = function (query, options) {
378
+ var self = this;
379
+ return this.prepare(query).then(function () { return self.search(query, options); });
380
+ };
381
+
218
382
  // Map of "doc,field" key -> Set of positions, merged over the alternatives.
219
383
  Index.prototype._positions = function (tokens) {
220
384
  var nf = this.fields.length;
221
385
  var map = new Map();
222
386
  for (var t = 0; t < tokens.length; t++) {
223
- var p = this.tokens[tokens[t]];
387
+ var p = this._postings(tokens[t]);
224
388
  if (!p) continue;
225
389
  for (var i = 0; i < p.length;) {
226
390
  var key = p[i] * nf + p[i + 1];
@@ -236,17 +400,36 @@
236
400
 
237
401
  // Alternatives for one query token: itself, and for Latin words the index
238
402
  // words it is a prefix of (last word only) or one edit away from.
239
- Index.prototype._alternatives = function (token, isLast) {
403
+ // With missing (prepare), what is not loaded yet is noted there and left out.
404
+ Index.prototype._alternatives = function (token, isLast, missing) {
240
405
  if (!isLatin(token)) return { tokens: [token], expanded: false };
241
- var out = this.tokens[token] ? [token] : [];
242
- var keys = this.latinTokens;
406
+ var has = this.tokens ? this.latinSet.has(token) : this._has(token, missing);
407
+ var out = has ? [token] : [];
243
408
  if (this.prefix && isLast) {
244
- var lo = 0, hi = keys.length;
245
- while (lo < hi) { var mid = (lo + hi) >> 1; if (keys[mid] < token) lo = mid + 1; else hi = mid; }
246
- for (var i = lo; i < keys.length && keys[i].indexOf(token) === 0; i++) if (keys[i] !== token) out.push(keys[i]);
409
+ if (this.tokens) {
410
+ var keys = this.latinTokens;
411
+ var lo = 0, hi = keys.length;
412
+ while (lo < hi) { var mid = (lo + hi) >> 1; if (keys[mid] < token) lo = mid + 1; else hi = mid; }
413
+ for (var i = lo; i < keys.length && keys[i].indexOf(token) === 0; i++) if (keys[i] !== token) out.push(keys[i]);
414
+ } else {
415
+ // The words starting with token are in its shard and the next ones
416
+ // that start with it (the tokens are sorted).
417
+ var first = Math.max(0, this._shardIndex(token));
418
+ for (var s = first; s < this.shardFirst.length && (s === first || this.shardFirst[s].indexOf(token) === 0); s++) {
419
+ var shard = this._shard(s, missing);
420
+ if (!shard) continue;
421
+ for (var k in shard) if (hasOwn.call(shard, k) && k !== token && k.indexOf(token) === 0) out.push(k);
422
+ }
423
+ }
247
424
  }
248
- if (this.typos && !this.tokens[token] && Array.from(token).length >= 5) {
249
- keys.forEach(function (k) { if (oneEdit(token, k)) out.push(k); });
425
+ if (this.typos && has === false && Array.from(token).length >= 5) {
426
+ if (this.latinTokens) {
427
+ this.latinTokens.forEach(function (k) { if (oneEdit(token, k)) out.push(k); });
428
+ } else if (missing) {
429
+ missing.latin = true;
430
+ } else {
431
+ throw notLoaded('the Latin tokens are');
432
+ }
250
433
  }
251
434
  return { tokens: out, expanded: out.length > 0 && out[0] !== token };
252
435
  };
@@ -335,20 +518,38 @@
335
518
  });
336
519
  }
337
520
 
521
+ // Characters that NFKC may compose with the one before (combining marks,
522
+ // Hangul vowel and final jamo, half-width sound marks): kept in one cluster
523
+ // so that the cluster normalizes as it does inside the whole text.
524
+ var JOINS_PREVIOUS = /^[\p{M}\u1160-\u11ff\ud7b0-\ud7ff\u314f-\u3163\u3187-\u318e\uff9e\uff9f]$/u;
525
+
338
526
  // [start, end) ranges, in code points of the original text, where a phrase
339
527
  // of the query occurs after normalization.
340
528
  function matchRanges(text, query) {
341
529
  var chars = Array.from(String(text === null || text === undefined ? '' : text));
342
530
  var norm = [];
343
- var origin = [];
531
+ var start = [];
532
+ var end = [];
344
533
  var prev = null;
345
- chars.forEach(function (ch, i) {
346
- for (var c of ch.normalize('NFKC').toLowerCase()) {
534
+ // Each folded code point remembers which original characters it came
535
+ // from. A character may fold to several (ko: one Private Use Area
536
+ // character -> a jamo sequence), and a cluster may compose into fewer.
537
+ function emit(str, from, to) {
538
+ for (var c of str.normalize('NFKC').toLowerCase()) {
347
539
  prev = foldChar(c, prev);
348
- norm.push(prev);
349
- origin.push(i);
540
+ for (var p of prev) { norm.push(p); start.push(from); end.push(to); }
350
541
  }
351
- });
542
+ }
543
+ for (var i = 0, j; i < chars.length; i = j) {
544
+ for (j = i + 1; j < chars.length && JOINS_PREVIOUS.test(chars[j]); j++);
545
+ var cluster = chars.slice(i, j);
546
+ var whole = cluster.join('').normalize('NFKC');
547
+ if (cluster.map(function (c) { return c.normalize('NFKC'); }).join('') === whole) {
548
+ cluster.forEach(function (c, k) { emit(c, i + k, i + k + 1); });
549
+ } else {
550
+ emit(cluster.join(''), i, j);
551
+ }
552
+ }
352
553
  var normText = norm.join('');
353
554
  var ranges = [];
354
555
  var needles = [];
@@ -363,7 +564,7 @@
363
564
  var at;
364
565
  while ((at = normText.indexOf(needle, from)) !== -1) {
365
566
  var cp = Array.from(normText.slice(0, at)).length;
366
- ranges.push([origin[cp], origin[cp + len - 1] + 1]);
567
+ ranges.push([start[cp], end[cp + len - 1]]);
367
568
  from = at + needle.length;
368
569
  }
369
570
  });
@@ -400,11 +601,24 @@
400
601
  return (start > 0 ? '…' : '') + highlight(part, query) + (end < m.chars.length ? '…' : '');
401
602
  }
402
603
 
403
- function load(url, options) {
604
+ function fetchJson(url) {
404
605
  return fetch(url).then(function (res) {
405
606
  if (!res.ok) throw new Error('cjk_index: ' + url + ' returned ' + res.status);
406
607
  return res.json();
407
- }).then(function (data) { return new Index(data, options); });
608
+ });
609
+ }
610
+
611
+ // Loads an index file, or the manifest of a split index: its shards are
612
+ // then fetched from the same directory as queries need them (searchAsync).
613
+ function load(url, options) {
614
+ options = Object.assign({}, options);
615
+ return fetchJson(url).then(function (data) {
616
+ if (data && data.shards && !options.loadShard) {
617
+ var base = new URL(url, typeof location !== 'undefined' ? location.href : undefined);
618
+ options.loadShard = function (file) { return fetchJson(new URL(file, base).href); };
619
+ }
620
+ return new Index(data, options);
621
+ });
408
622
  }
409
623
 
410
624
  var api = {
@@ -17,6 +17,7 @@ module CJKIndex
17
17
  # back-references and drop half of the regexp escapes.
18
18
  .sub("/*@VARIANTS@*/{}") { JSON.generate(normalizer.variants) }
19
19
  .sub("/*@TABLES@*/[]") { JSON.generate(normalizer.tables) }
20
+ .sub("/*@DIGEST@*/''") { JSON.generate(normalizer.digest) }
20
21
  .sub("/*@CJK@*/''") { JSON.generate(Tokenizer.char_class(Tokenizer::CJK_RANGES)) }
21
22
  .sub("/*@SEPARATOR@*/''") do
22
23
  JSON.generate(Tokenizer.char_class(Tokenizer::SEPARATOR_RANGES, Tokenizer::SEPARATOR_CHARS))
@@ -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
@@ -46,9 +58,10 @@ module CJKIndex
46
58
  end
47
59
 
48
60
  @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"
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