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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d4df8b57af0d9d2e2755bf87cc9fdb60313d87f68ab17160f5ff4f9f28460d9
4
- data.tar.gz: 3bc33035945c48d226c328c58eab9c012f7a93874edde8a3adc2c5a92d9972f3
3
+ metadata.gz: 843127d7f858d567b1e4eb0d37975111612feab1bdf63fe9d8bc6e4dbaf0e0b1
4
+ data.tar.gz: 78c8ab89bbaf4477aa0e8b51f72de64503bb71e2401ed13c013eeed40bc3f41c
5
5
  SHA512:
6
- metadata.gz: 3016fee0464ea949e62360c4d4bfbaf3a4f1a7271c711efe1096b4958ce754b5bf4059566395eb11980fc92a8c55d953989e6562b0fa0429e1e25036a7d277e7
7
- data.tar.gz: f7dade68d837d456467e6d1c8fe6e8fbba3335432c7735414df161627942137d626a7a8f270423085957b162e0fa75c3aa709dd7279635e1c4a6c0bb47a9ff2e
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 catalogue from
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`. A working example:
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.1, early. Not yet on RubyGems. Planned: index sharding for large sites, a
160
- Bridgetown adapter, a CLI, and size and speed benchmarks.
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
@@ -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
- # builder.add("item1.html", "title" => "東京帝國大學", "creator" => "…")
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 2):
13
+ # Format (version 3):
13
14
  # {
14
- # "format": "cjk_index/2",
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/2"
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
- attr_reader :fields, :refs, :normalizer
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
- def initialize(fields:, boosts: {}, normalizer: Normalizer.default)
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
- doc_index = @refs.length
128
+ slot = @refs.length
53
129
  @refs << ref.to_s
54
- @fields.each_with_index do |field, field_index|
55
- value = doc[field] || doc[field.to_sym]
56
- positions = Hash.new { |h, k| h[k] = [] }
57
- base = 0
58
- Array(value).each do |v|
59
- text = v.to_s
60
- Tokenizer.tokens_with_offsets(text, unigrams: true, normalizer: @normalizer)
61
- .each { |t, offset| positions[t] << (base + offset) }
62
- base += @normalizer.normalize(text).length + VALUE_GAP
63
- end
64
- @lengths << [base - VALUE_GAP, 0].max
65
- positions.each { |token, offsets| @postings[token].push(doc_index, field_index, offsets.length, *offsets) }
66
- end
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
@@ -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']}"
@@ -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 FORMAT = 'cjk_index/2';
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 !== 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
- Index.prototype.search = function (query) {
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) return [];
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;
@@ -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, :refs, :normalizer
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
- data = case index
35
- when Builder then index.to_h
36
- when String then JSON.parse(index)
37
- else index
38
- end
39
- unless data.is_a?(Hash) && data["format"] == Builder::FORMAT
40
- raise ArgumentError, "cjk_index: unsupported index format #{data.is_a?(Hash) ? data['format'].inspect : data.class}"
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
- @prefix = prefix
50
- @typos = typos
51
- @fields = data["fields"]
52
- @boosts = data["boosts"]
53
- @refs = data["refs"]
54
- @lengths = data["lengths"]
55
- @tokens = data["tokens"]
56
- nf = @fields.length
57
- avg = Array.new(nf, 0.0)
58
- @lengths.each_with_index { |len, i| avg[i % nf] += len }
59
- @avg_length = avg.map { |s| (v = s / [1, @refs.length].max).zero? ? 1 : v }
60
- @latin_tokens = @tokens.keys.select { |t| latin?(t) }.sort
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
- def search(query)
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 = @refs.length
82
+ n = @count
83
+ conditions = filter && compile_filter(filter)
69
84
  qp = Tokenizer.phrases(query, normalizer: @normalizer)
70
- return [] if qp.empty?
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[token] or next
240
+ postings = @tokens.fetch(token, nil) or next
129
241
  i = 0
130
242
  while i < postings.length
131
243
  count = postings[i + 2]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CJKIndex
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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: