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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/LICENSE-Unicode +39 -0
- data/NOTICE +17 -0
- data/README.md +193 -41
- data/data/ja-kyujitai.tsv +423 -292
- data/data/ko-hanyang-pua.tsv +5666 -0
- data/data/zh-hant-hans-safe.tsv +2774 -0
- data/exe/cjk_index +6 -0
- data/lib/cjk_index/bridgetown.rb +100 -0
- data/lib/cjk_index/builder.rb +250 -90
- data/lib/cjk_index/cli.rb +240 -0
- data/lib/cjk_index/index_file.rb +4 -0
- data/lib/cjk_index/jekyll.rb +23 -7
- data/lib/cjk_index/normalizer.rb +103 -18
- data/lib/cjk_index/postings.rb +218 -0
- data/lib/cjk_index/rake_task.rb +71 -0
- data/lib/cjk_index/runtime.js +243 -29
- data/lib/cjk_index/runtime.rb +1 -0
- data/lib/cjk_index/searcher.rb +102 -38
- data/lib/cjk_index/tokenizer.rb +30 -0
- data/lib/cjk_index/version.rb +1 -1
- data/lib/cjk_index.rb +1 -0
- metadata +16 -4
data/exe/cjk_index
ADDED
|
@@ -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
|
data/lib/cjk_index/builder.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
16
|
+
# Format (version 4), one file:
|
|
14
17
|
# {
|
|
15
|
-
# "format": "cjk_index/
|
|
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": { "東京":
|
|
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
|
-
#
|
|
28
|
-
# occurrences and
|
|
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.
|
|
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
|
|
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/
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
@postings =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
159
|
-
h =
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
"
|
|
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.
|
|
198
|
-
|
|
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 *
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
252
|
-
return { "kind" => "year", "docs" =>
|
|
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 =
|
|
257
|
-
|
|
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
|
-
#
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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.
|
|
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 @
|
|
299
|
-
@
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|