cjk_index 0.1.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 +125 -0
- data/LICENSE-Unicode +39 -0
- data/NOTICE +17 -0
- data/README.md +257 -27
- 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 +438 -31
- data/lib/cjk_index/cli.rb +240 -0
- data/lib/cjk_index/index_file.rb +203 -0
- data/lib/cjk_index/jekyll.rb +38 -7
- 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 +337 -25
- data/lib/cjk_index/runtime.rb +1 -0
- data/lib/cjk_index/searcher.rb +215 -39
- data/lib/cjk_index/tokenizer.rb +30 -0
- data/lib/cjk_index/version.rb +1 -1
- data/lib/cjk_index.rb +2 -0
- metadata +21 -4
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CJKIndex
|
|
4
|
+
# The compact encoding of a posting list in format 4, shared by Builder
|
|
5
|
+
# (writing) and Searcher (reading); runtime.js decodes it the same way.
|
|
6
|
+
#
|
|
7
|
+
# A list is a sequence of entries, ordered by document and field. Each
|
|
8
|
+
# entry is written as numbers:
|
|
9
|
+
#
|
|
10
|
+
# key gap, n, first position, gap to the next position, ...
|
|
11
|
+
#
|
|
12
|
+
# where key = document * number of fields + field, and the key gap is the
|
|
13
|
+
# difference to the key of the previous entry minus 1 (the first entry
|
|
14
|
+
# counts from -1). n is the number of positions. Each number is written in
|
|
15
|
+
# printable ASCII: the last digit in base 60 from the first 60 characters
|
|
16
|
+
# of ALPHABET, the digits before it in base 30 from the other 30, most
|
|
17
|
+
# significant first. 0..59 take one character, up to 1,799 two, up to
|
|
18
|
+
# 53,999 three. ALPHABET has neither " nor \, so a list is a JSON string
|
|
19
|
+
# without escapes.
|
|
20
|
+
module Postings
|
|
21
|
+
ALPHABET = ((0x23..0x7d).map(&:chr) - ["\\"]).join.freeze
|
|
22
|
+
LAST = 60
|
|
23
|
+
LEAD = 30
|
|
24
|
+
# The characters that end a number are the bytes below LEAD_BYTE
|
|
25
|
+
# (FINAL_BYTES in the notation of String#count).
|
|
26
|
+
LEAD_BYTE = ALPHABET.getbyte(LAST)
|
|
27
|
+
FINAL_BYTES = "#-[\\]-_"
|
|
28
|
+
# byte => digit; 0...60 ends a number, 60...90 leads.
|
|
29
|
+
DIGIT = Array.new(256, -1).tap { |t| ALPHABET.each_byte.with_index { |b, i| t[b] = i } }.freeze
|
|
30
|
+
|
|
31
|
+
def self.number_slow(value)
|
|
32
|
+
s = ALPHABET[value % LAST]
|
|
33
|
+
value /= LAST
|
|
34
|
+
while value.positive?
|
|
35
|
+
s = ALPHABET[LAST + (value % LEAD)] + s
|
|
36
|
+
value /= LEAD
|
|
37
|
+
end
|
|
38
|
+
s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Every number of up to three characters, written once.
|
|
42
|
+
NUMBERS = Array.new(LAST * LEAD * LEAD) { |v| number_slow(v).freeze }.freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def number(value) = NUMBERS[value] || number_slow(value)
|
|
47
|
+
|
|
48
|
+
# Appends one entry to out. offsets are ascending positions.
|
|
49
|
+
def append(out, key_gap, offsets)
|
|
50
|
+
out << number(key_gap) << number(offsets.length)
|
|
51
|
+
last = 0
|
|
52
|
+
offsets.each do |pos|
|
|
53
|
+
out << number(pos - last)
|
|
54
|
+
last = pos
|
|
55
|
+
end
|
|
56
|
+
out
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# All numbers of a list.
|
|
60
|
+
def numbers(str)
|
|
61
|
+
out = []
|
|
62
|
+
acc = 0
|
|
63
|
+
str.each_byte do |b|
|
|
64
|
+
d = DIGIT[b]
|
|
65
|
+
if d >= LAST then acc = (acc * LEAD) + d - LAST
|
|
66
|
+
else
|
|
67
|
+
out << ((acc * LAST) + d)
|
|
68
|
+
acc = 0
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
out
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Yields key, start, body and stop for each entry: its key, and the byte
|
|
75
|
+
# offsets where the entry starts, where its key gap ends and where it
|
|
76
|
+
# ends. Loops over bytes rather than numbers, so that the changes below
|
|
77
|
+
# can copy the rest of a list as it is.
|
|
78
|
+
def each_entry(str)
|
|
79
|
+
return enum_for(:each_entry, str) unless block_given?
|
|
80
|
+
|
|
81
|
+
key = -1
|
|
82
|
+
i = 0
|
|
83
|
+
len = str.bytesize
|
|
84
|
+
while i < len
|
|
85
|
+
start = i
|
|
86
|
+
acc = 0
|
|
87
|
+
while (d = DIGIT[str.getbyte(i)]) >= LAST
|
|
88
|
+
acc = (acc * LEAD) + d - LAST
|
|
89
|
+
i += 1
|
|
90
|
+
end
|
|
91
|
+
key += (acc * LAST) + d + 1
|
|
92
|
+
i += 1
|
|
93
|
+
body = i
|
|
94
|
+
acc = 0
|
|
95
|
+
while (d = DIGIT[str.getbyte(i)]) >= LAST
|
|
96
|
+
acc = (acc * LEAD) + d - LAST
|
|
97
|
+
i += 1
|
|
98
|
+
end
|
|
99
|
+
n = (acc * LAST) + d
|
|
100
|
+
i += 1
|
|
101
|
+
# Skip n numbers: each ends with a byte below LEAD_BYTE. Many at a
|
|
102
|
+
# time are counted by String#count, which runs in C.
|
|
103
|
+
while n > 8
|
|
104
|
+
t = str.byteslice(i, n).count(FINAL_BYTES)
|
|
105
|
+
i += n
|
|
106
|
+
n -= t
|
|
107
|
+
end
|
|
108
|
+
while n.positive?
|
|
109
|
+
n -= 1 if str.getbyte(i) < LEAD_BYTE
|
|
110
|
+
i += 1
|
|
111
|
+
end
|
|
112
|
+
yield key, start, body, i
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Key of the last entry, -1 for an empty list.
|
|
117
|
+
def last_key(str)
|
|
118
|
+
key = -1
|
|
119
|
+
each_entry(str) { |k| key = k }
|
|
120
|
+
key
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Each key of a list, in order.
|
|
124
|
+
def each_key(str)
|
|
125
|
+
return enum_for(:each_key, str) unless block_given?
|
|
126
|
+
|
|
127
|
+
each_entry(str) { |key| yield key }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The list without the entries whose keys are in range (consecutive).
|
|
131
|
+
def delete(str, range)
|
|
132
|
+
before = -1
|
|
133
|
+
from = nil
|
|
134
|
+
each_entry(str) do |key, start, body|
|
|
135
|
+
if range.cover?(key)
|
|
136
|
+
from ||= start
|
|
137
|
+
elsif from
|
|
138
|
+
return str.byteslice(0, from) + number(key - before - 1) + str.byteslice(body..)
|
|
139
|
+
elsif key > range.end
|
|
140
|
+
return str
|
|
141
|
+
else
|
|
142
|
+
before = key
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
from ? str.byteslice(0, from) : str
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The list with an entry for key (not yet in it) and offsets inserted in order.
|
|
149
|
+
def insert(str, key, offsets)
|
|
150
|
+
before = -1
|
|
151
|
+
each_entry(str) do |k, start, body|
|
|
152
|
+
if k > key
|
|
153
|
+
entry = append(+"", key - before - 1, offsets)
|
|
154
|
+
return str.byteslice(0, start) + entry + number(k - key - 1) + str.byteslice(body..)
|
|
155
|
+
end
|
|
156
|
+
before = k
|
|
157
|
+
end
|
|
158
|
+
append(+str, key - before - 1, offsets)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The list with each key's document replaced by numbering[document], which
|
|
162
|
+
# keeps the order (removed documents have no entries).
|
|
163
|
+
def renumber(str, fields, numbering)
|
|
164
|
+
out = +""
|
|
165
|
+
old_before = -1
|
|
166
|
+
new_before = -1
|
|
167
|
+
copied = 0 # str up to here is in out or to be copied as it is
|
|
168
|
+
each_entry(str) do |key, start, body|
|
|
169
|
+
new_key = (numbering[key / fields] * fields) + (key % fields)
|
|
170
|
+
if new_key - new_before != key - old_before
|
|
171
|
+
out << str.byteslice(copied, start - copied) << number(new_key - new_before - 1)
|
|
172
|
+
copied = body
|
|
173
|
+
end
|
|
174
|
+
old_before = key
|
|
175
|
+
new_before = new_key
|
|
176
|
+
end
|
|
177
|
+
copied.zero? ? str : out << str.byteslice(copied..)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The flat list of format 3, [doc, field, n, pos, ..., doc, ...].
|
|
181
|
+
def decode(str, fields)
|
|
182
|
+
nums = numbers(str)
|
|
183
|
+
out = []
|
|
184
|
+
key = -1
|
|
185
|
+
i = 0
|
|
186
|
+
while i < nums.length
|
|
187
|
+
key += nums[i] + 1
|
|
188
|
+
n = nums[i + 1]
|
|
189
|
+
out << (key / fields) << (key % fields) << n
|
|
190
|
+
pos = 0
|
|
191
|
+
j = i + 2
|
|
192
|
+
stop = j + n
|
|
193
|
+
while j < stop
|
|
194
|
+
pos += nums[j]
|
|
195
|
+
out << pos
|
|
196
|
+
j += 1
|
|
197
|
+
end
|
|
198
|
+
i = stop
|
|
199
|
+
end
|
|
200
|
+
out
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# A flat list of format 2 or 3 in this encoding.
|
|
204
|
+
def encode(flat, fields)
|
|
205
|
+
out = +""
|
|
206
|
+
last = -1
|
|
207
|
+
i = 0
|
|
208
|
+
while i < flat.length
|
|
209
|
+
key = (flat[i] * fields) + flat[i + 1]
|
|
210
|
+
n = flat[i + 2]
|
|
211
|
+
append(out, key - last - 1, flat[i + 3, n])
|
|
212
|
+
last = key
|
|
213
|
+
i += 3 + n
|
|
214
|
+
end
|
|
215
|
+
out
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -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
|