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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/LICENSE-Unicode +39 -0
- data/NOTICE +17 -0
- data/README.md +164 -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 +247 -89
- data/lib/cjk_index/cli.rb +240 -0
- data/lib/cjk_index/index_file.rb +4 -0
- data/lib/cjk_index/jekyll.rb +21 -5
- data/lib/cjk_index/normalizer.rb +60 -12
- 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 +101 -37
- 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
|
|
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)))
|
|
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,43 @@ 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
|
def self.load(index)
|
|
94
133
|
data = index.is_a?(String) ? JSON.parse(index) : index
|
|
95
134
|
unless data.is_a?(Hash) && FORMATS.include?(data["format"])
|
|
96
135
|
raise ArgumentError, "cjk_index: unsupported index format #{data.is_a?(Hash) ? data['format'].inspect : data.class}"
|
|
97
136
|
end
|
|
137
|
+
raise ArgumentError, "cjk_index: a split index is read with Builder.read(directory)" unless data["tokens"]
|
|
98
138
|
|
|
99
139
|
filters = (data["filters"] || {}).transform_values { |f| f.slice("kind", "separator") }
|
|
140
|
+
normalizer = Normalizer.new(tables: data["variants"])
|
|
141
|
+
normalizer.check!(data)
|
|
100
142
|
builder = new(fields: data["fields"], boosts: data["fields"].zip(data["boosts"]).to_h,
|
|
101
|
-
normalizer:
|
|
143
|
+
normalizer: normalizer, filters: filters)
|
|
102
144
|
builder.send(:restore, data)
|
|
103
145
|
builder
|
|
104
146
|
end
|
|
105
147
|
|
|
148
|
+
# Reads an index from a file (written by write or to_json) or from the
|
|
149
|
+
# directory of a split index (written by write_split), to update it.
|
|
150
|
+
def self.read(path)
|
|
151
|
+
load(Builder.read_data(path))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The index at path as one Hash with "tokens", for a file or a split
|
|
155
|
+
# index (its directory or its manifest).
|
|
156
|
+
def self.read_data(path) # :nodoc:
|
|
157
|
+
path = File.join(path, MANIFEST) if File.directory?(path)
|
|
158
|
+
data = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
159
|
+
return data unless data.is_a?(Hash) && data["shards"]
|
|
160
|
+
|
|
161
|
+
dir = File.dirname(path)
|
|
162
|
+
tokens = {}
|
|
163
|
+
data["shards"].each { |_, file| tokens.merge!(JSON.parse(File.read(File.join(dir, file), encoding: "UTF-8"))) }
|
|
164
|
+
data.reject { |k, _| %w[latin shards].include?(k) }.merge("tokens" => tokens)
|
|
165
|
+
end
|
|
166
|
+
|
|
106
167
|
# Folded form of a value filter's value, used to compare values.
|
|
107
168
|
def self.filter_key(value, normalizer)
|
|
108
169
|
normalizer.normalize(value.to_s).gsub(WHITESPACE, " ").delete_prefix(" ").delete_suffix(" ")
|
|
@@ -111,6 +172,7 @@ module CJKIndex
|
|
|
111
172
|
# The first four-digit number in a value (after folding full-width digits).
|
|
112
173
|
def self.year(value, normalizer)
|
|
113
174
|
return value.to_i if value.is_a?(Numeric)
|
|
175
|
+
return value.year if value.respond_to?(:year) # Time, Date (YAML front matter)
|
|
114
176
|
|
|
115
177
|
normalizer.normalize(value.to_s)[/(?<!\d)\d{4}(?!\d)/]&.to_i
|
|
116
178
|
end
|
|
@@ -155,17 +217,10 @@ module CJKIndex
|
|
|
155
217
|
end
|
|
156
218
|
|
|
157
219
|
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?
|
|
220
|
+
compact
|
|
221
|
+
h = header
|
|
222
|
+
h["tokens"] = @postings.keys.sort!.to_h { |token| [token, @postings[token].dup] }
|
|
223
|
+
h["filters"] = filters_h unless @filters.empty?
|
|
169
224
|
h
|
|
170
225
|
end
|
|
171
226
|
|
|
@@ -173,10 +228,91 @@ module CJKIndex
|
|
|
173
228
|
JSON.generate(to_h)
|
|
174
229
|
end
|
|
175
230
|
|
|
231
|
+
# Writes the index to one file, the same as File.write(path, to_json),
|
|
232
|
+
# without first making the whole JSON in memory. The file is written to a
|
|
233
|
+
# temporary file and renamed, so a reader never sees it partly written.
|
|
234
|
+
def write(path)
|
|
235
|
+
compact
|
|
236
|
+
atomic_write(path) do |io|
|
|
237
|
+
io << JSON.generate(header).delete_suffix("}") << ',"tokens":{'
|
|
238
|
+
@postings.keys.sort!.each_with_index do |token, i|
|
|
239
|
+
io << "," if i.positive?
|
|
240
|
+
io << JSON.generate(token) << ':"' << @postings[token] << '"'
|
|
241
|
+
end
|
|
242
|
+
io << "}"
|
|
243
|
+
io << ',"filters":' << JSON.generate(filters_h) unless @filters.empty?
|
|
244
|
+
io << "}"
|
|
245
|
+
end
|
|
246
|
+
path
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Writes a split index to the directory dir: the manifest index.json and
|
|
250
|
+
# shard files of about shard_size bytes (see the format above). Shard
|
|
251
|
+
# files are written first and the manifest last (renamed into place), and
|
|
252
|
+
# shard files that the new manifest does not list are then removed.
|
|
253
|
+
# Returns the path of the manifest.
|
|
254
|
+
def write_split(dir, shard_size: SHARD_SIZE)
|
|
255
|
+
FileUtils.mkdir_p(dir)
|
|
256
|
+
names = []
|
|
257
|
+
manifest = nil
|
|
258
|
+
each_split_file(shard_size: shard_size) do |name, content|
|
|
259
|
+
if name == MANIFEST
|
|
260
|
+
manifest = content
|
|
261
|
+
else
|
|
262
|
+
path = File.join(dir, name)
|
|
263
|
+
atomic_write(path) { |io| io << content } unless File.exist?(path) && File.size(path) == content.bytesize
|
|
264
|
+
names << name
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
atomic_write(File.join(dir, MANIFEST)) { |io| io << manifest }
|
|
268
|
+
Dir.children(dir).each do |name|
|
|
269
|
+
File.delete(File.join(dir, name)) if name.match?(SHARD_NAME) && !names.include?(name)
|
|
270
|
+
end
|
|
271
|
+
File.join(dir, MANIFEST)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Yields [name, content] for each file of a split index: the shards, the
|
|
275
|
+
# Latin tokens, then the manifest (MANIFEST). For writing them somewhere other than a
|
|
276
|
+
# directory on disk (the Jekyll plugin uses it).
|
|
277
|
+
def each_split_file(shard_size: SHARD_SIZE)
|
|
278
|
+
return enum_for(:each_split_file, shard_size: shard_size) unless block_given?
|
|
279
|
+
|
|
280
|
+
compact
|
|
281
|
+
shards = []
|
|
282
|
+
latin = []
|
|
283
|
+
part = []
|
|
284
|
+
bytes = 0
|
|
285
|
+
first = nil
|
|
286
|
+
flush = lambda do
|
|
287
|
+
content = "{#{part.join(',')}}"
|
|
288
|
+
name = "#{Digest::SHA256.hexdigest(content)[0, 16]}.json"
|
|
289
|
+
yield name, content
|
|
290
|
+
shards << [first, name]
|
|
291
|
+
part = []
|
|
292
|
+
bytes = 0
|
|
293
|
+
end
|
|
294
|
+
@postings.keys.sort!.each do |token|
|
|
295
|
+
latin << token unless token.match?(Tokenizer::CJK)
|
|
296
|
+
pair = "#{JSON.generate(token)}:\"#{@postings[token]}\""
|
|
297
|
+
flush.call if bytes.positive? && bytes + pair.bytesize > shard_size
|
|
298
|
+
first = token if part.empty?
|
|
299
|
+
part << pair
|
|
300
|
+
bytes += pair.bytesize + 1
|
|
301
|
+
end
|
|
302
|
+
flush.call unless part.empty?
|
|
303
|
+
content = JSON.generate(latin)
|
|
304
|
+
latin_name = "#{Digest::SHA256.hexdigest(content)[0, 16]}.json"
|
|
305
|
+
yield latin_name, content
|
|
306
|
+
manifest = header.merge("latin" => latin_name, "shards" => shards)
|
|
307
|
+
manifest["filters"] = filters_h unless @filters.empty?
|
|
308
|
+
yield MANIFEST, JSON.generate(manifest)
|
|
309
|
+
end
|
|
310
|
+
|
|
176
311
|
# For Searcher: the current documents by slot, with nil for removed ones.
|
|
177
312
|
def live_data # :nodoc:
|
|
178
313
|
{
|
|
179
|
-
"
|
|
314
|
+
"format" => FORMAT, "variants" => @normalizer.tables, "variant_digest" => @normalizer.digest,
|
|
315
|
+
"fields" => @fields, "boosts" => @boosts,
|
|
180
316
|
"refs" => @refs, "lengths" => @lengths, "tokens" => @postings, "count" => @count,
|
|
181
317
|
"filters" => @filters.to_h do |name, spec|
|
|
182
318
|
[name, { "kind" => spec["kind"], "docs" => @filter_values.map { |v| v && v[name] } }]
|
|
@@ -186,46 +322,71 @@ module CJKIndex
|
|
|
186
322
|
|
|
187
323
|
private
|
|
188
324
|
|
|
325
|
+
def header
|
|
326
|
+
{
|
|
327
|
+
"format" => FORMAT,
|
|
328
|
+
"variants" => @normalizer.tables,
|
|
329
|
+
"variant_digest" => @normalizer.digest,
|
|
330
|
+
"fields" => @fields,
|
|
331
|
+
"boosts" => @boosts.map { |b| b == b.to_i ? b.to_i : b },
|
|
332
|
+
"refs" => @refs.dup,
|
|
333
|
+
"lengths" => @lengths.dup
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def filters_h = @filters.to_h { |name, spec| [name, filter_to_h(name, spec)] }
|
|
338
|
+
|
|
339
|
+
def atomic_write(path)
|
|
340
|
+
tmp = "#{path}.#{Process.pid}.tmp"
|
|
341
|
+
File.open(tmp, "wb") { |io| yield io }
|
|
342
|
+
File.rename(tmp, path)
|
|
343
|
+
ensure
|
|
344
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
345
|
+
end
|
|
346
|
+
|
|
189
347
|
def index(slot, doc)
|
|
348
|
+
nf = @fields.length
|
|
349
|
+
# A document replaced in place goes before the later slots, so that
|
|
350
|
+
# every list stays in the order a fresh build writes.
|
|
190
351
|
in_place = slot < @refs.length - 1
|
|
191
|
-
tokens = []
|
|
352
|
+
tokens = @doc_tokens ? [] : nil
|
|
192
353
|
@fields.each_with_index do |field, field_index|
|
|
193
354
|
positions = Hash.new { |h, k| h[k] = [] }
|
|
194
355
|
base = 0
|
|
195
356
|
Array(value_of(doc, field)).each do |v|
|
|
196
|
-
text = v.to_s
|
|
197
|
-
Tokenizer.
|
|
198
|
-
|
|
199
|
-
base += @normalizer.normalize(text).length + VALUE_GAP
|
|
357
|
+
text = @normalizer.normalize(v.to_s)
|
|
358
|
+
Tokenizer.each_token(text, unigrams: true) { |t, offset| positions[t] << (base + offset) }
|
|
359
|
+
base += text.length + VALUE_GAP
|
|
200
360
|
end
|
|
201
|
-
@lengths[(slot *
|
|
361
|
+
@lengths[(slot * nf) + field_index] = [base - VALUE_GAP, 0].max
|
|
362
|
+
key = (slot * nf) + field_index
|
|
202
363
|
positions.each do |token, offsets|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
364
|
+
if in_place
|
|
365
|
+
@postings[token] = Postings.insert(@postings[token] || +"", key, offsets)
|
|
366
|
+
@last_keys.delete(token)
|
|
367
|
+
else
|
|
368
|
+
list = (@postings[token] ||= +"")
|
|
369
|
+
last = @last_keys.fetch(token) { Postings.last_key(list) }
|
|
370
|
+
Postings.append(list, key - last - 1, offsets)
|
|
371
|
+
@last_keys[token] = key
|
|
372
|
+
end
|
|
208
373
|
end
|
|
209
|
-
tokens
|
|
374
|
+
tokens&.concat(positions.keys)
|
|
210
375
|
end
|
|
211
|
-
@doc_tokens[slot] = tokens.uniq
|
|
376
|
+
@doc_tokens[slot] = tokens.uniq if tokens
|
|
212
377
|
@filter_values[slot] = @filters.to_h { |name, spec| [name, filter_value(spec, value_of(doc, name))] }
|
|
213
378
|
@version += 1
|
|
214
379
|
end
|
|
215
380
|
|
|
216
381
|
def unindex(slot)
|
|
382
|
+
nf = @fields.length
|
|
383
|
+
first = slot * nf
|
|
217
384
|
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
|
|
385
|
+
kept = Postings.delete(@postings[token], first..(first + nf - 1))
|
|
226
386
|
if kept.empty? then @postings.delete(token)
|
|
227
387
|
else @postings[token] = kept
|
|
228
388
|
end
|
|
389
|
+
@last_keys.delete(token)
|
|
229
390
|
end
|
|
230
391
|
@doc_tokens[slot] = nil
|
|
231
392
|
@version += 1
|
|
@@ -235,26 +396,28 @@ module CJKIndex
|
|
|
235
396
|
|
|
236
397
|
# Value filter: [[key, value as written], ...]; year filter: Integer or nil.
|
|
237
398
|
def filter_value(spec, raw)
|
|
399
|
+
# Array() would split a Time into its parts
|
|
400
|
+
raw = raw.is_a?(Array) ? raw : [raw].compact
|
|
238
401
|
if spec["kind"] == "year"
|
|
239
|
-
|
|
402
|
+
raw.each do |v|
|
|
240
403
|
year = Builder.year(v, @normalizer)
|
|
241
404
|
return year if year
|
|
242
405
|
end
|
|
243
406
|
return nil
|
|
244
407
|
end
|
|
245
408
|
|
|
246
|
-
|
|
409
|
+
raw.flat_map { |v| spec["separator"] ? v.to_s.split(spec["separator"]) : [v.to_s] }
|
|
247
410
|
.map { |v| v.gsub(WHITESPACE, " ").strip }.reject(&:empty?)
|
|
248
411
|
.map { |v| [Builder.filter_key(v, @normalizer), v] }.uniq(&:first)
|
|
249
412
|
end
|
|
250
413
|
|
|
251
|
-
def filter_to_h(name, spec
|
|
252
|
-
return { "kind" => "year", "docs" =>
|
|
414
|
+
def filter_to_h(name, spec)
|
|
415
|
+
return { "kind" => "year", "docs" => @filter_values.map { |v| v[name] } } if spec["kind"] == "year"
|
|
253
416
|
|
|
254
417
|
ids = {}
|
|
255
418
|
values = []
|
|
256
|
-
docs =
|
|
257
|
-
|
|
419
|
+
docs = @filter_values.map do |v|
|
|
420
|
+
v[name].map do |key, value|
|
|
258
421
|
ids.fetch(key) do
|
|
259
422
|
values << value
|
|
260
423
|
ids[key] = values.length - 1
|
|
@@ -266,45 +429,37 @@ module CJKIndex
|
|
|
266
429
|
|
|
267
430
|
def holes? = @count < @refs.length
|
|
268
431
|
|
|
269
|
-
#
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
i += 3 + postings[i + 2]
|
|
274
|
-
end
|
|
275
|
-
i
|
|
276
|
-
end
|
|
432
|
+
# Renumbers the slots to positions among the live documents, dropping the
|
|
433
|
+
# removed ones. Renumbering keeps the order, so sorted lists stay sorted.
|
|
434
|
+
def compact
|
|
435
|
+
return unless holes?
|
|
277
436
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
# order, so sorted lists stay sorted.
|
|
281
|
-
def renumbered_postings(live)
|
|
437
|
+
nf = @fields.length
|
|
438
|
+
live = @refs.each_index.reject { |slot| @refs[slot].nil? }
|
|
282
439
|
number = []
|
|
283
440
|
live.each_with_index { |slot, doc| number[slot] = doc }
|
|
284
|
-
@postings.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
end
|
|
441
|
+
@postings.each_key { |token| @postings[token] = Postings.renumber(@postings[token], nf, number) }
|
|
442
|
+
@last_keys.clear
|
|
443
|
+
@refs = live.map { |slot| @refs[slot] }
|
|
444
|
+
@lengths = live.flat_map { |slot| @lengths[slot * nf, nf] }
|
|
445
|
+
@filter_values = live.map { |slot| @filter_values[slot] }
|
|
446
|
+
@doc_tokens = live.map { |slot| @doc_tokens[slot] } if @doc_tokens
|
|
447
|
+
@slots.each_value { |slots| slots.map! { |slot| number[slot] } }
|
|
448
|
+
@version += 1
|
|
293
449
|
end
|
|
294
450
|
|
|
295
|
-
# Tokens of a slot.
|
|
296
|
-
# first change scans the postings once.
|
|
451
|
+
# Tokens of a slot. They are not recorded until a document is first
|
|
452
|
+
# removed or replaced; that first change scans the postings once.
|
|
297
453
|
def doc_tokens(slot)
|
|
298
|
-
unless @
|
|
299
|
-
@
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
454
|
+
unless @doc_tokens
|
|
455
|
+
nf = @fields.length
|
|
456
|
+
@doc_tokens = []
|
|
457
|
+
@postings.each do |token, list|
|
|
458
|
+
Postings.each_key(list) do |key|
|
|
459
|
+
tokens = (@doc_tokens[key / nf] ||= [])
|
|
460
|
+
tokens << token unless tokens.last.equal?(token)
|
|
304
461
|
end
|
|
305
462
|
end
|
|
306
|
-
@doc_tokens.each { |tokens| tokens&.uniq! }
|
|
307
|
-
@doc_tokens_complete = true
|
|
308
463
|
end
|
|
309
464
|
@doc_tokens[slot] || []
|
|
310
465
|
end
|
|
@@ -318,7 +473,12 @@ module CJKIndex
|
|
|
318
473
|
raise ArgumentError, "cjk_index: index has #{@lengths.length} lengths for #{@count} documents"
|
|
319
474
|
end
|
|
320
475
|
|
|
321
|
-
|
|
476
|
+
nf = @fields.length
|
|
477
|
+
if data["format"] == FORMAT
|
|
478
|
+
data["tokens"].each { |token, list| @postings[token] = +list }
|
|
479
|
+
else
|
|
480
|
+
data["tokens"].each { |token, flat| @postings[token] = Postings.encode(flat, nf) }
|
|
481
|
+
end
|
|
322
482
|
filters = data["filters"] || {}
|
|
323
483
|
@filter_values = Array.new(@count) do |slot|
|
|
324
484
|
filters.to_h do |name, f|
|
|
@@ -328,8 +488,6 @@ module CJKIndex
|
|
|
328
488
|
[name, value.map { |id| [Builder.filter_key(f["values"][id], @normalizer), f["values"][id]] }]
|
|
329
489
|
end
|
|
330
490
|
end
|
|
331
|
-
@doc_tokens = []
|
|
332
|
-
@doc_tokens_complete = false
|
|
333
491
|
end
|
|
334
492
|
end
|
|
335
493
|
end
|