Class: OKF::Bundle::Search
- Inherits:
-
Object
- Object
- OKF::Bundle::Search
- Defined in:
- lib/okf/bundle/search.rb,
lib/okf/bundle/search/scan.rb,
lib/okf/bundle/search/index.rb
Overview
Ranked text retrieval over one or more in-memory bundles. Terms are ANDed: every term must hit at least one searched field, though not necessarily the same one. Rows carry the fields each term hit, so a result stays explainable rather than being a bare relevance number.
This class is a facade. It owns everything that defines what a result is — the documents, the row and its key order, the snippet window, the final sort — and delegates only "which documents match, how well, and where" to an engine (Search::Index by default, Search::Scan for regexp). An engine that built its own rows could disagree about what a match is; this split makes that unrepresentable.
Pure — no disk, no stdio. The CLI's okf search and any embedding app
share it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
Defined Under Namespace
Modules: Index, Scan Classes: Corpus, UnknownEngine, UnsupportedQuery
Constant Summary collapse
- CAPABILITIES =
The declarable vocabulary: what an engine may claim about itself. Frozen so an engine declaring
:regexis refused at registration rather than silently never selected — a typo in an addon would otherwise present as "my engine is ignored".:prefixlives here and not in ROUTABLE on purpose. Nothing asks for its absence, so it selects nothing; what it does is document that this engine grows a term to the tokens it prefixes, which an FTS5 engine may not do by default. Declarative, and honest about being declarative. %i[regexp fuzzy prefix].freeze
- ROUTABLE =
The routable subset: the capabilities a query can actually require, and therefore the only ones that pick an engine. Kept distinct from CAPABILITIES because a capability nothing selects on, filed among the ones that do, is documentation posing as code.
Each entry is also the option name the facade hands an engine that declares it — see #engine_options, which is what keeps a meaningful option from reaching an engine that would quietly drop it.
%i[regexp fuzzy].freeze
- DEFAULT_ENGINE =
Chosen when the query requires nothing in particular, which is the overwhelming majority of searches.
The scan, not the index, because a one-shot CLI builds an index, asks one question and exits — a build with a single query to amortize it over. Measured end to end: 3.00s vs 0.24s at 1,000 concepts, 0.83s vs 0.18s at 250, and the gap widens with the bundle. Raw-text matching also carries no tokenizer, so the terms that are glued to symbols and therefore unreachable by token (
minifts, $OKF_HOME) stay findable by default.What it gives up is BM25+ ranking, reachable with
--engine index— and that is also the engine the browser page runs, so the two rank alike only when the index is named. See .okf/design/search-engines.md. :scan- WEIGHTS =
The searchable fields with their rank weight, strongest signal first. In the index engine these ride as MiniFTS per-field
boost; the scan sums the weights of the fields that matched instead. { "title" => 5, "id" => 4, "tags" => 3, "type" => 2, "description" => 2, # A regression fix rather than a feature: in v0.1 a citation's text # lived in the body and was searchable at weight 1. After a bundle # migrates it lives in frontmatter, so without this a migrated bundle # silently loses the hit entirely. What this restores is *recall* at # the same weight — not an identical total. A v0.1 concept keeps # matching `body` too, because the text really is body prose there, so # it scores one higher than its migrated twin; that divergence is # deliberate and pinned by cli_twins_test.rb ("migrating moves a # source-only hit's snippet from body text to source text"). Suppressing # it would mean lying about `--in body` on a bundle whose body does # contain the words. "sources" => 1, "body" => 1 }.freeze
- FIELDS =
WEIGHTS.keys.freeze
- SNIPPET_FIELDS =
Fields whose match is only meaningful with surrounding context. The other fields already appear whole on the result row.
sourcesis here because indexed-but-un-snippeted would degrade a consumer's evidence line to a bare id list: after migration the snippet moves from body text to source text, it does not vanish. %w[description body sources].freeze
- SNIPPET_RADIUS =
Characters of context kept on each side of the first matched term.
44- FUZZY_DISTANCE =
Edit distance as a fraction of term length, under
fuzzy: true— the same 0.2 the browser page passes, so both forgive the same typos. 0.2- KEY_SEPARATOR =
The unique document key is "
\0 ": ids are only unique within a bundle, and a merge that collided two bundles' same-named concepts would silently drop one. "\0"
Class Method Summary collapse
-
.across(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) ⇒ Object
Several bundles as [ slug, bundle ] pairs, ranked into one list with every row labeled by its slug.
- .call(bundle, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) ⇒ Object
-
.engine_for(required, engines: self.engines, name: nil) ⇒ Object
The router.
-
.engines ⇒ Object
A frozen snapshot in registration order.
-
.field_texts(concept) ⇒ Object
The searchable text of one concept, by field.
-
.prepare(bundles, engine: nil, engines: nil) ⇒ Object
Prepare a corpus for a long-lived caller.
-
.register(engine) ⇒ Object
Append-only and idempotent by id: a second registration of an id already present is a no-op, so a double
requirecannot double the registry and an addon cannot quietly displace a built-in. -
.with(corpus, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) ⇒ Object
Query a prepared corpus.
Instance Method Summary collapse
-
#initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil, corpus: nil) ⇒ Search
constructor
Raises RegexpError on an invalid pattern with
regexp: true, and UnsupportedQuery when no engine can answer — the caller owns turning either into a usage error. -
#results ⇒ Object
Ranked match rows, catalog-style identity plus where the terms hit: [{ slug:, id:, title:, type:, dir:, top_dir:, tags:, matched: [field, …], score:, snippet: }, …] ordered by score descending, then slug, then id.
Constructor Details
#initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil, corpus: nil) ⇒ Search
Raises RegexpError on an invalid pattern with regexp: true, and
UnsupportedQuery when no engine can answer — the caller owns turning
either into a usage error. engines: overrides the registry, which is how
the "nothing qualifies" path stays reachable without an addon installed.
287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/okf/bundle/search.rb', line 287 def initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil, corpus: nil) @bundles = bundles @corpus = corpus @terms = Array(terms).reject { |term| OKF.blank?(term) }.map(&:to_s) @fields = fields.nil? || fields.empty? ? FIELDS : fields @regexp = regexp @fuzzy = fuzzy @engine = engine @engines = engines @sources = {} end |
Class Method Details
.across(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) ⇒ Object
Several bundles as [ slug, bundle ] pairs, ranked into one list with every row labeled by its slug. They share one index on purpose: BM25 weighs a term by how rare it is in the corpus, so per-bundle indexes would score the same match differently depending on which bundle it came from. One index makes one corpus, and the merged ranking is comparable by construction.
200 201 202 |
# File 'lib/okf/bundle/search.rb', line 200 def self.across(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) new(bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results end |
.call(bundle, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) ⇒ Object
191 192 193 |
# File 'lib/okf/bundle/search.rb', line 191 def self.call(bundle, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) new([ [ nil, bundle ] ], terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results end |
.engine_for(required, engines: self.engines, name: nil) ⇒ Object
The router. Naming an engine is an override, not a hint: it is how a
caller reaches semantics no capability flag asks for — --engine scan
means "match raw text", which the flags cannot express because there is
nothing to require. A named engine that cannot do what was also asked
is an error rather than a silent fallback, since falling back would answer
a different question than the one that was posed.
Unnamed, the default engine leads, then registration order; the first available engine offering every required capability answers. Partition rather than sort_by, because sort_by is not stable and registration order is the tie-break.
168 169 170 171 172 173 174 175 176 177 |
# File 'lib/okf/bundle/search.rb', line 168 def self.engine_for(required, engines: self.engines, name: nil) available = engines.select(&:available?) return named_engine(name, required, available) unless OKF.blank?(name) default, rest = available.partition { |engine| engine.id == DEFAULT_ENGINE } found = (default + rest).find { |engine| (required - engine.capabilities).empty? } return found if found raise UnsupportedQuery, required end |
.engines ⇒ Object
A frozen snapshot in registration order. Frozen because the registry is only meant to grow through .register, where the vocabulary is checked.
153 154 155 |
# File 'lib/okf/bundle/search.rb', line 153 def self.engines (@engines ||= []).dup.freeze end |
.field_texts(concept) ⇒ Object
The searchable text of one concept, by field. Here rather than on an instance because a Corpus builds documents with no query in hand.
206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/okf/bundle/search.rb', line 206 def self.field_texts(concept) { "id" => concept.id, "title" => concept.title.to_s, "type" => concept.type.to_s, "description" => concept.description.to_s, "tags" => Array(concept.).join(" "), # Titles and resources together: a source is findable by what it is # called and by where it lives, which is how the body list read. "sources" => concept.sources.flat_map { |source| [ source["title"], source["resource"] ] }.compact.join(" "), "body" => concept.body } end |
.prepare(bundles, engine: nil, engines: nil) ⇒ Object
Prepare a corpus for a long-lived caller. Hand the result back to .with for every query.
engine: builds that engine's index now rather than on the first query.
Without it the corpus holds only the documents, and the expensive half —
the index — is still built lazily, which puts the whole cost on whoever
searches first. A server knows its engine at boot, so it can pay there.
268 269 270 271 272 273 274 |
# File 'lib/okf/bundle/search.rb', line 268 def self.prepare(bundles, engine: nil, engines: nil) corpus = Corpus.new(bundles) return corpus if OKF.blank?(engine) corpus.index_for(engine_for([], engines: engines || self.engines, name: engine)) corpus end |
.register(engine) ⇒ Object
Append-only and idempotent by id: a second registration of an id already
present is a no-op, so a double require cannot double the registry and
an addon cannot quietly displace a built-in. Deliberately the same shape
as the Linter's planned register hook — two extension points, one idiom.
142 143 144 145 146 147 148 149 |
# File 'lib/okf/bundle/search.rb', line 142 def self.register(engine) rogue = engine.capabilities - CAPABILITIES raise ArgumentError, "unknown search capability: #{rogue.join(", ")}" unless rogue.empty? @engines ||= [] @engines << engine unless @engines.any? { |registered| registered.id == engine.id } engine end |
.with(corpus, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) ⇒ Object
Query a prepared corpus. Same rows as .across, without rebuilding what the corpus already holds.
278 279 280 281 |
# File 'lib/okf/bundle/search.rb', line 278 def self.with(corpus, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil) new(corpus.bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines, corpus: corpus).results end |
Instance Method Details
#results ⇒ Object
Ranked match rows, catalog-style identity plus where the terms hit:
[{ slug:, id:, title:, type:, dir:, top_dir:, tags:, matched: [field, …], score:, snippet: }, …]
ordered by score descending, then slug, then id. slug is present only
when searching across bundles. No terms means no matches.
303 304 305 306 307 308 309 310 311 312 |
# File 'lib/okf/bundle/search.rb', line 303 def results return [] if @terms.empty? chosen = engine rows = chosen.call(documents, @terms, **(chosen)).map do |hit| slug, concept = @sources[hit[:key]] row(slug, concept, hit[:matched], hit[:score], hit[:terms]) end rows.sort_by { |row| [ -row[:score], row[:slug].to_s, row[:id] ] } end |