minifts 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4da0fc15a39db6e325bc33fae8d81c0c03f1d916d5a6b200a309fdccfdb2ddd3
4
+ data.tar.gz: ec3f3dbce15dd997865a0c520dd13d5df80ec5e71792ad93cff1d32f5ebf3cdd
5
+ SHA512:
6
+ metadata.gz: 658d33e390731ffa0ad1783d7f86006155b20d22df882ca3d069808ff33b9aaa6e794f909158baf4a6d20cc311a3d88b1810ba4b65067838e862993e0cab34a1
7
+ data.tar.gz: f980ef1aa9fac904faa7a5bff362bb65edb24ee4ff9e9c7877e8352ad46b789c4c9d76bef8c4f75585c20f8120ae1fe939e5ea9d044d14427415d8b7cc55ffec
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ ## [1.0.0] - 2026-07-18
2
+
3
+ - Initial release: a pure-Ruby port of the JavaScript
4
+ [MiniSearch](https://github.com/lucaong/minisearch) full-text search engine.
5
+ - In-memory inverted index backed by a radix tree (`MiniFTS::SearchableMap`)
6
+ with exact, prefix, and fuzzy (Levenshtein) lookup.
7
+ - BM25+ relevance scoring with field boosting, term boosting, document boosting,
8
+ and per-field weights.
9
+ - Query combinators (`OR`, `AND`, `AND_NOT`), combination-tree queries, result
10
+ filtering, and wildcard queries.
11
+ - Auto-suggestions (`auto_suggest`).
12
+ - Incremental `add` / `remove` / `discard` / `replace`, with lazy index cleanup
13
+ and synchronous (auto-)vacuuming.
14
+ - Byte-identical JSON serialization with the JavaScript library (`to_json` /
15
+ `load_json`): a Ruby-serialized index loads and searches identically in
16
+ JavaScript, and vice versa, for any corpus without astral-plane (above
17
+ U+FFFF) characters. Verified bidirectionally against the real
18
+ `minisearch@7.2.0` across 32 scenarios (`rake compat`).
19
+ - Runs on every Ruby since 2.4; no runtime dependencies.
20
+ - Configurable `logger` for index-corruption warnings; setting `logger: nil`
21
+ silences them without raising.
22
+ - Verified bit-for-bit against the reference JavaScript implementation via
23
+ golden and randomized differential tests, plus a full parity suite mirroring
24
+ the reference's own (non-async) test cases, including the movie/song ranking
25
+ sets.
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "minifts" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["rodrigo.serradura@gmail.com"](mailto:"rodrigo.serradura@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rodrigo Serradura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,302 @@
1
+ <h1 align="center">MiniFTS</h1>
2
+
3
+ <p align="center">
4
+ A tiny, dependency-free full-text search engine for Ruby, held entirely in memory —<br>
5
+ a faithful, bit-for-bit port of the JavaScript <a href="https://github.com/lucaong/minisearch">MiniSearch</a> library.
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://rubygems.org/gems/minifts"><img src="https://img.shields.io/gem/v/minifts" alt="Gem version"></a>
10
+ <a href="https://rubygems.org/gems/minifts"><img src="https://img.shields.io/gem/dt/minifts" alt="Downloads"></a>
11
+ <a href="https://github.com/serradura/minifts/actions/workflows/main.yml"><img src="https://github.com/serradura/minifts/actions/workflows/main.yml/badge.svg" alt="CI"></a>
12
+ <a href="https://github.com/serradura/minifts"><img src="https://img.shields.io/badge/ruby-%3E%3D%202.4-black" alt="Ruby >= 2.4"></a>
13
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT"></a>
14
+ </p>
15
+
16
+ It has the same BM25+ scoring, prefix and fuzzy matching, boosting, query
17
+ combinators, auto-suggestions, and JSON index format as the original — the
18
+ public API mirrors it, translated to idiomatic Ruby.
19
+
20
+ - **Pure Ruby, zero runtime dependencies.** No native extensions, no database,
21
+ no build step. It runs on the Ruby your OS already ships — the floor is
22
+ **Ruby 2.4**.
23
+ - **Bit-for-bit compatible with MiniSearch.** Scores, ranking, and the
24
+ serialized index are identical to the JavaScript library (verified against it
25
+ across thousands of generated cases — see [Fidelity](#fidelity)). A JSON index
26
+ written by one can be loaded by the other.
27
+ - **Good for the "just add search" case.** When you want relevance-ranked search
28
+ over a modest corpus (a few docs to hundreds of thousands) without reaching for
29
+ SQLite FTS5, Elasticsearch, or a service.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ gem install minifts
35
+ ```
36
+
37
+ Or in a `Gemfile`:
38
+
39
+ ```ruby
40
+ gem "minifts"
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```ruby
46
+ require "minifts"
47
+
48
+ documents = [
49
+ { "id" => 1, "title" => "Moby Dick", "text" => "Call me Ishmael. Some years ago...", "category" => "fiction" },
50
+ { "id" => 2, "title" => "Zen and the Art of Motorcycle Maintenance", "text" => "I can see by my watch...", "category" => "fiction" },
51
+ { "id" => 3, "title" => "Neuromancer", "text" => "The sky above the port was...", "category" => "sci-fi" },
52
+ ]
53
+
54
+ ms = MiniFTS.new(fields: %w[title text], store_fields: %w[title category])
55
+ ms.add_all(documents)
56
+
57
+ ms.search("zen motorcycle")
58
+ # => [
59
+ # { id: 2, score: 2.77, terms: ["zen", "motorcycle"], query_terms: ["zen", "motorcycle"],
60
+ # match: { "zen" => ["title"], "motorcycle" => ["title"] },
61
+ # "title" => "Zen and the Art of Motorcycle Maintenance", "category" => "fiction" },
62
+ # ]
63
+ ```
64
+
65
+ Each result is a Hash: `:id`, `:score`, `:terms` (the matched *document* terms),
66
+ `:query_terms` (the matched query terms), `:match` (term → fields it matched in),
67
+ plus any `store_fields` under their **string** keys.
68
+
69
+ ## Search options
70
+
71
+ Pass options as the second argument to `search`:
72
+
73
+ ```ruby
74
+ ms.search("moto", prefix: true) # prefix search
75
+ ms.search("ishmael", fuzzy: 0.2) # fuzzy (edit distance = 0.2 * term length)
76
+ ms.search("zen art", combine_with: "AND") # require all terms
77
+ ms.search("zen", boost: { "title" => 2 }) # weight matches in :title higher
78
+ ms.search("art", fields: ["title"]) # restrict to certain fields
79
+ ms.search("art", filter: ->(r) { r["category"] == "fiction" })
80
+ ms.search("art", boost_document: ->(id, term, stored) { stored["featured"] ? 2 : 1 })
81
+ ms.search("zen art", boost_term: ->(term, i, terms) { term == "zen" ? 2 : 1 }) # weight some query terms higher
82
+ ms.search("moto", prefix: true, weights: { prefix: 0.1 }) # down-weight non-exact (prefix/fuzzy) matches
83
+ ms.search("zen", bm25: { k: 1.2, b: 0.7, d: 0.5 }) # tune BM25+ scoring (defaults shown)
84
+ ms.search(MiniFTS::WILDCARD) # match every document
85
+ ```
86
+
87
+ Query strings can also be combination trees:
88
+
89
+ ```ruby
90
+ ms.search(
91
+ combine_with: "AND",
92
+ queries: ["zen", { combine_with: "OR", queries: %w[motorcycle archery] }]
93
+ )
94
+ ```
95
+
96
+ Supported search options: `:fields`, `:filter`, `:boost`, `:boost_term`,
97
+ `:weights`, `:boost_document`, `:prefix`, `:fuzzy`, `:max_fuzzy`,
98
+ `:combine_with`, `:tokenize`, `:process_term`, `:bm25`.
99
+
100
+ `:weights` scales non-exact matches — defaults `{ fuzzy: 0.45, prefix: 0.375 }`,
101
+ so a fuzzy or prefix hit counts for less than an exact one. `:boost_term` takes a
102
+ callable returning a per-*query-term* multiplier (default `1`). `:bm25` tunes the
103
+ BM25+ parameters `k`, `b`, and `d` (defaults `{ k: 1.2, b: 0.7, d: 0.5 }`).
104
+ Defaults can be set once via the constructor's `:search_options`.
105
+
106
+ ## Auto-suggestions
107
+
108
+ ```ruby
109
+ ms.auto_suggest("neuro")
110
+ # => [{ suggestion: "neuromancer", terms: ["neuromancer"], score: 0.46 }]
111
+
112
+ ms.auto_suggest("zen ar")
113
+ # => [{ suggestion: "zen archery art", terms: [...], score: 1.73 },
114
+ # { suggestion: "zen art", terms: [...], score: 1.21 }]
115
+ ```
116
+
117
+ ## Adding, removing, and updating documents
118
+
119
+ ```ruby
120
+ ms.add(document) # add one
121
+ ms.add_all(documents) # add many
122
+ ms.remove(document) # remove (needs the full, unchanged document)
123
+ ms.discard(id) # remove by ID (lazy cleanup; see below)
124
+ ms.discard_all([id, ...])
125
+ ms.replace(updated) # discard + add, same ID
126
+ ms.remove_all # clear everything
127
+ ms.vacuum # reclaim space from discarded documents
128
+ ```
129
+
130
+ `discard` is the convenient counterpart to `remove`: it only needs the ID and
131
+ takes effect immediately, cleaning up the inverted index lazily (during later
132
+ searches, and via `vacuum`). Auto-vacuuming runs on your behalf once enough
133
+ documents have been discarded.
134
+
135
+ Introspection: `document_count`, `term_count`, `has?(id)`,
136
+ `get_stored_fields(id)`, `dirt_count`, `dirt_factor`.
137
+
138
+ ## Configuration
139
+
140
+ ```ruby
141
+ MiniFTS.new(
142
+ fields: %w[title text], # REQUIRED: field names (strings) to index
143
+ id_field: "id", # unique-ID field (default "id")
144
+ store_fields: %w[title], # fields to keep and return in results
145
+ extract_field: ->(doc, field) { doc[field] }, # how to read a field
146
+ stringify_field: ->(value, field) { value.to_s },
147
+ tokenize: ->(text, field = nil) { text.split(/\s+/) },
148
+ process_term: ->(term, field = nil) { term.downcase }, # normalize/stem; return nil to drop
149
+ search_options: { prefix: true }, # default options for every search
150
+ auto_suggest_options: { fuzzy: 0.2 }, # default options for every auto_suggest
151
+ logger: ->(level, msg, code) { warn("#{level}: #{msg}") }, # warning sink; default logs to stderr
152
+ auto_vacuum: true
153
+ )
154
+ ```
155
+
156
+ Callables are anything responding to `call` (lambdas, procs, method objects).
157
+ `MiniFTS.get_default(:tokenize)` returns the built-in default for any
158
+ constructor option (`:tokenize`, `:process_term`, `:extract_field`, ...), handy
159
+ when you want to wrap the default rather than replace it.
160
+
161
+ ### Documents with symbol keys
162
+
163
+ By default documents are Hashes with **string** keys matching the field names.
164
+ For symbol-keyed documents, supply an extractor:
165
+
166
+ ```ruby
167
+ MiniFTS.new(fields: ["title"], extract_field: ->(doc, field) { doc[field.to_sym] })
168
+ ```
169
+
170
+ ### Stemming, stop words, synonyms
171
+
172
+ Do it in `process_term` (return `nil`/`false` to drop a term, or an array to
173
+ expand one):
174
+
175
+ ```ruby
176
+ STOP = %w[the a an of and].to_set
177
+ MiniFTS.new(
178
+ fields: ["text"],
179
+ process_term: ->(t, _f = nil) { d = t.downcase; STOP.include?(d) ? nil : d }
180
+ )
181
+ ```
182
+
183
+ ## Serialization and JavaScript interop
184
+
185
+ The index serializes to JSON in exactly MiniSearch's format:
186
+
187
+ ```ruby
188
+ json = ms.to_json
189
+ File.write("index.json", json)
190
+
191
+ # Later — pass the same options used to build it:
192
+ ms = MiniFTS.load_json(File.read("index.json"), fields: %w[title text], store_fields: %w[title])
193
+ ```
194
+
195
+ If you already hold the index as a Ruby Hash (string keys) rather than a JSON
196
+ string, use `ms.as_plain_object` to get one and `MiniFTS.load(hash, **opts)`
197
+ to load it back — `to_json`/`load_json` are exactly those two with JSON in
198
+ between.
199
+
200
+ Because the format is identical, an index built in Ruby can be loaded by
201
+ JavaScript MiniSearch in the browser (and vice-versa) — index server-side, search
202
+ client-side.
203
+
204
+ ## The radix tree
205
+
206
+ The inverted index is backed by `MiniFTS::SearchableMap`, a radix tree with
207
+ `Map`-like semantics plus `at_prefix` and `fuzzy_get`. It is exported for
208
+ standalone use:
209
+
210
+ ```ruby
211
+ map = MiniFTS::SearchableMap.new
212
+ map.set("motorcycle", 1).set("motor", 2)
213
+ map.at_prefix("moto").keys # => ["motor", "motorcycle"]
214
+ map.fuzzy_get("moter", 1) # => { "motor" => [2, 1] }
215
+ ```
216
+
217
+ ## Performance
218
+
219
+ Everything is in-memory and the heavy lifting is a radix-tree lookup plus BM25
220
+ scoring, so it is fast for its class and scales with the number of *matching*
221
+ documents rather than the total. Indicative figures from
222
+ `benchmarks/search_bench.rb` (5,000 synthetic documents, pure Ruby):
223
+
224
+ | operation | throughput |
225
+ | -------------- | ---------------- |
226
+ | index build | ~3,000 docs/sec |
227
+ | exact search | ~400 queries/sec |
228
+ | prefix search | ~330 queries/sec |
229
+ | fuzzy search | ~180 queries/sec |
230
+
231
+ On the same corpus, exact search is ~6× faster than a naive "scan every
232
+ document" approach — a gap that widens as the corpus grows.
233
+
234
+ ### Optimization
235
+
236
+ The implementation was profiled and tuned against a real-world corpus. Relative
237
+ to an early baseline, on the same workload with output verified byte-identical
238
+ (2,627 result rows, scores matched to 17 significant figures):
239
+
240
+ | metric | change vs. baseline |
241
+ | --------------- | -------------------------- |
242
+ | indexing memory | **4.3× lighter** (−76.6%) |
243
+ | search memory | **1.8× lighter** (−44.7%) |
244
+ | indexing speed | +37% (1.37×) |
245
+ | search speed | +15% (1.15×) |
246
+
247
+ Memory (allocated bytes) is deterministic; the throughput figures are wall-clock
248
+ medians and directional. The memory wins dominate because the hot path was
249
+ allocation-bound: indexing 500 documents churned ~11.5M objects and now churns
250
+ ~2.4M — far lower GC pressure and peak memory, which is what matters most for an
251
+ in-memory index at scale.
252
+
253
+ ## Fidelity
254
+
255
+ This port is validated against the original JavaScript MiniSearch, not just by
256
+ hand-written expectations:
257
+
258
+ - **Golden cases** (`test/golden.json`, `test/lifecycle.json`): every documented
259
+ feature and lifecycle operation, with the exact output the JS library produces.
260
+ - **Randomized differential testing** (`test/fuzz.json`): 250 documents and 700
261
+ randomly generated queries with random options, run through the real JS library
262
+ and replayed in Ruby — ~150,000 assertions, scores matched to full double
263
+ precision.
264
+ - **Byte-identical serialization**: a Ruby-serialized index is byte-for-byte
265
+ identical to the JavaScript library's — index in one, search in the other. The
266
+ lone exception is a corpus containing astral-plane characters (above U+FFFF,
267
+ e.g. most emoji), whose radix-tree terms sort by UTF-8 code point here vs
268
+ UTF-16 code unit in JS; such indexes still load and search identically, only
269
+ their serialized byte order differs. Interchange is verified in both directions
270
+ across 32 scenarios by `rake compat` (see `fidelity/`).
271
+
272
+ The whole suite runs on **Ruby 2.4** (verified in a `ruby:2.4` container).
273
+
274
+ ### Differences from the JavaScript library
275
+
276
+ - Options and result metadata use **snake_case symbol keys** (`:id_field`,
277
+ `:store_fields`, `:query_terms`, ...). Stored fields keep their string keys.
278
+ - Field names are **strings**; documents are read with `doc[field]` by default.
279
+ - Vacuuming is **synchronous** (Ruby has no main thread to protect), so the
280
+ async variants (`addAllAsync`, `loadJSONAsync`, batched `vacuum`) are omitted;
281
+ `vacuum` cleans up immediately and `is_vacuuming` does not apply.
282
+ - The wildcard is `MiniFTS::WILDCARD` (or `MiniFTS.wildcard`).
283
+
284
+ ## Development
285
+
286
+ ```bash
287
+ bin/setup # install dependencies
288
+ rake test # run the test suite
289
+ rake rubocop # lint (Ruby 2.4 target)
290
+ rake # both
291
+ ruby -Ilib benchmarks/search_bench.rb [num_docs] [num_queries]
292
+ ```
293
+
294
+ ## Credits
295
+
296
+ A Ruby port of [MiniSearch](https://github.com/lucaong/minisearch) by Luca
297
+ Ongaro. All the search design is theirs; this project translates it to idiomatic
298
+ Ruby with no dependencies.
299
+
300
+ ## License
301
+
302
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ # rake/testtask (not minitest/test_task) so `rake test` runs on every supported
6
+ # Ruby — minitest's own task class needs minitest 5.16+, which needs Ruby 2.6.
7
+ require "rake/testtask"
8
+
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << "test" << "lib"
11
+ t.test_files = FileList["test/**/test_*.rb"].exclude("test/test_helper.rb")
12
+ t.warning = false
13
+ end
14
+
15
+ # Ruby <-> JavaScript index interchange suite. Kept out of `test`/`default`
16
+ # because it needs Node and the real `minisearch` npm package; the pure-Ruby
17
+ # suite (including the Ruby 2.4 floor) must not depend on either.
18
+ desc "Run the Ruby <-> JavaScript index interchange suite (needs Node in fidelity/)"
19
+ task :compat do
20
+ Dir.chdir("fidelity") do
21
+ sh "npm install --silent" unless Dir.exist?("node_modules")
22
+ sh "ruby bin/build_ruby.rb" # stage 1: Ruby produces the index fixtures
23
+ sh "node bin/check_js.mjs" # stage 2: JS loads them + builds native
24
+ sh "ruby -Itest test/test_interchange.rb" # stage 3: Ruby loads the JS indexes
25
+ end
26
+ end
27
+
28
+ # RuboCop only installs on newer Rubies (see Gemfile); the default task degrades
29
+ # to test-only where it is absent.
30
+ begin
31
+ require "rubocop/rake_task"
32
+ RuboCop::RakeTask.new
33
+ task default: %i[test rubocop]
34
+ rescue LoadError
35
+ task default: %i[test]
36
+ end