cjk_index 0.1.0 → 0.2.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 +63 -0
- data/README.md +113 -6
- data/lib/cjk_index/builder.rb +275 -26
- data/lib/cjk_index/index_file.rb +199 -0
- data/lib/cjk_index/jekyll.rb +17 -2
- data/lib/cjk_index/runtime.js +103 -5
- data/lib/cjk_index/searcher.rb +136 -24
- data/lib/cjk_index/version.rb +1 -1
- data/lib/cjk_index.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 843127d7f858d567b1e4eb0d37975111612feab1bdf63fe9d8bc6e4dbaf0e0b1
|
|
4
|
+
data.tar.gz: 78c8ab89bbaf4477aa0e8b51f72de64503bb71e2401ed13c013eeed40bc3f41c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f8aef83a37b2b0644cab2d22a0bea337a467ea9c89a142c4a4fe2c702e49cfb4afcf1a9ef4146aa9a656b54ec95ebb9055d53a0ec3439808ea970533ba1588c9
|
|
7
|
+
data.tar.gz: d88440121eb7f3ebc1239ae28d12b80b7ac706a70065b66c160b0ceaf33f6e434d093bb53e8a1aca6e8f26cbed1ce79556e48eba8837132c05c04c42352e51fa
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-10-28)
|
|
4
|
+
|
|
5
|
+
- Filters: `Builder.new(filters:)` with `value` and `year` filters;
|
|
6
|
+
`search(query, filter:)` in Ruby and `search(query, { filter })` in the
|
|
7
|
+
browser; a blank query with a filter lists every document that passes;
|
|
8
|
+
`facets(field, results)` counts values. Ruby and the browser give the same
|
|
9
|
+
results and counts.
|
|
10
|
+
- Index updates: `Builder#remove`, `Builder#update`, `Builder.load`. The
|
|
11
|
+
changed index is the same as one rebuilt from scratch.
|
|
12
|
+
- `Searcher.new(builder)` follows later changes to the builder (it used to
|
|
13
|
+
copy the index).
|
|
14
|
+
- `IndexFile`: an index file shared by several processes (e.g. Rails
|
|
15
|
+
workers). Updates take a lock and are written with a rename, so none is
|
|
16
|
+
lost and readers never see a partly written file; readers load the file
|
|
17
|
+
again when it changes. A killed writer leaves the old index intact;
|
|
18
|
+
`lock_timeout:` bounds the wait for another writer.
|
|
19
|
+
- Index format `cjk_index/3` (adds `filters`). The browser script and
|
|
20
|
+
`Searcher` still read `cjk_index/2`.
|
|
21
|
+
- Jekyll: `filters:` for an index; the CollectionBuilder preset filters on
|
|
22
|
+
`metadata-facets-fields` and the year of `date`.
|
|
23
|
+
|
|
24
|
+
## 0.1.1 (2026-10-18)
|
|
25
|
+
|
|
26
|
+
No change to the library. This release fixes the state of the repository
|
|
27
|
+
cited in a paper.
|
|
28
|
+
|
|
29
|
+
- Retrieval quality on two real datasets (69 catalog records from cb-ja-demo,
|
|
30
|
+
16,951 Aozora Bunko works; 15 queries each), against lunr.js with its
|
|
31
|
+
default and Japanese settings: [bench/retrieval/](bench/retrieval/).
|
|
32
|
+
Queries, relevance judgments (committed before the runs), judging rules,
|
|
33
|
+
scripts and raw outputs are included.
|
|
34
|
+
- Load and query times in Chrome and Safari for 1,000, 5,000 and 16,951
|
|
35
|
+
records: [bench/browser/](bench/browser/).
|
|
36
|
+
- README and docs/comparison.md: the recommended size is now the range we
|
|
37
|
+
measured (up to 16,951 records), not "about 20,000".
|
|
38
|
+
- CITATION.cff for citing the software.
|
|
39
|
+
- CODE_OF_CONDUCT.md (Contributor Covenant 2.1), CONTRIBUTING.md, SECURITY.md,
|
|
40
|
+
issue forms and a pull request template.
|
|
41
|
+
|
|
42
|
+
## 0.1.0 (2026-10-02)
|
|
43
|
+
|
|
44
|
+
First release on RubyGems.
|
|
45
|
+
|
|
46
|
+
- Normalizer in pure Ruby: NFKC, lowercase, katakana → hiragana, 291 old-form
|
|
47
|
+
kanji → modern forms, the iteration mark 々. Optional Traditional → Simplified
|
|
48
|
+
Chinese table (3,202 characters, from OpenCC, Apache-2.0). `CJKIndex.normalize`.
|
|
49
|
+
- Tokenizer: positional bigrams for CJK runs, including hangul and old-hangul
|
|
50
|
+
jamo; Latin words as whole tokens.
|
|
51
|
+
- `CJKIndex::Builder`: BM25 index with field boosts, written as JSON.
|
|
52
|
+
- `CJKIndex::Searcher`: exact substring matching, BM25 ranking, highlighting and
|
|
53
|
+
excerpts in pure Ruby; prefix matching and one-typo tolerance for Latin words.
|
|
54
|
+
- Browser runtime with no dependencies (`CJKIndex::Runtime.source`), generated
|
|
55
|
+
from the same tables; the test suite requires identical results in Ruby and
|
|
56
|
+
under Node.
|
|
57
|
+
- Jekyll plugin (`cjk_index/jekyll`) with a CollectionBuilder preset.
|
|
58
|
+
|
|
59
|
+
In the repository (not packaged in the gem):
|
|
60
|
+
|
|
61
|
+
- `examples/search_csv.rb`: search a CSV catalog from the command line, without Jekyll.
|
|
62
|
+
- Benchmarks on Aozora Bunko (up to 16,951 works): [bench/README.md](bench/README.md).
|
|
63
|
+
- Comparison with other search tools: [docs/comparison.md](docs/comparison.md).
|
data/README.md
CHANGED
|
@@ -37,13 +37,33 @@ even when it is an exact title. cjk_index instead:
|
|
|
37
37
|
The browser script is about 6.5 KB gzipped with the Japanese table, 24 KB
|
|
38
38
|
with the Chinese table as well.
|
|
39
39
|
|
|
40
|
-
How this compares with Pagefind, kensaku, lunr.js and others:
|
|
40
|
+
How this compares with Pagefind, kensaku, lunr.js, rroonga, tantiny and others:
|
|
41
41
|
[docs/comparison.md](docs/comparison.md).
|
|
42
42
|
Index size and query time on up to 16,951 works of Aozora Bunko:
|
|
43
|
-
[bench/README.md](bench/README.md).
|
|
43
|
+
[bench/README.md](bench/README.md). Retrieval quality against lunr.js:
|
|
44
|
+
[bench/retrieval/](bench/retrieval/). Load and query times in Chrome and
|
|
45
|
+
Safari: [bench/browser/](bench/browser/).
|
|
46
|
+
|
|
47
|
+
When to use it, and when not to:
|
|
48
|
+
|
|
49
|
+
- **Good fit:** catalogs, collection records or blog posts, from a few
|
|
50
|
+
thousand records up to the 16,951 we measured (19.3 MB gzipped at that size,
|
|
51
|
+
a heavy download on slow connections; above about 10,000 records, consider
|
|
52
|
+
splitting the index); static sites, command-line tools, scripts and small apps; anywhere
|
|
53
|
+
you want nothing to install outside Ruby, or the same results in Ruby and
|
|
54
|
+
the browser.
|
|
55
|
+
- **Not a good fit (yet):** large amounts of full text, frequent updates, or
|
|
56
|
+
search served continuously by a server. The whole index is held in memory
|
|
57
|
+
and shipped as one file: about 5 bytes per character gzipped, and 3 GB of
|
|
58
|
+
memory to build 35.6 million characters. A native engine (rroonga, tantiny)
|
|
59
|
+
or a database feature (pg_bigm) is the better choice there.
|
|
44
60
|
|
|
45
61
|
## Ruby
|
|
46
62
|
|
|
63
|
+
```console
|
|
64
|
+
$ gem install cjk_index
|
|
65
|
+
```
|
|
66
|
+
|
|
47
67
|
```ruby
|
|
48
68
|
require "cjk_index"
|
|
49
69
|
|
|
@@ -71,9 +91,81 @@ Matching, BM25 ranking, highlighting and excerpts follow the browser script
|
|
|
71
91
|
step for step. The test suite runs both on the same index and checks that
|
|
72
92
|
refs, scores, matched fields, highlights and excerpts are identical.
|
|
73
93
|
|
|
94
|
+
### Filters
|
|
95
|
+
|
|
96
|
+
Fields to filter on are given when the index is built. A `value` filter
|
|
97
|
+
matches whole values, folded like the query (地圖 matches 地図); a `year`
|
|
98
|
+
filter keeps the first four-digit number of a value (`1900-01-01`,
|
|
99
|
+
`1931年`; era dates such as 明治33年 give no year).
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
builder = CJKIndex::Builder.new(fields: %w[title], filters: {
|
|
103
|
+
"type" => "value", "subject" => { "kind" => "value", "separator" => ";" }, "date" => "year"
|
|
104
|
+
})
|
|
105
|
+
builder.add("item1.html", "title" => "…鳥瞰圖", "type" => "地図", "subject" => "建物;農学部", "date" => "1900")
|
|
106
|
+
|
|
107
|
+
searcher.search("鳥瞰図", filter: { "type" => "地図", "date" => 1890..1910 })
|
|
108
|
+
searcher.search("", filter: { "subject" => %w[建物 植物] }) # no query: every item that passes
|
|
109
|
+
searcher.facets("type", results) # => [{ value: "地図", count: 12 }, ...]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Conditions on different fields must all hold; a list matches any of its
|
|
113
|
+
values. A filter only removes results: the scores of the others do not change.
|
|
114
|
+
|
|
115
|
+
### Updating an index
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
builder.update("item1.html", "title" => "…", "type" => "写真") # in place, or added if new
|
|
119
|
+
builder.remove("item2.html")
|
|
120
|
+
|
|
121
|
+
builder = CJKIndex::Builder.load(File.read("search.json")) # change a written index
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
After any changes, `to_h` is the same as for an index built from scratch
|
|
125
|
+
with the remaining documents in the same order (the test suite checks this
|
|
126
|
+
on random sequences of changes). A `Searcher` made from a `Builder` answers
|
|
127
|
+
for its current documents.
|
|
128
|
+
|
|
129
|
+
### Sharing an index file between processes
|
|
130
|
+
|
|
131
|
+
A Rails app usually runs several worker processes, and each holds its own
|
|
132
|
+
copy of the index in memory. `IndexFile` keeps them in step through one file:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# config/initializers/cjk_index.rb
|
|
136
|
+
SEARCH = CJKIndex::IndexFile.new(Rails.root.join("db/search-index.json"))
|
|
137
|
+
|
|
138
|
+
SEARCH.write(builder) # a full build, e.g. in a Rake task
|
|
139
|
+
SEARCH.update { |b| b.update(item.id, item.to_doc) } # after a record changes, in any process
|
|
140
|
+
SEARCH.searcher.search("鳥瞰図") # loads the file again if it has changed
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`update` takes a lock, reads the file, runs the block and writes the result,
|
|
144
|
+
so updates from different processes are applied one after another. The new
|
|
145
|
+
index is written to a temporary file and renamed over the old one: a reader
|
|
146
|
+
gets the old index or the new one, never a partly written file. A reader
|
|
147
|
+
keeps the index it loaded until the file changes; `interval:` limits how
|
|
148
|
+
often it checks. Put several changes in one block to write once.
|
|
149
|
+
|
|
150
|
+
On the Aozora Bunko catalog (16,951 records, 61 MB file), a check of an
|
|
151
|
+
unchanged file takes 0.09 ms, loading a changed file 0.6–0.7 s, and an update
|
|
152
|
+
0.6 s (2.1 s when another process wrote last, as the file is read again).
|
|
153
|
+
[bench/index_file.rb](bench/index_file.rb). The lock is `flock`: use a local
|
|
154
|
+
disk, not NFS.
|
|
155
|
+
|
|
156
|
+
The file is never changed in place, so a writer that is killed leaves the
|
|
157
|
+
previous index intact and nothing to repair. A writer waits at most
|
|
158
|
+
`lock_timeout:` seconds (default 60) for another one, then raises
|
|
159
|
+
`CJKIndex::IndexFile::LockTimeout`.
|
|
160
|
+
|
|
161
|
+
An update rewrites the whole file, so its cost grows with the index: 0.08 s
|
|
162
|
+
for 1,000 records, 0.24 s for 5,000, 0.7 s for 16,951. A record edited now
|
|
163
|
+
and then needs nothing special; put a few changes in one block; for an
|
|
164
|
+
import, build the index again and `write` it (11 s for 16,951 records).
|
|
165
|
+
|
|
74
166
|
### Without Jekyll
|
|
75
167
|
|
|
76
|
-
[examples/search_csv.rb](examples/search_csv.rb) searches a CSV
|
|
168
|
+
[examples/search_csv.rb](examples/search_csv.rb) searches a CSV catalog from
|
|
77
169
|
the command line, in about 40 lines of plain Ruby:
|
|
78
170
|
|
|
79
171
|
```console
|
|
@@ -96,6 +188,9 @@ agri_6d032fb5 東京[帝國大學]農學部建物位置圖
|
|
|
96
188
|
index.search("鳥瞰図"); // => [{ ref: "item1.html", score: ..., fields: ["title"] }]
|
|
97
189
|
CJKIndex.highlight("東京帝國大學", "帝国大学"); // => "東京<mark>帝國大學</mark>"
|
|
98
190
|
CJKIndex.excerpt(longText, "鳥瞰図", 80); // highlighted window around the first match
|
|
191
|
+
|
|
192
|
+
var results = index.search("鳥瞰図", { filter: { type: "地図", date: { min: 1890, max: 1910 } } });
|
|
193
|
+
index.facets("type", results); // => [{ value: "地図", count: 12 }, ...]
|
|
99
194
|
});
|
|
100
195
|
</script>
|
|
101
196
|
```
|
|
@@ -123,6 +218,10 @@ cjk_index:
|
|
|
123
218
|
data: metadata # rows of _data/metadata.csv
|
|
124
219
|
ref: objectid
|
|
125
220
|
fields: [title, creator, subject]
|
|
221
|
+
filters: # optional
|
|
222
|
+
type: value
|
|
223
|
+
subject: { kind: value, separator: ";" }
|
|
224
|
+
date: year
|
|
126
225
|
```
|
|
127
226
|
|
|
128
227
|
The runtime is written to `assets/cjk-index/cjk-index.js`.
|
|
@@ -139,7 +238,9 @@ cjk_index:
|
|
|
139
238
|
The preset follows CollectionBuilder's own search settings: fields with
|
|
140
239
|
`index: true` in `_data/config-search.csv`, and child objects only when
|
|
141
240
|
`search-child-objects` is on. Result refs are the same `id` values as in
|
|
142
|
-
`assets/js/lunr-store.js`.
|
|
241
|
+
`assets/js/lunr-store.js`. Filters default to the theme's
|
|
242
|
+
`metadata-facets-fields` (values separated by `;`) and a year from the `date`
|
|
243
|
+
column; set `filters:` to change them. A working example:
|
|
143
244
|
https://github.com/nakamura196/cb-ja-demo
|
|
144
245
|
|
|
145
246
|
## Variant tables
|
|
@@ -156,8 +257,11 @@ it is opt-in.
|
|
|
156
257
|
|
|
157
258
|
## Status
|
|
158
259
|
|
|
159
|
-
0.
|
|
160
|
-
|
|
260
|
+
0.2. On RubyGems: `gem install cjk_index`
|
|
261
|
+
(https://rubygems.org/gems/cjk_index). 0.2 adds filters, index updates and
|
|
262
|
+
an index file shared by several processes. Planned for 1.0: index sharding
|
|
263
|
+
for large sites (see the [benchmarks](bench/README.md)), a Bridgetown
|
|
264
|
+
adapter, a CLI, and variant tables from official sources.
|
|
161
265
|
|
|
162
266
|
## Development
|
|
163
267
|
|
|
@@ -166,6 +270,9 @@ bundle install
|
|
|
166
270
|
bundle exec rake test # the runtime tests need node
|
|
167
271
|
```
|
|
168
272
|
|
|
273
|
+
Bug reports and pull requests: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
274
|
+
Security problems: [SECURITY.md](SECURITY.md).
|
|
275
|
+
|
|
169
276
|
## License
|
|
170
277
|
|
|
171
278
|
MIT
|
data/lib/cjk_index/builder.rb
CHANGED
|
@@ -5,82 +5,331 @@ require "json"
|
|
|
5
5
|
module CJKIndex
|
|
6
6
|
# Builds a prebuilt inverted index that the browser runtime loads as JSON.
|
|
7
7
|
#
|
|
8
|
-
# builder = CJKIndex::Builder.new(fields: %w[title creator], boosts: { "title" => 3 }
|
|
9
|
-
#
|
|
8
|
+
# builder = CJKIndex::Builder.new(fields: %w[title creator], boosts: { "title" => 3 },
|
|
9
|
+
# filters: { "type" => "value", "date" => "year" })
|
|
10
|
+
# builder.add("item1.html", "title" => "東京帝國大學", "creator" => "…", "type" => "地図", "date" => "1900")
|
|
10
11
|
# File.write("search-index.json", builder.to_json)
|
|
11
12
|
#
|
|
12
|
-
# Format (version
|
|
13
|
+
# Format (version 3):
|
|
13
14
|
# {
|
|
14
|
-
# "format": "cjk_index/
|
|
15
|
+
# "format": "cjk_index/3",
|
|
15
16
|
# "variants": ["ja"],
|
|
16
17
|
# "fields": ["title", "creator"],
|
|
17
18
|
# "boosts": [3, 1],
|
|
18
19
|
# "refs": ["item1.html", ...],
|
|
19
20
|
# "lengths": [len(doc0.title), len(doc0.creator), len(doc1.title), ...],
|
|
20
|
-
# "tokens": { "東京": [doc, field, n, pos1, ..., posn, doc, field, n, ...], ... }
|
|
21
|
+
# "tokens": { "東京": [doc, field, n, pos1, ..., posn, doc, field, n, ...], ... },
|
|
22
|
+
# "filters": {
|
|
23
|
+
# "type": { "kind": "value", "values": ["地図", ...], "docs": [[0], [], ...] },
|
|
24
|
+
# "date": { "kind": "year", "docs": [1900, null, ...] }
|
|
25
|
+
# }
|
|
21
26
|
# }
|
|
22
27
|
# doc and field are positions in "refs" and "fields"; n is the number of
|
|
23
28
|
# occurrences and pos are character offsets in the normalized field text.
|
|
24
29
|
# Offsets let the runtime require the bigrams of a query to be adjacent
|
|
25
30
|
# (exact substring match); lengths feed BM25 ranking.
|
|
31
|
+
# "filters" is present only when filters were configured. A value filter
|
|
32
|
+
# stores each distinct value once (by its folded form, as first written)
|
|
33
|
+
# and lists the values of each document by number; a year filter stores one
|
|
34
|
+
# year or null per document. Version 2 is the same without "filters".
|
|
35
|
+
#
|
|
36
|
+
# Documents can be removed and replaced (remove, update), also in an index
|
|
37
|
+
# read back with Builder.load. to_h is then the same as for an index built
|
|
38
|
+
# from scratch with the remaining documents in the same order.
|
|
26
39
|
class Builder
|
|
27
|
-
FORMAT = "cjk_index/
|
|
40
|
+
FORMAT = "cjk_index/3"
|
|
41
|
+
FORMATS = ["cjk_index/2", FORMAT].freeze
|
|
28
42
|
|
|
29
43
|
# Gap between the values of a multi-valued field, so that a phrase cannot
|
|
30
44
|
# match across two values.
|
|
31
45
|
VALUE_GAP = 1
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
FILTER_KINDS = %w[value year].freeze
|
|
48
|
+
|
|
49
|
+
# Space, tab and line breaks, written out so that Ruby and JavaScript agree.
|
|
50
|
+
WHITESPACE = /[ \t\n\r\f\v]+/
|
|
51
|
+
|
|
52
|
+
attr_reader :fields, :normalizer, :filters, :version
|
|
34
53
|
|
|
35
54
|
# normalizer must match the one the runtime script was generated with
|
|
36
55
|
# (Runtime.source(normalizer:)); the index records its tables and the
|
|
37
56
|
# runtime refuses an index built with different ones.
|
|
38
|
-
|
|
57
|
+
# filters: field name => "value" or "year", or { "kind" => "value", "separator" => ";" }
|
|
58
|
+
# for several values written in one string; an Array of names means "value" for each.
|
|
59
|
+
def initialize(fields:, boosts: {}, normalizer: Normalizer.default, filters: {})
|
|
39
60
|
raise ArgumentError, "fields must not be empty" if fields.empty?
|
|
40
61
|
|
|
41
62
|
@normalizer = normalizer
|
|
42
63
|
@fields = fields.map(&:to_s)
|
|
43
64
|
@boosts = @fields.map { |f| (boosts[f] || boosts[f.to_sym] || 1).to_f }
|
|
65
|
+
@filters = Builder.filter_specs(filters)
|
|
66
|
+
# Indexed by slot; a removed document leaves nil until to_h renumbers.
|
|
44
67
|
@refs = []
|
|
45
68
|
@lengths = []
|
|
69
|
+
@filter_values = []
|
|
70
|
+
@doc_tokens = []
|
|
71
|
+
@doc_tokens_complete = true
|
|
46
72
|
@postings = Hash.new { |h, k| h[k] = [] }
|
|
73
|
+
@slots = Hash.new { |h, k| h[k] = [] }
|
|
74
|
+
@count = 0
|
|
75
|
+
@version = 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.filter_specs(filters)
|
|
79
|
+
filters = filters.to_h { |name| [name, "value"] } if filters.is_a?(Array)
|
|
80
|
+
filters.to_h do |name, spec|
|
|
81
|
+
spec = { "kind" => spec } unless spec.is_a?(Hash)
|
|
82
|
+
spec = spec.to_h { |k, v| [k.to_s, v.to_s] }
|
|
83
|
+
unless FILTER_KINDS.include?(spec["kind"])
|
|
84
|
+
raise ArgumentError, "cjk_index: filter #{name} has kind #{spec['kind'].inspect}; use value or year"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
[name.to_s, spec.slice("kind", "separator")]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Reads an index written by to_json (String) or to_h back, to update it.
|
|
92
|
+
# A value filter keeps the form of each value that the index stored.
|
|
93
|
+
def self.load(index)
|
|
94
|
+
data = index.is_a?(String) ? JSON.parse(index) : index
|
|
95
|
+
unless data.is_a?(Hash) && FORMATS.include?(data["format"])
|
|
96
|
+
raise ArgumentError, "cjk_index: unsupported index format #{data.is_a?(Hash) ? data['format'].inspect : data.class}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
filters = (data["filters"] || {}).transform_values { |f| f.slice("kind", "separator") }
|
|
100
|
+
builder = new(fields: data["fields"], boosts: data["fields"].zip(data["boosts"]).to_h,
|
|
101
|
+
normalizer: Normalizer.new(tables: data["variants"]), filters: filters)
|
|
102
|
+
builder.send(:restore, data)
|
|
103
|
+
builder
|
|
47
104
|
end
|
|
48
105
|
|
|
106
|
+
# Folded form of a value filter's value, used to compare values.
|
|
107
|
+
def self.filter_key(value, normalizer)
|
|
108
|
+
normalizer.normalize(value.to_s).gsub(WHITESPACE, " ").delete_prefix(" ").delete_suffix(" ")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The first four-digit number in a value (after folding full-width digits).
|
|
112
|
+
def self.year(value, normalizer)
|
|
113
|
+
return value.to_i if value.is_a?(Numeric)
|
|
114
|
+
|
|
115
|
+
normalizer.normalize(value.to_s)[/(?<!\d)\d{4}(?!\d)/]&.to_i
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Refs of the documents in the index, in index order.
|
|
119
|
+
def refs = holes? ? @refs.compact : @refs
|
|
120
|
+
|
|
121
|
+
def size = @count
|
|
122
|
+
|
|
123
|
+
def include?(ref) = !@slots[ref.to_s].empty?
|
|
124
|
+
|
|
49
125
|
# doc is a Hash of field name => value. Array values are indexed as
|
|
50
126
|
# separate strings (e.g. multi-valued subject fields).
|
|
51
127
|
def add(ref, doc)
|
|
52
|
-
|
|
128
|
+
slot = @refs.length
|
|
53
129
|
@refs << ref.to_s
|
|
54
|
-
@
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
130
|
+
@slots[ref.to_s] << slot
|
|
131
|
+
@count += 1
|
|
132
|
+
index(slot, doc)
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Removes the first document added with ref. Returns false if there is none.
|
|
137
|
+
def remove(ref)
|
|
138
|
+
slot = @slots[ref.to_s].shift or return false
|
|
139
|
+
|
|
140
|
+
unindex(slot)
|
|
141
|
+
@refs[slot] = nil
|
|
142
|
+
@lengths.fill(nil, slot * @fields.length, @fields.length)
|
|
143
|
+
@filter_values[slot] = nil
|
|
144
|
+
@count -= 1
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Replaces the document with ref in place, or adds it if there is none.
|
|
149
|
+
def update(ref, doc)
|
|
150
|
+
slot = @slots[ref.to_s].first or return add(ref, doc)
|
|
151
|
+
|
|
152
|
+
unindex(slot)
|
|
153
|
+
index(slot, doc)
|
|
67
154
|
self
|
|
68
155
|
end
|
|
69
156
|
|
|
70
157
|
def to_h
|
|
71
|
-
{
|
|
158
|
+
live = holes? ? @refs.each_index.reject { |slot| @refs[slot].nil? } : @refs.each_index.to_a
|
|
159
|
+
h = {
|
|
72
160
|
"format" => FORMAT,
|
|
73
161
|
"variants" => @normalizer.tables,
|
|
74
162
|
"fields" => @fields,
|
|
75
163
|
"boosts" => @boosts.map { |b| b == b.to_i ? b.to_i : b },
|
|
76
|
-
"refs" => @refs,
|
|
77
|
-
"lengths" => @lengths,
|
|
78
|
-
"tokens" => @postings.sort.to_h
|
|
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
|
|
79
167
|
}
|
|
168
|
+
h["filters"] = @filters.to_h { |name, spec| [name, filter_to_h(name, spec, live)] } unless @filters.empty?
|
|
169
|
+
h
|
|
80
170
|
end
|
|
81
171
|
|
|
82
172
|
def to_json(*_args)
|
|
83
173
|
JSON.generate(to_h)
|
|
84
174
|
end
|
|
175
|
+
|
|
176
|
+
# For Searcher: the current documents by slot, with nil for removed ones.
|
|
177
|
+
def live_data # :nodoc:
|
|
178
|
+
{
|
|
179
|
+
"variants" => @normalizer.tables, "fields" => @fields, "boosts" => @boosts,
|
|
180
|
+
"refs" => @refs, "lengths" => @lengths, "tokens" => @postings, "count" => @count,
|
|
181
|
+
"filters" => @filters.to_h do |name, spec|
|
|
182
|
+
[name, { "kind" => spec["kind"], "docs" => @filter_values.map { |v| v && v[name] } }]
|
|
183
|
+
end
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def index(slot, doc)
|
|
190
|
+
in_place = slot < @refs.length - 1
|
|
191
|
+
tokens = []
|
|
192
|
+
@fields.each_with_index do |field, field_index|
|
|
193
|
+
positions = Hash.new { |h, k| h[k] = [] }
|
|
194
|
+
base = 0
|
|
195
|
+
Array(value_of(doc, field)).each do |v|
|
|
196
|
+
text = v.to_s
|
|
197
|
+
Tokenizer.tokens_with_offsets(text, unigrams: true, normalizer: @normalizer)
|
|
198
|
+
.each { |t, offset| positions[t] << (base + offset) }
|
|
199
|
+
base += @normalizer.normalize(text).length + VALUE_GAP
|
|
200
|
+
end
|
|
201
|
+
@lengths[(slot * @fields.length) + field_index] = [base - VALUE_GAP, 0].max
|
|
202
|
+
positions.each do |token, offsets|
|
|
203
|
+
postings = @postings[token]
|
|
204
|
+
# A document replaced in place goes before the later slots, so that
|
|
205
|
+
# every list stays in the order a fresh build writes.
|
|
206
|
+
at = in_place ? insertion_point(postings, slot, field_index) : postings.length
|
|
207
|
+
postings.insert(at, slot, field_index, offsets.length, *offsets)
|
|
208
|
+
end
|
|
209
|
+
tokens.concat(positions.keys)
|
|
210
|
+
end
|
|
211
|
+
@doc_tokens[slot] = tokens.uniq
|
|
212
|
+
@filter_values[slot] = @filters.to_h { |name, spec| [name, filter_value(spec, value_of(doc, name))] }
|
|
213
|
+
@version += 1
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def unindex(slot)
|
|
217
|
+
doc_tokens(slot).each do |token|
|
|
218
|
+
postings = @postings[token]
|
|
219
|
+
kept = []
|
|
220
|
+
i = 0
|
|
221
|
+
while i < postings.length
|
|
222
|
+
n = 3 + postings[i + 2]
|
|
223
|
+
kept.concat(postings[i, n]) unless postings[i] == slot
|
|
224
|
+
i += n
|
|
225
|
+
end
|
|
226
|
+
if kept.empty? then @postings.delete(token)
|
|
227
|
+
else @postings[token] = kept
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
@doc_tokens[slot] = nil
|
|
231
|
+
@version += 1
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def value_of(doc, field) = doc[field] || doc[field.to_sym]
|
|
235
|
+
|
|
236
|
+
# Value filter: [[key, value as written], ...]; year filter: Integer or nil.
|
|
237
|
+
def filter_value(spec, raw)
|
|
238
|
+
if spec["kind"] == "year"
|
|
239
|
+
Array(raw).each do |v|
|
|
240
|
+
year = Builder.year(v, @normalizer)
|
|
241
|
+
return year if year
|
|
242
|
+
end
|
|
243
|
+
return nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
Array(raw).flat_map { |v| spec["separator"] ? v.to_s.split(spec["separator"]) : [v.to_s] }
|
|
247
|
+
.map { |v| v.gsub(WHITESPACE, " ").strip }.reject(&:empty?)
|
|
248
|
+
.map { |v| [Builder.filter_key(v, @normalizer), v] }.uniq(&:first)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def filter_to_h(name, spec, live)
|
|
252
|
+
return { "kind" => "year", "docs" => live.map { |slot| @filter_values[slot][name] } } if spec["kind"] == "year"
|
|
253
|
+
|
|
254
|
+
ids = {}
|
|
255
|
+
values = []
|
|
256
|
+
docs = live.map do |slot|
|
|
257
|
+
@filter_values[slot][name].map do |key, value|
|
|
258
|
+
ids.fetch(key) do
|
|
259
|
+
values << value
|
|
260
|
+
ids[key] = values.length - 1
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
{ "kind" => "value", "values" => values, "docs" => docs }.merge(spec.slice("separator"))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def holes? = @count < @refs.length
|
|
268
|
+
|
|
269
|
+
# Where an entry for slot and field goes in a list ordered by slot, then field.
|
|
270
|
+
def insertion_point(postings, slot, field_index)
|
|
271
|
+
i = 0
|
|
272
|
+
while i < postings.length && (postings[i] < slot || (postings[i] == slot && postings[i + 1] < field_index))
|
|
273
|
+
i += 3 + postings[i + 2]
|
|
274
|
+
end
|
|
275
|
+
i
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Postings with slots renumbered to positions among the live documents.
|
|
279
|
+
# Removed slots appear in no list any more, and renumbering keeps the
|
|
280
|
+
# order, so sorted lists stay sorted.
|
|
281
|
+
def renumbered_postings(live)
|
|
282
|
+
number = []
|
|
283
|
+
live.each_with_index { |slot, doc| number[slot] = doc }
|
|
284
|
+
@postings.sort.to_h do |token, postings|
|
|
285
|
+
postings = postings.dup
|
|
286
|
+
i = 0
|
|
287
|
+
while i < postings.length
|
|
288
|
+
postings[i] = number[postings[i]]
|
|
289
|
+
i += 3 + postings[i + 2]
|
|
290
|
+
end
|
|
291
|
+
[token, postings]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Tokens of a slot. An index read with load does not record them, so the
|
|
296
|
+
# first change scans the postings once.
|
|
297
|
+
def doc_tokens(slot)
|
|
298
|
+
unless @doc_tokens_complete
|
|
299
|
+
@postings.each do |token, postings|
|
|
300
|
+
i = 0
|
|
301
|
+
while i < postings.length
|
|
302
|
+
(@doc_tokens[postings[i]] ||= []) << token
|
|
303
|
+
i += 3 + postings[i + 2]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
@doc_tokens.each { |tokens| tokens&.uniq! }
|
|
307
|
+
@doc_tokens_complete = true
|
|
308
|
+
end
|
|
309
|
+
@doc_tokens[slot] || []
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def restore(data)
|
|
313
|
+
@refs = data["refs"].dup
|
|
314
|
+
@refs.each_with_index { |ref, slot| @slots[ref] << slot }
|
|
315
|
+
@count = @refs.length
|
|
316
|
+
@lengths = data["lengths"].dup
|
|
317
|
+
unless @lengths.length == @count * @fields.length
|
|
318
|
+
raise ArgumentError, "cjk_index: index has #{@lengths.length} lengths for #{@count} documents"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
data["tokens"].each { |token, postings| @postings[token] = postings.dup }
|
|
322
|
+
filters = data["filters"] || {}
|
|
323
|
+
@filter_values = Array.new(@count) do |slot|
|
|
324
|
+
filters.to_h do |name, f|
|
|
325
|
+
value = f["docs"][slot]
|
|
326
|
+
next [name, value] if f["kind"] == "year"
|
|
327
|
+
|
|
328
|
+
[name, value.map { |id| [Builder.filter_key(f["values"][id], @normalizer), f["values"][id]] }]
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
@doc_tokens = []
|
|
332
|
+
@doc_tokens_complete = false
|
|
333
|
+
end
|
|
85
334
|
end
|
|
86
335
|
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CJKIndex
|
|
4
|
+
# An index file shared by several processes, such as the workers of a Rails
|
|
5
|
+
# app: any number of readers, and one writer at a time.
|
|
6
|
+
#
|
|
7
|
+
# INDEX = CJKIndex::IndexFile.new("db/search-index.json")
|
|
8
|
+
# INDEX.write(builder) # a full build
|
|
9
|
+
# INDEX.update { |builder| builder.update(ref, doc) } # from any process
|
|
10
|
+
# INDEX.searcher.search("東京") # sees the last update
|
|
11
|
+
#
|
|
12
|
+
# update takes an exclusive lock on "<path>.lock", reads the index, runs the
|
|
13
|
+
# block and writes the result, so updates from different processes are
|
|
14
|
+
# applied one after another and none is lost. The lock is released when the
|
|
15
|
+
# process ends, also when it crashes. An index is written to a temporary
|
|
16
|
+
# file and renamed over the old one, so a reader gets the old index or the
|
|
17
|
+
# new one, never a partly written file.
|
|
18
|
+
#
|
|
19
|
+
# Each reader keeps the index it loaded (a snapshot) and loads the file
|
|
20
|
+
# again when it has changed: checked on each call to searcher, or once every
|
|
21
|
+
# interval seconds. While one thread reloads, the others go on with the old
|
|
22
|
+
# snapshot. The process that wrote an update sees it on its next call.
|
|
23
|
+
#
|
|
24
|
+
# The file is never changed in place. A writer killed at any point leaves
|
|
25
|
+
# the previous index intact, the OS releases its lock, and its temporary
|
|
26
|
+
# file is removed by the next writer; there is nothing to recover.
|
|
27
|
+
#
|
|
28
|
+
# Locks use flock, which is reliable on a local disk; do not share the file
|
|
29
|
+
# over NFS.
|
|
30
|
+
class IndexFile
|
|
31
|
+
# Raised by update and write when another writer holds the lock too long.
|
|
32
|
+
class LockTimeout < StandardError; end
|
|
33
|
+
|
|
34
|
+
attr_reader :path
|
|
35
|
+
|
|
36
|
+
# lock_timeout: seconds update and write wait for another writer.
|
|
37
|
+
# options are passed to Searcher (prefix:, typos:).
|
|
38
|
+
def initialize(path, interval: 0, lock_timeout: 60, **options)
|
|
39
|
+
@path = path.to_s
|
|
40
|
+
@lock_path = "#{@path}.lock"
|
|
41
|
+
@interval = interval
|
|
42
|
+
@lock_timeout = lock_timeout
|
|
43
|
+
@options = options
|
|
44
|
+
@read_mutex = Mutex.new
|
|
45
|
+
@write_mutex = Mutex.new
|
|
46
|
+
@searcher = nil
|
|
47
|
+
@searcher_stamp = nil
|
|
48
|
+
@checked_at = nil
|
|
49
|
+
@builder = nil
|
|
50
|
+
@builder_stamp = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A Searcher for the current file, loaded again if the file has changed.
|
|
54
|
+
def searcher
|
|
55
|
+
return @searcher if @searcher && !due?
|
|
56
|
+
|
|
57
|
+
if @searcher
|
|
58
|
+
# Another thread is checking or loading: use the snapshot we have.
|
|
59
|
+
return @searcher unless @read_mutex.try_lock
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
reload
|
|
63
|
+
ensure
|
|
64
|
+
@read_mutex.unlock
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
@read_mutex.synchronize { reload }
|
|
68
|
+
end
|
|
69
|
+
@searcher
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Applies the block to a Builder holding the index in the file, and writes
|
|
73
|
+
# the result. The file must exist (see write). If the block raises,
|
|
74
|
+
# nothing is written.
|
|
75
|
+
def update
|
|
76
|
+
@write_mutex.synchronize do
|
|
77
|
+
lock do
|
|
78
|
+
# The lock is held, so the file cannot change between stat and read.
|
|
79
|
+
@builder = Builder.load(File.read(@path, encoding: "UTF-8")) unless @builder && stamp == @builder_stamp
|
|
80
|
+
done = false
|
|
81
|
+
begin
|
|
82
|
+
yield @builder
|
|
83
|
+
done = true
|
|
84
|
+
ensure
|
|
85
|
+
@builder = nil unless done # half-applied changes; read the file again next time
|
|
86
|
+
end
|
|
87
|
+
@builder_stamp = replace(@builder.to_json)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
@checked_at = nil
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Replaces the index in the file with builder's (e.g. after a full build).
|
|
95
|
+
def write(builder)
|
|
96
|
+
@write_mutex.synchronize do
|
|
97
|
+
lock do
|
|
98
|
+
replace(builder.to_json)
|
|
99
|
+
@builder = nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
@checked_at = nil
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
109
|
+
|
|
110
|
+
def due? = @checked_at.nil? || now - @checked_at >= @interval
|
|
111
|
+
|
|
112
|
+
# What identifies one written version of the file. rename gives the new
|
|
113
|
+
# file a new inode; mtime and size cover systems without inodes. Always
|
|
114
|
+
# from File.stat: on Windows the stat of an open file differs from it.
|
|
115
|
+
def stamp
|
|
116
|
+
stat = File.stat(@path)
|
|
117
|
+
[stat.ino, stat.dev, stat.size, stat.mtime.to_r]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def reload
|
|
121
|
+
return unless due?
|
|
122
|
+
|
|
123
|
+
@checked_at = now
|
|
124
|
+
# stat does not keep the file open; on Windows an open file cannot be
|
|
125
|
+
# renamed over, so open it only when it has changed.
|
|
126
|
+
current = stamp
|
|
127
|
+
return if @searcher && current == @searcher_stamp
|
|
128
|
+
|
|
129
|
+
# If a writer renames between stat and read, this reads the newer file
|
|
130
|
+
# under the older stamp, and the next check loads it once more.
|
|
131
|
+
@searcher = Searcher.new(windows_retry { File.read(@path, encoding: "UTF-8") }, **@options)
|
|
132
|
+
@searcher_stamp = current
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Waits for the lock for at most lock_timeout seconds, so that a writer
|
|
136
|
+
# that hangs while holding it cannot block the others for ever.
|
|
137
|
+
def lock
|
|
138
|
+
File.open(@lock_path, File::RDWR | File::CREAT, 0o644) do |f|
|
|
139
|
+
deadline = now + @lock_timeout
|
|
140
|
+
until f.flock(File::LOCK_EX | File::LOCK_NB)
|
|
141
|
+
raise LockTimeout, "cjk_index: #{@lock_path} still locked after #{@lock_timeout} s" if now >= deadline
|
|
142
|
+
|
|
143
|
+
sleep 0.01
|
|
144
|
+
end
|
|
145
|
+
remove_stale_files
|
|
146
|
+
yield
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Temporary files are made only while the lock is held, so any found now
|
|
151
|
+
# were left by a writer that was killed.
|
|
152
|
+
def remove_stale_files
|
|
153
|
+
dir = File.dirname(@path)
|
|
154
|
+
prefix = "#{File.basename(@path)}."
|
|
155
|
+
Dir.each_child(dir) do |name|
|
|
156
|
+
File.delete(File.join(dir, name)) if name.start_with?(prefix) && name.end_with?(".tmp")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Writes json next to the file and renames it over the file. Returns the
|
|
161
|
+
# stamp of the new file.
|
|
162
|
+
def replace(json)
|
|
163
|
+
tmp = "#{@path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
164
|
+
File.open(tmp, "w:UTF-8") do |f|
|
|
165
|
+
f.write(json)
|
|
166
|
+
f.flush
|
|
167
|
+
f.fsync
|
|
168
|
+
end
|
|
169
|
+
windows_retry { File.rename(tmp, @path) }
|
|
170
|
+
sync_dir
|
|
171
|
+
stamp
|
|
172
|
+
ensure
|
|
173
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The rename itself survives a power loss only once the directory is
|
|
177
|
+
# synced too (Linux ext4, xfs). Windows cannot open a directory; skip it.
|
|
178
|
+
def sync_dir
|
|
179
|
+
File.open(File.dirname(@path), &:fsync)
|
|
180
|
+
rescue SystemCallError, IOError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# On Windows, renaming over a file fails while a reader has it open, and
|
|
185
|
+
# opening it can fail while it is being replaced. Both last only a
|
|
186
|
+
# moment, so try again for up to 5 s.
|
|
187
|
+
def windows_retry
|
|
188
|
+
deadline = now + 5
|
|
189
|
+
begin
|
|
190
|
+
yield
|
|
191
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
192
|
+
raise if now >= deadline
|
|
193
|
+
|
|
194
|
+
sleep 0.01
|
|
195
|
+
retry
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
data/lib/cjk_index/jekyll.rb
CHANGED
|
@@ -19,6 +19,10 @@ module CJKIndex
|
|
|
19
19
|
# data: metadata # ...rows of _data/metadata.csv
|
|
20
20
|
# ref: objectid # column used as the result's ref
|
|
21
21
|
# fields: [title, creator, subject]
|
|
22
|
+
# filters: # fields to filter on (optional)
|
|
23
|
+
# type: value
|
|
24
|
+
# subject: { kind: value, separator: ";" }
|
|
25
|
+
# date: year
|
|
22
26
|
# - name: search
|
|
23
27
|
# preset: collectionbuilder # reads CollectionBuilder's own config
|
|
24
28
|
#
|
|
@@ -70,7 +74,7 @@ module CJKIndex
|
|
|
70
74
|
raise ::Jekyll::Errors::FatalException, "cjk_index: unknown preset #{spec['preset']}" if spec["preset"]
|
|
71
75
|
|
|
72
76
|
builder = Builder.new(fields: Array(spec.fetch("fields")), boosts: spec.fetch("boosts", {}),
|
|
73
|
-
normalizer: normalizer)
|
|
77
|
+
normalizer: normalizer, filters: spec.fetch("filters", {}))
|
|
74
78
|
if spec["collection"]
|
|
75
79
|
docs = site.collections.fetch(spec["collection"]) do
|
|
76
80
|
raise ::Jekyll::Errors::FatalException, "cjk_index: no collection #{spec['collection']}"
|
|
@@ -97,6 +101,9 @@ module CJKIndex
|
|
|
97
101
|
# items with an objectid, child objects only when the theme asks for them,
|
|
98
102
|
# fields marked index=true in _data/config-search.csv, and the same "id"
|
|
99
103
|
# values, so results can be looked up in the existing search store.
|
|
104
|
+
# Filters default to the theme's metadata-facets-fields (values
|
|
105
|
+
# separated by ";", as CollectionBuilder writes them) and a year from
|
|
106
|
+
# the date column.
|
|
100
107
|
module CollectionBuilder
|
|
101
108
|
module_function
|
|
102
109
|
|
|
@@ -107,7 +114,8 @@ module CJKIndex
|
|
|
107
114
|
.select { |f| f["index"].to_s == "true" }
|
|
108
115
|
.map { |f| f["field"] }
|
|
109
116
|
boosts = spec.fetch("boosts") { fields.first ? { fields.first => 3 } : {} }
|
|
110
|
-
builder = Builder.new(fields: fields, boosts: boosts, normalizer: normalizer
|
|
117
|
+
builder = Builder.new(fields: fields, boosts: boosts, normalizer: normalizer,
|
|
118
|
+
filters: spec.fetch("filters") { default_filters(site, rows) })
|
|
111
119
|
rows.each do |row|
|
|
112
120
|
next if row["objectid"].to_s.empty?
|
|
113
121
|
next if !row["parentid"].to_s.empty? && !children
|
|
@@ -117,6 +125,13 @@ module CJKIndex
|
|
|
117
125
|
builder
|
|
118
126
|
end
|
|
119
127
|
|
|
128
|
+
def default_filters(site, rows)
|
|
129
|
+
facets = site.data.dig("theme", "metadata-facets-fields").to_s.split(",").map(&:strip).reject(&:empty?)
|
|
130
|
+
filters = facets.to_h { |f| [f, { "kind" => "value", "separator" => ";" }] }
|
|
131
|
+
filters["date"] = "year" if rows.any? { |row| row.key?("date") } && !filters.key?("date")
|
|
132
|
+
filters
|
|
133
|
+
end
|
|
134
|
+
|
|
120
135
|
def ref_for(row)
|
|
121
136
|
parent = row["parentid"].to_s
|
|
122
137
|
parent.empty? ? "#{row['objectid']}.html" : "#{parent}.html##{row['objectid']}"
|
data/lib/cjk_index/runtime.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
var TABLES = /*@TABLES@*/[];
|
|
12
12
|
var CJK = new RegExp(/*@CJK@*/'', 'u');
|
|
13
13
|
var SEPARATOR = new RegExp(/*@SEPARATOR@*/'', 'u');
|
|
14
|
-
var
|
|
14
|
+
var FORMATS = ['cjk_index/2', 'cjk_index/3'];
|
|
15
15
|
|
|
16
16
|
// BM25 parameters (the usual defaults, as in Lucene and Pagefind).
|
|
17
17
|
var K1 = 1.2;
|
|
@@ -89,6 +89,11 @@
|
|
|
89
89
|
|
|
90
90
|
// Substring test on normalized text, ignoring whitespace. Useful for
|
|
91
91
|
// filters that do not need the index (e.g. a browse page).
|
|
92
|
+
// Folded form of a filter value: as Builder.filter_key in Ruby.
|
|
93
|
+
function filterKey(value) {
|
|
94
|
+
return normalize(value).replace(/[ \t\n\r\f\v]+/g, ' ').replace(/^ /, '').replace(/ $/, '');
|
|
95
|
+
}
|
|
96
|
+
|
|
92
97
|
function includes(haystack, needle) {
|
|
93
98
|
var strip = function (s) { return normalize(s).replace(/\s+/g, ''); };
|
|
94
99
|
return strip(haystack).indexOf(strip(needle)) !== -1;
|
|
@@ -107,7 +112,7 @@
|
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
function Index(data, options) {
|
|
110
|
-
if (!data || data.format
|
|
115
|
+
if (!data || FORMATS.indexOf(data.format) < 0) {
|
|
111
116
|
throw new Error('cjk_index: unsupported index format ' + (data && data.format));
|
|
112
117
|
}
|
|
113
118
|
if (JSON.stringify(data.variants) !== JSON.stringify(TABLES)) {
|
|
@@ -127,8 +132,89 @@
|
|
|
127
132
|
for (var i = 0; i < this.lengths.length; i++) avg[i % nf] += this.lengths[i];
|
|
128
133
|
this.avgLength = avg.map(function (s) { return s / Math.max(1, data.refs.length) || 1; });
|
|
129
134
|
this.latinTokens = Object.keys(this.tokens).filter(isLatin).sort();
|
|
135
|
+
this.slotOf = new Map();
|
|
136
|
+
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
|
|
138
|
+
// form to show for each key and its rank of first appearance.
|
|
139
|
+
this.filters = {};
|
|
140
|
+
var filters = data.filters || {};
|
|
141
|
+
Object.keys(filters).forEach(function (name) {
|
|
142
|
+
var f = filters[name];
|
|
143
|
+
var docs = f.docs;
|
|
144
|
+
var display = new Map();
|
|
145
|
+
if (f.kind !== 'year') {
|
|
146
|
+
var keys = f.values.map(filterKey);
|
|
147
|
+
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]; }); });
|
|
149
|
+
}
|
|
150
|
+
var rank = new Map();
|
|
151
|
+
docs.forEach(function (value) {
|
|
152
|
+
(f.kind === 'year' ? (value === null ? [] : [value]) : value).forEach(function (key) {
|
|
153
|
+
if (!rank.has(key)) rank.set(key, rank.size);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
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 };
|
|
158
|
+
}, this);
|
|
130
159
|
}
|
|
131
160
|
|
|
161
|
+
// [[filter, test], ...] from the filter option of search.
|
|
162
|
+
Index.prototype._compileFilter = function (filter) {
|
|
163
|
+
var self = this;
|
|
164
|
+
var out = [];
|
|
165
|
+
Object.keys(filter).forEach(function (name) {
|
|
166
|
+
var f = self.filters[name];
|
|
167
|
+
if (!f) throw new Error('cjk_index: no filter ' + name);
|
|
168
|
+
var c = filter[name];
|
|
169
|
+
if (c === null || c === undefined || (Array.isArray(c) && !c.length)) return;
|
|
170
|
+
if (f.kind === 'year') {
|
|
171
|
+
var min = typeof c === 'object' ? c.min : c;
|
|
172
|
+
var max = typeof c === 'object' ? c.max : c;
|
|
173
|
+
out.push([f, function (year) {
|
|
174
|
+
return year !== null && (min === null || min === undefined || year >= min) &&
|
|
175
|
+
(max === null || max === undefined || year <= max);
|
|
176
|
+
}]);
|
|
177
|
+
} 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); }); }]);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return out;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
Index.prototype._pass = function (doc, conditions) {
|
|
186
|
+
for (var i = 0; i < conditions.length; i++) if (!conditions[i][1](conditions[i][0].docs[doc])) return false;
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Counts of the values of a filter, for a facet list: over results (as
|
|
191
|
+
// returned by search, or refs) or over the whole index. Returns
|
|
192
|
+
// [{value, count}], the most frequent first, ties in index order.
|
|
193
|
+
Index.prototype.facets = function (field, results) {
|
|
194
|
+
var self = this;
|
|
195
|
+
var f = this.filters[field];
|
|
196
|
+
if (!f) throw new Error('cjk_index: no filter ' + field);
|
|
197
|
+
var docs = [];
|
|
198
|
+
if (results) {
|
|
199
|
+
results.forEach(function (r) {
|
|
200
|
+
var d = self.slotOf.get(typeof r === 'object' ? r.ref : String(r));
|
|
201
|
+
if (d !== undefined) docs.push(d);
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
for (var d = 0; d < this.refs.length; d++) docs.push(d);
|
|
205
|
+
}
|
|
206
|
+
var counts = new Map();
|
|
207
|
+
docs.forEach(function (doc) {
|
|
208
|
+
var v = f.docs[doc];
|
|
209
|
+
(f.kind === 'year' ? (v === null ? [] : [v]) : v).forEach(function (key) {
|
|
210
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
var out = Array.from(counts.entries());
|
|
214
|
+
out.sort(function (a, b) { return b[1] - a[1] || f.rank.get(a[0]) - f.rank.get(b[0]); });
|
|
215
|
+
return out.map(function (e) { return { value: f.display.get(e[0]), count: e[1] }; });
|
|
216
|
+
};
|
|
217
|
+
|
|
132
218
|
// Map of "doc,field" key -> Set of positions, merged over the alternatives.
|
|
133
219
|
Index.prototype._positions = function (tokens) {
|
|
134
220
|
var nf = this.fields.length;
|
|
@@ -168,12 +254,23 @@
|
|
|
168
254
|
// Every phrase of the query must occur in the document (in any field),
|
|
169
255
|
// with its tokens adjacent and in order. Returns [{ref, score, fields}]
|
|
170
256
|
// with the best match first; fields lists the names of the fields that matched.
|
|
171
|
-
|
|
257
|
+
// options.filter: { type: '地図', subject: ['植物', '動物'], date: { min: 1900, max: 1950 } }
|
|
258
|
+
// keeps the documents that meet every condition (see Searcher#search in Ruby).
|
|
259
|
+
// With a filter, a blank query returns every document that passes it.
|
|
260
|
+
Index.prototype.search = function (query, options) {
|
|
172
261
|
var self = this;
|
|
173
262
|
var nf = this.fields.length;
|
|
174
263
|
var n = this.refs.length;
|
|
264
|
+
var conditions = options && options.filter ? this._compileFilter(options.filter) : null;
|
|
175
265
|
var qp = phrases(query, false);
|
|
176
|
-
if (!qp.length)
|
|
266
|
+
if (!qp.length) {
|
|
267
|
+
if (!conditions) return [];
|
|
268
|
+
var all = [];
|
|
269
|
+
for (var d = 0; d < n; d++) {
|
|
270
|
+
if (this._pass(d, conditions)) all.push({ ref: this.refs[d], score: 0, fields: [] });
|
|
271
|
+
}
|
|
272
|
+
return all;
|
|
273
|
+
}
|
|
177
274
|
var scores = new Map();
|
|
178
275
|
var matchedFields = new Map();
|
|
179
276
|
var docsSoFar = null;
|
|
@@ -225,6 +322,7 @@
|
|
|
225
322
|
|
|
226
323
|
var results = [];
|
|
227
324
|
docsSoFar.forEach(function (doc) {
|
|
325
|
+
if (conditions && !self._pass(doc, conditions)) return;
|
|
228
326
|
results.push({ ref: self.refs[doc], score: scores.get(doc), fields: Array.from(matchedFields.get(doc)), doc: doc });
|
|
229
327
|
});
|
|
230
328
|
results.sort(function (a, b) { return b.score - a.score || a.doc - b.doc; });
|
|
@@ -310,7 +408,7 @@
|
|
|
310
408
|
}
|
|
311
409
|
|
|
312
410
|
var api = {
|
|
313
|
-
tables: TABLES, normalize: normalize, tokenize: tokenize, phrases: phrases, includes: includes,
|
|
411
|
+
tables: TABLES, normalize: normalize, filterKey: filterKey, tokenize: tokenize, phrases: phrases, includes: includes,
|
|
314
412
|
highlight: highlight, excerpt: excerpt, Index: Index, load: load
|
|
315
413
|
};
|
|
316
414
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
data/lib/cjk_index/searcher.rb
CHANGED
|
@@ -21,9 +21,11 @@ module CJKIndex
|
|
|
21
21
|
# Score multiplier for matches found by prefix or typo expansion.
|
|
22
22
|
EXPANDED = 0.6
|
|
23
23
|
|
|
24
|
-
attr_reader :fields, :
|
|
24
|
+
attr_reader :fields, :normalizer
|
|
25
25
|
|
|
26
26
|
# index is a Builder, its to_h, or the JSON it wrote (String or parsed).
|
|
27
|
+
# A Searcher made from a Builder answers for the builder's current
|
|
28
|
+
# documents, also after later add, remove and update.
|
|
27
29
|
# prefix: the last Latin word of a query also matches longer words.
|
|
28
30
|
# typos: Latin words of 5+ letters also match words one edit away.
|
|
29
31
|
def self.load(path, **options)
|
|
@@ -31,13 +33,16 @@ module CJKIndex
|
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def initialize(index, prefix: true, typos: true, normalizer: nil)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
@prefix = prefix
|
|
37
|
+
@typos = typos
|
|
38
|
+
if index.is_a?(Builder)
|
|
39
|
+
@builder = index
|
|
40
|
+
data = index.live_data
|
|
41
|
+
else
|
|
42
|
+
data = index.is_a?(String) ? JSON.parse(index) : index
|
|
43
|
+
unless data.is_a?(Hash) && Builder::FORMATS.include?(data["format"])
|
|
44
|
+
raise ArgumentError, "cjk_index: unsupported index format #{data.is_a?(Hash) ? data['format'].inspect : data.class}"
|
|
45
|
+
end
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
@normalizer = normalizer || Normalizer.new(tables: data["variants"])
|
|
@@ -46,28 +51,43 @@ module CJKIndex
|
|
|
46
51
|
"but this normalizer folds with #{@normalizer.tables.inspect}; rebuild both together"
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
setup(data)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Refs of the documents in the index, in index order.
|
|
58
|
+
def refs
|
|
59
|
+
refresh
|
|
60
|
+
@refs.compact
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Names of the filters the index was built with.
|
|
64
|
+
def filters
|
|
65
|
+
refresh
|
|
66
|
+
@filters.keys
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
# Every phrase of the query must occur in the document (in any field),
|
|
64
70
|
# with its tokens adjacent and in order. Returns [{ ref:, score:, fields: }]
|
|
65
71
|
# with the best match first; fields lists the fields that matched.
|
|
66
|
-
|
|
72
|
+
#
|
|
73
|
+
# filter: { "type" => "地図", "subject" => ["植物", "動物"], "date" => 1900..1950 }
|
|
74
|
+
# keeps the documents that meet every condition: a value filter matches
|
|
75
|
+
# any of the given values (folded like the query), a year filter takes a
|
|
76
|
+
# Range, { "min" => 1900, "max" => 1950 } or one year. Scores do not
|
|
77
|
+
# depend on the filter. With a filter, a blank query returns every
|
|
78
|
+
# document that passes it, in index order, with score 0.
|
|
79
|
+
def search(query, filter: nil)
|
|
80
|
+
refresh
|
|
67
81
|
nf = @fields.length
|
|
68
|
-
n = @
|
|
82
|
+
n = @count
|
|
83
|
+
conditions = filter && compile_filter(filter)
|
|
69
84
|
qp = Tokenizer.phrases(query, normalizer: @normalizer)
|
|
70
|
-
|
|
85
|
+
if qp.empty?
|
|
86
|
+
return [] unless conditions
|
|
87
|
+
|
|
88
|
+
return @refs.each_index.select { |doc| @refs[doc] && pass?(doc, conditions) }
|
|
89
|
+
.map { |doc| { ref: @refs[doc], score: 0.0, fields: [] } }
|
|
90
|
+
end
|
|
71
91
|
|
|
72
92
|
scores = Hash.new(0.0)
|
|
73
93
|
matched_fields = Hash.new { |h, k| h[k] = [] }
|
|
@@ -108,16 +128,108 @@ module CJKIndex
|
|
|
108
128
|
end
|
|
109
129
|
end
|
|
110
130
|
|
|
131
|
+
docs_so_far = docs_so_far.select { |doc| pass?(doc, conditions) } if conditions
|
|
111
132
|
docs_so_far.sort_by { |doc| [-scores[doc], doc] }
|
|
112
133
|
.map { |doc| { ref: @refs[doc], score: scores[doc], fields: matched_fields[doc] } }
|
|
113
134
|
end
|
|
114
135
|
|
|
136
|
+
# Counts of the values of a filter, for a facet list: over results (as
|
|
137
|
+
# returned by search, or refs) or over the whole index. Returns
|
|
138
|
+
# [{ value:, count: }], the most frequent first, ties in index order.
|
|
139
|
+
# Year filters count each year.
|
|
140
|
+
def facets(field, results = nil)
|
|
141
|
+
refresh
|
|
142
|
+
filter = @filters.fetch(field.to_s) { raise ArgumentError, "cjk_index: no filter #{field}" }
|
|
143
|
+
docs = if results
|
|
144
|
+
results.filter_map { |r| @slot_of[r.is_a?(Hash) ? (r[:ref] || r["ref"]) : r.to_s] }
|
|
145
|
+
else
|
|
146
|
+
@refs.each_index.select { |doc| @refs[doc] }
|
|
147
|
+
end
|
|
148
|
+
counts = Hash.new(0)
|
|
149
|
+
docs.each do |doc|
|
|
150
|
+
value = filter[:docs][doc]
|
|
151
|
+
if filter[:kind] == "year" then counts[value] += 1 unless value.nil?
|
|
152
|
+
else value.each { |key| counts[key] += 1 }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
counts.sort_by { |key, count| [-count, filter[:rank][key]] }
|
|
156
|
+
.map { |key, count| { value: filter[:display][key], count: count } }
|
|
157
|
+
end
|
|
158
|
+
|
|
115
159
|
def highlight(text, query) = CJKIndex.highlight(text, query, normalizer: @normalizer)
|
|
116
160
|
|
|
117
161
|
def excerpt(text, query, length = 80) = CJKIndex.excerpt(text, query, length, normalizer: @normalizer)
|
|
118
162
|
|
|
119
163
|
private
|
|
120
164
|
|
|
165
|
+
def setup(data)
|
|
166
|
+
@version = @builder&.version
|
|
167
|
+
@fields = data["fields"]
|
|
168
|
+
@boosts = data["boosts"]
|
|
169
|
+
@refs = data["refs"]
|
|
170
|
+
@lengths = data["lengths"]
|
|
171
|
+
@tokens = data["tokens"]
|
|
172
|
+
@count = data["count"] || @refs.length
|
|
173
|
+
nf = @fields.length
|
|
174
|
+
avg = Array.new(nf, 0.0)
|
|
175
|
+
@lengths.each_with_index { |len, i| avg[i % nf] += len if len }
|
|
176
|
+
@avg_length = avg.map { |s| (v = s / [1, @count].max).zero? ? 1 : v }
|
|
177
|
+
@latin_tokens = @tokens.keys.select { |t| latin?(t) }.sort
|
|
178
|
+
@slot_of = {}
|
|
179
|
+
@refs.each_with_index { |ref, doc| @slot_of[ref] ||= doc if ref }
|
|
180
|
+
@filters = (data["filters"] || {}).to_h { |name, f| [name, setup_filter(f)] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Filter values by document: keys (value filters) or a year, with the
|
|
184
|
+
# form to show for each key and its rank of first appearance.
|
|
185
|
+
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 } }
|
|
196
|
+
end
|
|
197
|
+
rank = {}
|
|
198
|
+
docs.each_with_index do |value, doc|
|
|
199
|
+
next unless @refs[doc]
|
|
200
|
+
|
|
201
|
+
Array(value).each { |key| rank[key] ||= rank.size }
|
|
202
|
+
end
|
|
203
|
+
display = rank.keys.to_h { |year| [year, year] } if f["kind"] == "year"
|
|
204
|
+
{ kind: f["kind"], docs: docs, display: display, rank: rank }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def refresh
|
|
208
|
+
setup(@builder.live_data) if @builder && @builder.version != @version
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# [[filter, test], ...] from a filter argument of search.
|
|
212
|
+
def compile_filter(filter)
|
|
213
|
+
filter.filter_map do |name, condition|
|
|
214
|
+
f = @filters.fetch(name.to_s) { raise ArgumentError, "cjk_index: no filter #{name}" }
|
|
215
|
+
next if condition.nil? || (condition.is_a?(Array) && condition.empty?)
|
|
216
|
+
|
|
217
|
+
if f[:kind] == "year"
|
|
218
|
+
range = case condition
|
|
219
|
+
when Range then condition
|
|
220
|
+
when Hash then (condition["min"] || condition[:min])..(condition["max"] || condition[:max])
|
|
221
|
+
else Integer(condition)..Integer(condition)
|
|
222
|
+
end
|
|
223
|
+
[f, ->(year) { !year.nil? && range.cover?(year) }]
|
|
224
|
+
else
|
|
225
|
+
keys = Array(condition).map { |v| Builder.filter_key(v, @normalizer) }.to_set
|
|
226
|
+
[f, ->(values) { values.any? { |key| keys.include?(key) } }]
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def pass?(doc, conditions) = conditions.all? { |f, test| test.call(f[:docs][doc]) }
|
|
232
|
+
|
|
121
233
|
def latin?(token) = !token.match?(Tokenizer::CJK)
|
|
122
234
|
|
|
123
235
|
# "doc * nf + field" => Set of positions, merged over the alternatives.
|
|
@@ -125,7 +237,7 @@ module CJKIndex
|
|
|
125
237
|
nf = @fields.length
|
|
126
238
|
map = {}
|
|
127
239
|
tokens.each do |token|
|
|
128
|
-
postings = @tokens
|
|
240
|
+
postings = @tokens.fetch(token, nil) or next
|
|
129
241
|
i = 0
|
|
130
242
|
while i < postings.length
|
|
131
243
|
count = postings[i + 2]
|
data/lib/cjk_index/version.rb
CHANGED
data/lib/cjk_index.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "cjk_index/tokenizer"
|
|
|
6
6
|
require_relative "cjk_index/builder"
|
|
7
7
|
require_relative "cjk_index/runtime"
|
|
8
8
|
require_relative "cjk_index/searcher"
|
|
9
|
+
require_relative "cjk_index/index_file"
|
|
9
10
|
|
|
10
11
|
# Dictionary-free full-text search for Chinese, Japanese and Korean text:
|
|
11
12
|
# build the index in Ruby, query it in Ruby (Searcher) or in the browser
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cjk_index
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Satoru Nakamura
|
|
@@ -16,6 +16,7 @@ executables: []
|
|
|
16
16
|
extensions: []
|
|
17
17
|
extra_rdoc_files: []
|
|
18
18
|
files:
|
|
19
|
+
- CHANGELOG.md
|
|
19
20
|
- LICENSE
|
|
20
21
|
- LICENSE-OpenCC
|
|
21
22
|
- NOTICE
|
|
@@ -24,6 +25,7 @@ files:
|
|
|
24
25
|
- data/zh-hant-hans.tsv
|
|
25
26
|
- lib/cjk_index.rb
|
|
26
27
|
- lib/cjk_index/builder.rb
|
|
28
|
+
- lib/cjk_index/index_file.rb
|
|
27
29
|
- lib/cjk_index/jekyll.rb
|
|
28
30
|
- lib/cjk_index/normalizer.rb
|
|
29
31
|
- lib/cjk_index/runtime.js
|
|
@@ -37,6 +39,9 @@ licenses:
|
|
|
37
39
|
- Apache-2.0
|
|
38
40
|
metadata:
|
|
39
41
|
source_code_uri: https://github.com/nakamura196/cjk_index
|
|
42
|
+
documentation_uri: https://github.com/nakamura196/cjk_index#readme
|
|
43
|
+
changelog_uri: https://github.com/nakamura196/cjk_index/blob/main/CHANGELOG.md
|
|
44
|
+
bug_tracker_uri: https://github.com/nakamura196/cjk_index/issues
|
|
40
45
|
rubygems_mfa_required: 'true'
|
|
41
46
|
rdoc_options: []
|
|
42
47
|
require_paths:
|