truffler 0.1.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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +425 -0
- data/bench/cassettes/004cad52c71fc8d847bf388eb9c2c288e1169b65ec4a28ce040cb7fea7c55f04.json +1 -0
- data/bench/cassettes/0547d3d23c3fbaae86772ffa0e4e96475aa152a2556b8fa4bc7568effcb81a28.json +1 -0
- data/bench/cassettes/07aa23d166ab3500178e3f3c0838183051b093507bef88e1e2d29efdf5759ee6.json +1 -0
- data/bench/cassettes/0858c5006c0bfac3e970ddaff3c139f6c110dbe3d8539fd95263706c637cdc8e.json +1 -0
- data/bench/cassettes/0c0eb7c27d76bd0b9263690a63cd92d6b68c305826318555fd5b084a09970b78.json +1 -0
- data/bench/cassettes/0fada0c0a103882e2895f7f455d73d2cbe2b4a81bf743bb19b2b4f6e0130126b.json +1 -0
- data/bench/cassettes/1117d79ac7c43b21beaf75e510461f91cd4ecc3cdbbe461f1794644af4aaf8f3.json +1 -0
- data/bench/cassettes/11b7444a074cca4d0ef1535db0cf0edefe4c34eac4be3c593c9c2f33d3b038b8.json +1 -0
- data/bench/cassettes/11d00371d1b993675be00463159164536f1f8ed90a48652b0935eb9c36f8b684.json +1 -0
- data/bench/cassettes/1456ebe12616ce961d68a14eada62778a7271ebd3d1b4cb6eefd6857651c7afc.json +1 -0
- data/bench/cassettes/14c3693f64b0e9ea7e470277fccae6ab501d404185eec5bb9074ef4b75c572c0.json +1 -0
- data/bench/cassettes/14fe9f6d8055d41f5d970dc32ab640c213ea8782d365c82d32b9884ba4f28c67.json +1 -0
- data/bench/cassettes/1a1d8557e4698426c2467aa573a240357715d3d9e3fa77bf066672a8b6bf9a86.json +1 -0
- data/bench/cassettes/1b5a8aaaf98305560588246cd51f53c6c185a951f8018c26bf588954d9de1cea.json +1 -0
- data/bench/cassettes/1fe596be747e33e9c7e4d7c50de32613957ac66bee43679b4bed96c551b1fd0a.json +1 -0
- data/bench/cassettes/21ebce6db6d39b1747dccf25bd3ceb298e1285425190b4d78830c395a7ad786a.json +1 -0
- data/bench/cassettes/23066253621e9cb00fd93cbe95ef1124ff0715585fcb6325b3abbd0802b2c303.json +1 -0
- data/bench/cassettes/2f6576325dfab696cec8d8b35fa89c29960bee15d10dab31c91cef507f4842be.json +1 -0
- data/bench/cassettes/33ec27d22c0f63dbbe93008f50af07fa8667a5c806a9fbfc7897683915412b40.json +1 -0
- data/bench/cassettes/351f745b0c47e0cf3272b0fea7fc4941b6aa733f013c55a9a19707b542543200.json +1 -0
- data/bench/cassettes/37d974fdc0c1876413111c244d363c8d2306e3229fec668c158b5c924ffdb983.json +1 -0
- data/bench/cassettes/39524467463dd612cbf03e314b9cfd8cbe1c5f06969da2289359cf7cf83de5da.json +1 -0
- data/bench/cassettes/3f2c758c111ab7f1163ba579f2329718afe15620507d356d3ebe2a053df237ea.json +1 -0
- data/bench/cassettes/44bbc3a3f0ceb0edb443b080b939af80dd145795505bd13cac113a35365d0f04.json +1 -0
- data/bench/cassettes/45bf701a13c348acb45a3b3a56990570e3caf797c3daee5d1358a477593ce587.json +1 -0
- data/bench/cassettes/4676a0f2df29493fdfa0b7cd75c097e647373862d88373dc0e1fa9fca26707c2.json +1 -0
- data/bench/cassettes/49708a50e3c78f45beed2f1b8d8d5375b99ea4ab3497fed7650e6ece4ec81ba8.json +1 -0
- data/bench/cassettes/54ea30ecca8fa49f607d5c8265296eb16edb48d0620a7399c6480d4cb0f43ae1.json +1 -0
- data/bench/cassettes/550e73a8291f53d0f2b008f166560eff4d10cc7c09a927d3c4f70544f2578300.json +1 -0
- data/bench/cassettes/570039823d5af1b6c1d4bcbe14f9802d40e851f8e4946278a46884c76c0ceee1.json +1 -0
- data/bench/cassettes/5ae9dce82fa7b9084b592f657a24acfd5852c4fcb091a6a8e22f2cbbf947bcdd.json +1 -0
- data/bench/cassettes/5c40794d3bf6e229c6ffd26922388776121f971ef27279e02a35ce004e0794dd.json +1 -0
- data/bench/cassettes/62f2e71a2d9cfdc18fdd6600f5a1450519b25a75c2f799b7695258627250497d.json +1 -0
- data/bench/cassettes/642becd7ad9cd4cc2ac6d389ef8ff14fd9fe125beca834afee625ea2c31476be.json +1 -0
- data/bench/cassettes/656cc1158890e928c129d9cd54c1e08af1b6a2c0f6f94bacf395b13a198dc81f.json +1 -0
- data/bench/cassettes/67b30efd5681046fb5f2252d73f9aedf603be1b7c7895e4e71c6cfb88238309e.json +1 -0
- data/bench/cassettes/691958dd39537d72c4b0ab4ba69fea3c51b8e06a49a772f3ffd5bb5747dfe99d.json +1 -0
- data/bench/cassettes/6931e51f0455b5e3260165aa22a445d3a4f782ae5233a961384149cfbb045fe0.json +1 -0
- data/bench/cassettes/6b79cf1a80fc9edd09bb4606613ff2de2ab2bc61487983dffac1174025270997.json +1 -0
- data/bench/cassettes/6d683884d5d039db072ac18c9420bdcc989838f08a71096076cccc2edcd48234.json +1 -0
- data/bench/cassettes/6df49dc7eae4104adf1b020fd846d893da3e49f4f13a4c6fd1f87a78c410ba31.json +1 -0
- data/bench/cassettes/75cc5d99bbc1d48ace6095d41daae1e2e2c829291f01d40f0ce40d6eea52c0bf.json +1 -0
- data/bench/cassettes/771a86cbb6c025101384a50d01a4745683e1c2b17bbfff73e40e475dbf016efb.json +1 -0
- data/bench/cassettes/7a2a93c357473f4a289a6ec250e3bfe6f51152a0c448e5420ffebee7872f9d11.json +1 -0
- data/bench/cassettes/7a4774a02c3eebe24d4fbd443ea90661711fe5cb8248bce7a2dedfedf6ddbc03.json +1 -0
- data/bench/cassettes/7c1fa04f8eb43251ca3f6652977b698e4e6ba5e120da8301b4dbce79ef01ccb9.json +1 -0
- data/bench/cassettes/7c4dea48c2bc2c413da81307ce3cbb2749b20e16e4d831fca5861decf11ec55b.json +1 -0
- data/bench/cassettes/7dd499da389eaeeb59eba533695c20fd1a43e25f89f21e3456b826f9953b2010.json +1 -0
- data/bench/cassettes/80438d0bbfe106baa236eef358e46260693e6491f67e7a9f8ea1076c6fbfd449.json +1 -0
- data/bench/cassettes/80db562534181514f243ac74d7ddaec1fafc28b2b72ae35482cfc2de8f2d9e61.json +1 -0
- data/bench/cassettes/825a51543665a468a55c823788c68eb5785c90be8131988f72bf98f4f0294177.json +1 -0
- data/bench/cassettes/83d8276c227b73e542bd70c023d9a8ffa1c2cf4dd78e71d61457f097bc743d19.json +1 -0
- data/bench/cassettes/891405ffd7727b6d4c4186e2c60a08aa8b62c7b711bd1558c8033fcf8e4178a4.json +1 -0
- data/bench/cassettes/89fabff05dc4c0abdf630781a324b7802cbc0dc8bd09b20ce59643465a2aeaec.json +1 -0
- data/bench/cassettes/8cbceece3ca6136b6c6bb71c9bf28f409a5fa070596ebfc0f3187aba287bf6d3.json +1 -0
- data/bench/cassettes/8d56da27350d9dea20468eae30fa51cbbb10637c06d977b3e1a00734966be46f.json +1 -0
- data/bench/cassettes/8fb43b452b5e15248f34bf48e41cfc227129cfe349fe4b3fc4ab7133d66914bc.json +1 -0
- data/bench/cassettes/92e5426bd0c1f31d7acca92f77d7f2586e74054467729f0be28d139f515354d5.json +1 -0
- data/bench/cassettes/938be225155e1952b817652778b7cab377017fc4a219628ae89ad01a9c1151bc.json +1 -0
- data/bench/cassettes/9519ad701b28e7b2c5f1e1cad5df1ec3e5254c8cec969b9bee1361af8803d39d.json +1 -0
- data/bench/cassettes/9536e8ed68330eda400d765c3ca16a4dd3bfe0d026b569599a33e7e4951f4860.json +1 -0
- data/bench/cassettes/99c6e5aefd97722f4028bacc11d299eb9036805cd5cf7bf75f879ebe3f27c00a.json +1 -0
- data/bench/cassettes/9cdfd2f3ef8dc4cde917a9d196e3137159067c10ec8d2132f32da034865f9959.json +1 -0
- data/bench/cassettes/a1e3030984d4e1982fb5a2b2068664187358c88c6b71f768841d3e78406e9535.json +1 -0
- data/bench/cassettes/a2140f51b8fa25da73efd387a41f9eb84ffbc1f1ab47dec0c3b37b249ff373a0.json +1 -0
- data/bench/cassettes/a36d248ce5c26fd66430b780c8ecdd176f29830ac4edb2bf0b2e07cb93ffb865.json +1 -0
- data/bench/cassettes/a64329816ca1e537e90b89862a86be119e1e6236c6f3cf0141a96505b0734364.json +1 -0
- data/bench/cassettes/aba24c67dcaefa51c204a529b5ea3fe01333181a608a8ab8899568c731677e7d.json +1 -0
- data/bench/cassettes/aed233524926f2b4ff8c2ec21224bd284595c91eb57e0fb6981cdc8ac7e7d8d3.json +1 -0
- data/bench/cassettes/b00e9da6403e48aa1f4f9346419f88b2a336e1a2ac4a28515d84b2337eb777b2.json +1 -0
- data/bench/cassettes/b02f4c76c073882ff0fd12fddc28e4db52d9e6486a130194bf1057c7693de831.json +1 -0
- data/bench/cassettes/b6a9cb89ae4afd08a6919eaf7f609790c895d4cf22d47dad6c3b287b9278be87.json +1 -0
- data/bench/cassettes/b71b982225eab48d02c81913877571dfa53717c0983d1d72cbe3787242dda055.json +1 -0
- data/bench/cassettes/bda5e115b566e7f955aa45222859fe4f725949d5f91e3d794821701491448510.json +1 -0
- data/bench/cassettes/c0ac703268faf8d49dbc1ad0e877693a9f52181ae6070de844fcd4218d33d5e4.json +1 -0
- data/bench/cassettes/c0b57bcbb4004edd44c2d8c7e1f8cfc3567d954a3d5b21b2cda7257e3bc07437.json +1 -0
- data/bench/cassettes/d1ade9adde20981211c470a77b4d560264cae290941e692be6876a79227e3d18.json +1 -0
- data/bench/cassettes/d28de3038544a5bf9c8425b6976e360fae71c5ca96409784ad6642703701986a.json +1 -0
- data/bench/cassettes/d6418acb9f7f58ffdf3049481cbd17cfb3f0581064e157c9c58c19a3fc26b4d4.json +1 -0
- data/bench/cassettes/d71e8cf7c2be4b6e4089eb40c5a108c0c45fc58398af5138f905129f640afbb8.json +1 -0
- data/bench/cassettes/d9672c45b5358b92f7740abfb20b287235cf949990c88558f41d3a9c00eb6d3b.json +1 -0
- data/bench/cassettes/da0c9ce695111f6acf3cfb58dc0494f79230739d1773377db420bf438fd2e244.json +1 -0
- data/bench/cassettes/db57e3a15971cd2832313172c0d7df6e6a48b13a276ba9877eeb7d39389d74d3.json +1 -0
- data/bench/cassettes/db9c8c4d0a8d855a2578572afe5edc5eebb39c0fd5f04489614ea2557ec4f0cd.json +1 -0
- data/bench/cassettes/e2c396b0475a07a615dde9208b088e9e479720c1ec5889ade07fbfd0d28fb517.json +1 -0
- data/bench/cassettes/e3006ccc97646f43f4ea23694665526e47982fa1ea413359b9669071f6cb990d.json +1 -0
- data/bench/cassettes/e623737636e797d22f80d128cbff46101837044e159f41fa62a8d3d0cc6bb996.json +1 -0
- data/bench/cassettes/ea0cefaaac1a62e8eac962391df025a3b14c3a3d5b69fe57d60b65811010908c.json +1 -0
- data/bench/cassettes/ee3e06456cf4201ab46f579dd53bdea4071ce43bfe02e9b870bcb4b9c1c680b4.json +1 -0
- data/bench/cassettes/f26e675da9088f08cb76dd6b3fd89230b84fd1b9be3767c36857217bda2da15b.json +1 -0
- data/bench/cassettes/f396f34b2356d42af385eac9f5760e97e553a584c3f93da7bd618ad311e04d93.json +1 -0
- data/bench/cassettes/fce02c5c04cb8c27e868f6c54cfcbbbf69bde2f7f612d50ff8fb6d0aec9df3e3.json +1 -0
- data/bench/cassettes/ffc09a04b083822c1597dd96bb27ee4cde365bc8346175ebf6e378ab2ab1b598.json +1 -0
- data/bench/cassettes/manifest.json +1 -0
- data/bench/fixtures/gold.jsonl +27 -0
- data/bench/fixtures/injection.jsonl +6 -0
- data/bench/fixtures/records.jsonl +90 -0
- data/bench/params.yml +30 -0
- data/lib/generators/truffler/install/install_generator.rb +51 -0
- data/lib/generators/truffler/install/templates/channel.rb.tt +17 -0
- data/lib/generators/truffler/install/templates/initializer.rb.tt +23 -0
- data/lib/generators/truffler/install/templates/migration.rb.tt +109 -0
- data/lib/tasks/truffler/bench.rake +30 -0
- data/lib/tasks/truffler/suggestions.rake +8 -0
- data/lib/tasks/truffler.rake +26 -0
- data/lib/truffler/answers.rb +66 -0
- data/lib/truffler/benchmark/adapters.rb +80 -0
- data/lib/truffler/benchmark/database.rb +31 -0
- data/lib/truffler/benchmark/dataset.rb +65 -0
- data/lib/truffler/benchmark/email.rb +50 -0
- data/lib/truffler/benchmark/generator.rb +188 -0
- data/lib/truffler/benchmark/metrics.rb +62 -0
- data/lib/truffler/benchmark/params.rb +91 -0
- data/lib/truffler/benchmark/runner.rb +370 -0
- data/lib/truffler/benchmark/synthetic_jev.rb +85 -0
- data/lib/truffler/benchmark.rb +29 -0
- data/lib/truffler/broadcaster.rb +46 -0
- data/lib/truffler/budget.rb +118 -0
- data/lib/truffler/canonical.rb +27 -0
- data/lib/truffler/clients/base.rb +49 -0
- data/lib/truffler/clients/callable.rb +26 -0
- data/lib/truffler/clients/cassette.rb +44 -0
- data/lib/truffler/clients/fake.rb +85 -0
- data/lib/truffler/clients/ruby_llm_typesafe.rb +32 -0
- data/lib/truffler/configuration.rb +89 -0
- data/lib/truffler/definition.rb +176 -0
- data/lib/truffler/embeddings/backfill.rb +48 -0
- data/lib/truffler/embeddings/column_store.rb +65 -0
- data/lib/truffler/embeddings/embedder.rb +53 -0
- data/lib/truffler/embeddings/fake_embedder.rb +45 -0
- data/lib/truffler/embeddings/label_vector.rb +81 -0
- data/lib/truffler/embeddings/neighbor_store.rb +66 -0
- data/lib/truffler/embeddings/ruby_llm_embedder.rb +25 -0
- data/lib/truffler/embeddings/ruby_store.rb +12 -0
- data/lib/truffler/embeddings/vector_store.rb +84 -0
- data/lib/truffler/embeddings.rb +30 -0
- data/lib/truffler/errors.rb +40 -0
- data/lib/truffler/instrumentation.rb +17 -0
- data/lib/truffler/jobs/backfill_job.rb +45 -0
- data/lib/truffler/jobs/embed_job.rb +33 -0
- data/lib/truffler/jobs/encode_query_job.rb +17 -0
- data/lib/truffler/jobs/expire_lenses_job.rb +15 -0
- data/lib/truffler/jobs/label_flush_job.rb +39 -0
- data/lib/truffler/jobs/lens_backfill_job.rb +33 -0
- data/lib/truffler/jobs/provider_search_job.rb +18 -0
- data/lib/truffler/jobs/prune_query_misses_job.rb +15 -0
- data/lib/truffler/jobs/rerank_chunk_job.rb +14 -0
- data/lib/truffler/jobs/resume_job.rb +61 -0
- data/lib/truffler/jobs/smart_search_job.rb +14 -0
- data/lib/truffler/label_definition.rb +153 -0
- data/lib/truffler/labeling/backfill.rb +189 -0
- data/lib/truffler/labeling/labeler.rb +161 -0
- data/lib/truffler/labeling/queue.rb +125 -0
- data/lib/truffler/labeling/request_builder.rb +87 -0
- data/lib/truffler/labeling/supplied.rb +70 -0
- data/lib/truffler/lenses/activator.rb +51 -0
- data/lib/truffler/lenses/backfill.rb +112 -0
- data/lib/truffler/lenses/draft.rb +16 -0
- data/lib/truffler/lenses/drafter.rb +147 -0
- data/lib/truffler/lenses/fake_generator.rb +58 -0
- data/lib/truffler/lenses/lens.rb +161 -0
- data/lib/truffler/lenses/lens_label.rb +51 -0
- data/lib/truffler/lenses/policy.rb +46 -0
- data/lib/truffler/lenses/previewer.rb +217 -0
- data/lib/truffler/lenses/proposer.rb +53 -0
- data/lib/truffler/lenses/ruby_llm_generator.rb +42 -0
- data/lib/truffler/lenses/scope.rb +35 -0
- data/lib/truffler/lenses/sealed_description.rb +29 -0
- data/lib/truffler/lenses/settings.rb +45 -0
- data/lib/truffler/lenses/validation.rb +69 -0
- data/lib/truffler/lenses/version.rb +34 -0
- data/lib/truffler/lenses.rb +138 -0
- data/lib/truffler/misses/clusterer.rb +69 -0
- data/lib/truffler/misses/recorder.rb +29 -0
- data/lib/truffler/misses/suggestions.rb +49 -0
- data/lib/truffler/misses.rb +83 -0
- data/lib/truffler/model.rb +111 -0
- data/lib/truffler/providers/backup.rb +58 -0
- data/lib/truffler/providers/runner.rb +67 -0
- data/lib/truffler/providers.rb +57 -0
- data/lib/truffler/query_encoding/cache.rb +71 -0
- data/lib/truffler/query_encoding/encoder.rb +183 -0
- data/lib/truffler/query_encoding/prefetch.rb +34 -0
- data/lib/truffler/query_encoding.rb +12 -0
- data/lib/truffler/questions.rb +78 -0
- data/lib/truffler/railtie.rb +11 -0
- data/lib/truffler/records/embedding.rb +39 -0
- data/lib/truffler/records/label.rb +11 -0
- data/lib/truffler/records/query_miss.rb +20 -0
- data/lib/truffler/records/record_state.rb +30 -0
- data/lib/truffler/redaction.rb +24 -0
- data/lib/truffler/registry.rb +16 -0
- data/lib/truffler/search/encoding.rb +84 -0
- data/lib/truffler/search/encoding_cache.rb +68 -0
- data/lib/truffler/search/keystroke.rb +149 -0
- data/lib/truffler/search/query.rb +67 -0
- data/lib/truffler/search/result.rb +100 -0
- data/lib/truffler/search/sql.rb +197 -0
- data/lib/truffler/smart_search/dispatcher.rb +88 -0
- data/lib/truffler/smart_search/reranker.rb +105 -0
- data/lib/truffler/smart_search/run.rb +306 -0
- data/lib/truffler/smart_search/starter.rb +73 -0
- data/lib/truffler/smart_search/store.rb +89 -0
- data/lib/truffler/smart_search.rb +43 -0
- data/lib/truffler/tokens.rb +12 -0
- data/lib/truffler/usage.rb +7 -0
- data/lib/truffler/version.rb +3 -0
- data/lib/truffler/vocabulary.rb +48 -0
- data/lib/truffler.rb +41 -0
- metadata +336 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# What query encoding (U9) decided about one query, as keystroke search
|
|
4
|
+
# reads it from the cache. Keys are label storage keys (`needs_action`,
|
|
5
|
+
# `category:billing`, `lens:<id>:<label>`).
|
|
6
|
+
#
|
|
7
|
+
# - `filters`: hard filters, key => minimum value, applied as EXISTS.
|
|
8
|
+
# - `boosts`: key => weight, shown as boost chips.
|
|
9
|
+
# - `intent_vector`: the sparse query vector of KTD20, key => weight, used
|
|
10
|
+
# for the label term SUM(weight * value). Defaults to `boosts`; the
|
|
11
|
+
# encoder decides whether filtered labels also carry weight here.
|
|
12
|
+
# - `keyword_tokens`: query tokens for the keyword source; nil means
|
|
13
|
+
# every token that is not a label term.
|
|
14
|
+
# - `label_term_tokens`: tokens that named a label rather than a keyword.
|
|
15
|
+
Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens) do
|
|
16
|
+
def self.load(value, query)
|
|
17
|
+
return value if value.is_a?(self)
|
|
18
|
+
return if value.nil?
|
|
19
|
+
|
|
20
|
+
tokens = query.tokens
|
|
21
|
+
new(filters: value["filters"], boosts: value["boosts"], intent_vector: value["intent_vector"],
|
|
22
|
+
keyword_tokens: value["keyword_positions"]&.map { |position| tokens[position] }&.compact,
|
|
23
|
+
label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [])
|
|
27
|
+
filters = weights(filters)
|
|
28
|
+
boosts = weights(boosts)
|
|
29
|
+
intent_vector = weights(intent_vector || boosts).reject { |_, weight| weight.zero? }
|
|
30
|
+
super(filters: filters.freeze, boosts: boosts.freeze, intent_vector: intent_vector.freeze,
|
|
31
|
+
keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def empty?
|
|
35
|
+
filters.empty? && boosts.empty? && intent_vector.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The encoding minus the chips the searcher removed (R20), matched by
|
|
39
|
+
# storage key or by label key.
|
|
40
|
+
def without(suppressed)
|
|
41
|
+
suppressed = Array(suppressed).map(&:to_s).to_set
|
|
42
|
+
return self if suppressed.empty?
|
|
43
|
+
|
|
44
|
+
keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
|
|
45
|
+
with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Splits a storage key into its label key and choice option. Lens keys
|
|
49
|
+
# ("lens:<id>:<label>[:<option>]") carry two extra colons.
|
|
50
|
+
def self.split_key(key)
|
|
51
|
+
key = key.to_s
|
|
52
|
+
if key.start_with?("#{Lenses::KEY_PREFIX}:")
|
|
53
|
+
prefix, id, label, option = key.split(":", 4)
|
|
54
|
+
[ "#{prefix}:#{id}:#{label}", option ]
|
|
55
|
+
else
|
|
56
|
+
key.split(":", 2)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def keywords(query)
|
|
61
|
+
keyword_tokens || (query.tokens - label_term_tokens)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The cache form: decisions plus token positions in the normalized
|
|
65
|
+
# query, so no query text is stored (the cache key already fixes the
|
|
66
|
+
# query).
|
|
67
|
+
def dump(query)
|
|
68
|
+
{ "filters" => filters, "boosts" => boosts, "intent_vector" => intent_vector,
|
|
69
|
+
"keyword_positions" => keyword_tokens && positions(query, keyword_tokens),
|
|
70
|
+
"label_term_positions" => positions(query, label_term_tokens) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def weights(hash)
|
|
76
|
+
hash.to_h.to_h { |key, weight| [ key.to_s, Float(weight) ] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def positions(query, words)
|
|
80
|
+
query.tokens.each_index.select { |index| words.include?(query.tokens[index]) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# Where keystroke search finds query encodings and query vectors (R12,
|
|
4
|
+
# R15). Keys digest the model, the normalized query, and the vocabulary
|
|
5
|
+
# version, plus the tenant when the vocabulary has per-tenant choices.
|
|
6
|
+
# Values hold decisions and floats, never query text. Encoding keys use
|
|
7
|
+
# the searcher's vocabulary version, which holds the lenses that searcher
|
|
8
|
+
# can see (KTD21), so a personal lens's encoding is never shared; query
|
|
9
|
+
# vectors do not depend on lenses and stay keyed per tenant.
|
|
10
|
+
#
|
|
11
|
+
# On a miss, search calls `prefetch`, which hands off to
|
|
12
|
+
# `config.encoding_prefetch` (the query encoder, U9). The hook is called
|
|
13
|
+
# as `call(model, query, cache_key:, tenant_key:, user_key:)` and returns
|
|
14
|
+
# truthy when an encoding is now in flight. Without a hook nothing is
|
|
15
|
+
# encoded and the encoding status reads `:none`.
|
|
16
|
+
class EncodingCache
|
|
17
|
+
TTL = 7.days
|
|
18
|
+
|
|
19
|
+
def initialize(store: Truffler.config.cache_store)
|
|
20
|
+
@store = store
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def key(model, query, tenant_key:, user_key: nil)
|
|
24
|
+
"truffler/enc/#{digest(model, query, tenant_key, user_key)}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def vector_key(model, query, tenant_key:)
|
|
28
|
+
"truffler/vec/#{digest(model, query, tenant_key, nil)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read(model, query, tenant_key:, user_key: nil)
|
|
32
|
+
query = Query.wrap(query)
|
|
33
|
+
Encoding.load(@store.read(key(model, query, tenant_key: tenant_key, user_key: user_key)), query)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write(model, query, encoding, tenant_key:, user_key: nil, expires_in: TTL)
|
|
37
|
+
query = Query.wrap(query)
|
|
38
|
+
@store.write(key(model, query, tenant_key: tenant_key, user_key: user_key), encoding.dump(query), expires_in: expires_in)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read_vector(model, query, tenant_key:)
|
|
42
|
+
@store.read(vector_key(model, Query.wrap(query), tenant_key: tenant_key))&.map(&:to_f)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write_vector(model, query, vector, tenant_key:, expires_in: TTL)
|
|
46
|
+
@store.write(vector_key(model, Query.wrap(query), tenant_key: tenant_key), vector.map(&:to_f), expires_in: expires_in)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def prefetch(model, query, tenant_key:, user_key:)
|
|
50
|
+
hook = Truffler.config.encoding_prefetch
|
|
51
|
+
return false unless hook
|
|
52
|
+
|
|
53
|
+
query = Query.wrap(query)
|
|
54
|
+
hook.call(model, query, cache_key: key(model, query, tenant_key: tenant_key, user_key: user_key), tenant_key: tenant_key,
|
|
55
|
+
user_key: user_key).present?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def digest(model, query, tenant_key, user_key)
|
|
61
|
+
definition = model.truffler_definition
|
|
62
|
+
tenant = tenant_key&.to_s if definition.per_tenant_vocabulary?
|
|
63
|
+
version = definition.vocabulary.version(tenant_key: tenant_key&.to_s, user_key: user_key)
|
|
64
|
+
Canonical.digest(record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# A tenant-scoped keystroke search (KTD8). It reads the query encoding
|
|
4
|
+
# and query vector from the cache only, so no network call sits on the
|
|
5
|
+
# keystroke (R12); a miss hands the query to the prefetch hook and the
|
|
6
|
+
# results stand without it. Everything else is one SQL query.
|
|
7
|
+
class Keystroke
|
|
8
|
+
DEFAULT_LIMIT = 50
|
|
9
|
+
|
|
10
|
+
attr_reader :model, :query, :tenant_key, :scope, :user_key, :suppressed, :surface, :limit
|
|
11
|
+
|
|
12
|
+
def initialize(model, query, tenant:, scope:, user: nil, suppressed: [], surface: nil, limit: DEFAULT_LIMIT, weights: {},
|
|
13
|
+
cache: EncodingCache.new)
|
|
14
|
+
@model = model
|
|
15
|
+
@definition = model.try(:truffler_definition) || raise(DefinitionError, "#{model.name} has no truffler declaration")
|
|
16
|
+
@query = Query.wrap(query)
|
|
17
|
+
@tenant_key = tenant&.to_s
|
|
18
|
+
@scope = scope.nil? && !@definition.scoped? ? model.all : scope
|
|
19
|
+
@user_key = self.class.user_key(user)
|
|
20
|
+
@suppressed = Array(suppressed).map(&:to_s)
|
|
21
|
+
@surface = surface&.to_s
|
|
22
|
+
@limit = limit
|
|
23
|
+
@weights = @definition.ranking.merge(weights.to_h { |key, weight| [ key.to_sym, Float(weight) ] })
|
|
24
|
+
@cache = cache
|
|
25
|
+
check_scope!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.user_key(user)
|
|
29
|
+
case user
|
|
30
|
+
when nil then nil
|
|
31
|
+
when ActiveRecord::Base then "#{user.class.polymorphic_name}:#{user.id}"
|
|
32
|
+
else user.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
started = Instrumentation.monotonic_ms
|
|
38
|
+
watermark = Time.current
|
|
39
|
+
explicit_action = surface_action
|
|
40
|
+
cached = read_encoding
|
|
41
|
+
status = encoding_status(cached)
|
|
42
|
+
encoding = visible_lenses_only(cached&.without(suppressed), record_usage: true)
|
|
43
|
+
sql = sql(encoding)
|
|
44
|
+
records = sql.relation(scope, limit: limit).to_a
|
|
45
|
+
result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
|
|
46
|
+
explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached),
|
|
47
|
+
weights: @weights, recount: ->(since) { count(since: since) })
|
|
48
|
+
instrument(result, started)
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# How many records the same search would return that arrived after
|
|
53
|
+
# `since` (R25). Reads the cache only and never prefetches.
|
|
54
|
+
def count(since:)
|
|
55
|
+
sql(visible_lenses_only(read_encoding&.without(suppressed))).candidates(scope)
|
|
56
|
+
.where(model.arel_table[@definition.arrived_at_column].gt(since)).count
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def check_scope!
|
|
62
|
+
if @definition.scoped? && tenant_key.nil?
|
|
63
|
+
raise MissingScope, "#{model.name}.truffler needs tenant: (declared tenant #{@definition.tenant_column})"
|
|
64
|
+
end
|
|
65
|
+
return if scope.is_a?(ActiveRecord::Relation) && scope.klass <= model
|
|
66
|
+
|
|
67
|
+
raise MissingScope, "#{model.name}.truffler needs scope: to be a relation of #{model.name}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def surface_action
|
|
71
|
+
return unless surface
|
|
72
|
+
|
|
73
|
+
@definition.surfaces.fetch(surface) { raise DefinitionError, "#{model.name} declares no surface #{surface}" }[:explicit_action]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_encoding
|
|
77
|
+
return if query.blank? || @definition.labels.empty?
|
|
78
|
+
|
|
79
|
+
@cache.read(model, query, tenant_key: tenant_key, user_key: user_key)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Drops lens keys this searcher cannot see (another user's personal
|
|
83
|
+
# lens, an expired lens) and counts a use of the rest (R42, R43).
|
|
84
|
+
def visible_lenses_only(encoding, record_usage: false)
|
|
85
|
+
lens_keys = encoding ? (encoding.intent_vector.keys | encoding.filters.keys | encoding.boosts.keys).select { |key| lens_id(key) } : []
|
|
86
|
+
return encoding if lens_keys.empty?
|
|
87
|
+
|
|
88
|
+
visible = Lenses.labels(model, tenant_key: tenant_key, user_key: user_key).values.map(&:lens_id).uniq
|
|
89
|
+
hidden, shown = lens_keys.partition { |key| !visible.include?(lens_id(key)) }
|
|
90
|
+
Lenses.record_usage(shown.map { |key| lens_id(key) }.uniq) if record_usage
|
|
91
|
+
encoding.without(hidden)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def lens_id(key)
|
|
95
|
+
prefix, id = key.split(":", 3)
|
|
96
|
+
Integer(id, exception: false) if prefix == Lenses::KEY_PREFIX
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def read_vector
|
|
100
|
+
return if query.blank? || !@definition.embeddings
|
|
101
|
+
|
|
102
|
+
@vector ||= @cache.read_vector(model, query, tenant_key: tenant_key)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Prefetches on a missing encoding or query vector; the status reports
|
|
106
|
+
# the encoding only.
|
|
107
|
+
def encoding_status(cached)
|
|
108
|
+
return :none if query.blank?
|
|
109
|
+
|
|
110
|
+
in_flight = prefetch?(cached) && @cache.prefetch(model, query, tenant_key: tenant_key, user_key: user_key)
|
|
111
|
+
return :cached if cached
|
|
112
|
+
return :none if @definition.labels.empty?
|
|
113
|
+
|
|
114
|
+
in_flight ? :pending : :none
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def prefetch?(cached)
|
|
118
|
+
(cached.nil? && @definition.labels.any?) || (@definition.embeddings.present? && read_vector.nil?)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def sql(encoding)
|
|
122
|
+
Sql.new(model, tenant_key: tenant_key, query: query, encoding: encoding, vector: read_vector, weights: @weights)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# R21: the Smart search row. A model with no local text search whose
|
|
126
|
+
# encoding is not cached yet invites the action even when a blind index
|
|
127
|
+
# matched, because intent queries resolve on the action there (AE10).
|
|
128
|
+
def invite_row(records, cached)
|
|
129
|
+
return if query.blank?
|
|
130
|
+
|
|
131
|
+
reason =
|
|
132
|
+
if @definition.keyword.blank? && cached.nil? then :encoding_pending
|
|
133
|
+
elsif records.empty? then :empty
|
|
134
|
+
elsif records.size < @definition.weak_below then :weak
|
|
135
|
+
end
|
|
136
|
+
{ query: query.raw.strip, reason: reason } if reason
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def instrument(result, started)
|
|
140
|
+
payload = { record_type: model.polymorphic_name, tenant_key: tenant_key, surface: surface, outcome: result.encoding_status,
|
|
141
|
+
result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i,
|
|
142
|
+
boost_count: result.encoding&.intent_vector&.size.to_i, sources: result.sources.map(&:to_s),
|
|
143
|
+
reason: result.invite_row&.dig(:reason), latency_ms: Instrumentation.elapsed_ms(started) }
|
|
144
|
+
payload[:query_digest] = Misses.digest(:query, query.normalized) if Misses.encrypted_model?(model)
|
|
145
|
+
Instrumentation.instrument("search", payload)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# A normalized, tokenized search query. Quoted phrases stay one token.
|
|
4
|
+
# Exact-text signals (quoted phrases, digit-bearing tokens, emails, and
|
|
5
|
+
# identifier shapes such as `INV-4471` or `order_id`) are detected
|
|
6
|
+
# locally and never asked of Jev (R18).
|
|
7
|
+
class Query
|
|
8
|
+
QUOTED = /"([^"]*)"/
|
|
9
|
+
EMAIL = /\A[^@\s]+@[^@\s]+\.[a-z]{2,}\z/
|
|
10
|
+
IDENTIFIER = /\A[a-z0-9]+(?:[-_.][a-z0-9]+)+\z/
|
|
11
|
+
EDGE_PUNCTUATION = /\A[^\p{Alnum}@]+|[^\p{Alnum}]+\z/
|
|
12
|
+
|
|
13
|
+
attr_reader :raw, :normalized, :tokens, :exact_tokens
|
|
14
|
+
|
|
15
|
+
def self.wrap(query)
|
|
16
|
+
query.is_a?(self) ? query : new(query)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.normalize(text)
|
|
20
|
+
text.to_s.unicode_normalize(:nfkc).downcase.squish
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(raw)
|
|
24
|
+
@raw = raw.to_s
|
|
25
|
+
@normalized = self.class.normalize(raw)
|
|
26
|
+
@tokens, @exact_tokens = tokenize(normalized)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def blank?
|
|
30
|
+
tokens.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def exact_text?
|
|
34
|
+
exact_tokens.any?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def tokenize(text)
|
|
40
|
+
tokens = []
|
|
41
|
+
exact = []
|
|
42
|
+
text.split(QUOTED, -1).each_with_index do |part, index|
|
|
43
|
+
if index.odd?
|
|
44
|
+
phrase = part.squish
|
|
45
|
+
next if phrase.empty?
|
|
46
|
+
|
|
47
|
+
tokens << phrase
|
|
48
|
+
exact << phrase
|
|
49
|
+
else
|
|
50
|
+
part.split.each do |word|
|
|
51
|
+
word = word.gsub(EDGE_PUNCTUATION, "")
|
|
52
|
+
next if word.empty?
|
|
53
|
+
|
|
54
|
+
tokens << word
|
|
55
|
+
exact << word if exact?(word)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
[ tokens.freeze, exact.freeze ]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def exact?(word)
|
|
63
|
+
word.match?(/\d/) || word.match?(EMAIL) || word.match?(IDENTIFIER)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# One keystroke search, carrying every host UI contract: records, chips
|
|
4
|
+
# (R20), the Smart search invite row (R21), the encoding status, the
|
|
5
|
+
# watermark for "N new matches" (R25), the surface's explicit action
|
|
6
|
+
# (R23), and per-record score breakdowns for debugging.
|
|
7
|
+
class Result
|
|
8
|
+
ENCODING_STATUSES = %i[cached pending none].freeze
|
|
9
|
+
INVITE_REASONS = %i[weak empty encoding_pending].freeze
|
|
10
|
+
SCORE_COLUMNS = { label: "truffler_label_score", text: "truffler_text_score", keyword: "truffler_keyword_score",
|
|
11
|
+
exact: "truffler_exact_score" }.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :records, :query, :encoding, :encoding_status, :watermark, :explicit_action, :sources, :invite_row
|
|
14
|
+
|
|
15
|
+
def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount:)
|
|
16
|
+
@records = records
|
|
17
|
+
@query = query
|
|
18
|
+
@encoding = encoding
|
|
19
|
+
@encoding_status = encoding_status
|
|
20
|
+
@watermark = watermark
|
|
21
|
+
@explicit_action = explicit_action
|
|
22
|
+
@sources = sources
|
|
23
|
+
@invite_row = invite_row
|
|
24
|
+
@weights = weights
|
|
25
|
+
@recount = recount
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ids
|
|
29
|
+
records.map(&:id)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Applied filters, then boosts, as `{key:, label:, kind:, name:}`.
|
|
33
|
+
def chips
|
|
34
|
+
return [] unless encoding
|
|
35
|
+
|
|
36
|
+
filters = encoding.filters.keys.map { |key| chip(key, :filter) }
|
|
37
|
+
filters + (encoding.boosts.keys - encoding.filters.keys).map { |key| chip(key, :boost) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def score(record)
|
|
41
|
+
find(record)&.attributes&.fetch("truffler_score", nil)&.to_f
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The weighted terms that summed to the record's score.
|
|
45
|
+
def breakdown(record)
|
|
46
|
+
found = find(record)
|
|
47
|
+
return {} unless found
|
|
48
|
+
|
|
49
|
+
SCORE_COLUMNS.to_h { |term, name| [ term, found.attributes.fetch(name, 0.0).to_f ] }.merge(total: score(found))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Each intent label's share of the label term, `key => w_label * weight
|
|
53
|
+
# * value`. Loaded on first use, so the keystroke itself stays one query.
|
|
54
|
+
def contributions(record)
|
|
55
|
+
id = record.respond_to?(:id) ? record.id : record
|
|
56
|
+
(@contributions ||= load_contributions).fetch(id.to_s, {})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def new_matches_count
|
|
60
|
+
@recount.call(watermark)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def promoted_ids(run)
|
|
64
|
+
return [] unless run
|
|
65
|
+
|
|
66
|
+
ids & Array(run.promoted_ids)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def smart_ranking_paused?(run = nil)
|
|
70
|
+
run&.status&.to_sym == :paused
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def find(record)
|
|
76
|
+
id = record.respond_to?(:id) ? record.id : record
|
|
77
|
+
records.find { |item| item.id == id }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def chip(key, kind)
|
|
81
|
+
label, option = Encoding.split_key(key)
|
|
82
|
+
title = label.split(":").last.humanize
|
|
83
|
+
name = option ? "#{title}: #{option}" : title
|
|
84
|
+
{ key: key, label: label, kind: kind, name: name }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load_contributions
|
|
88
|
+
intent = encoding&.intent_vector.to_h
|
|
89
|
+
return {} if intent.empty? || records.empty?
|
|
90
|
+
|
|
91
|
+
model = records.first.class
|
|
92
|
+
rows = Records::Label.where(record_type: model.polymorphic_name, record_id: ids, label_key: intent.keys)
|
|
93
|
+
.pluck(:record_id, :label_key, :value)
|
|
94
|
+
rows.each_with_object(Hash.new { |hash, id| hash[id] = {} }) do |(id, key, value), map|
|
|
95
|
+
map[id.to_s][key] = @weights[:label] * intent[key] * value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Search
|
|
3
|
+
# Builds the one keystroke query of KTD8, scored per KTD20:
|
|
4
|
+
#
|
|
5
|
+
# w_label * SUM(weight * value) over the intent's nonzero label keys
|
|
6
|
+
# + w_text * text similarity (inline SQL, or the store's top-K CASE)
|
|
7
|
+
# + w_keyword * keyword hit + w_exact * exact-source hit
|
|
8
|
+
#
|
|
9
|
+
# A weighted dot product, not cosine: cosine would divide out magnitude
|
|
10
|
+
# and let a record high on unrelated labels outrank the one the query
|
|
11
|
+
# asked for. Hard filters are EXISTS subqueries that run before scoring.
|
|
12
|
+
class Sql
|
|
13
|
+
LABELS = "truffler_labels".freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :model, :tenant_key, :query, :encoding, :vector, :weights
|
|
16
|
+
|
|
17
|
+
def initialize(model, tenant_key:, query:, encoding: nil, vector: nil, weights: nil, store: nil)
|
|
18
|
+
@model = model
|
|
19
|
+
@tenant_key = tenant_key&.to_s
|
|
20
|
+
@query = query
|
|
21
|
+
@encoding = encoding || Encoding.new
|
|
22
|
+
@vector = vector
|
|
23
|
+
@weights = weights || definition.ranking
|
|
24
|
+
@store = store
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The caller's relation, ANDed with the tenant and the hard filters.
|
|
28
|
+
def base(scope)
|
|
29
|
+
scope = scope.where(definition.tenant_column => tenant_key) if definition.scoped?
|
|
30
|
+
encoding.filters.reduce(scope) { |relation, (key, threshold)| relation.where(Arel.sql(label_filter_sql(key, threshold))) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Every record the query can return, before ranking.
|
|
34
|
+
def candidates(scope)
|
|
35
|
+
base = base(scope)
|
|
36
|
+
return base if label_only?
|
|
37
|
+
|
|
38
|
+
conditions = [ keyword_sql, exact_sql, (text_candidate_sql if text_score_sql) ].compact
|
|
39
|
+
base.where(Arel.sql(conditions.any? ? conditions.map { |condition| "(#{condition})" }.join(" OR ") : "1 = 0"))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def relation(scope, limit: nil)
|
|
43
|
+
relation = candidates(scope)
|
|
44
|
+
relation = relation.select(Arel.sql("#{table}.*")) if relation.select_values.empty?
|
|
45
|
+
relation = relation.select(*score_columns.map { |name, sql| Arel.sql("(#{sql}) AS #{name}") })
|
|
46
|
+
relation = relation.reorder(*ordering)
|
|
47
|
+
limit ? relation.limit(limit) : relation
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def label_score_sql
|
|
51
|
+
intent = encoding.intent_vector
|
|
52
|
+
return if intent.empty?
|
|
53
|
+
|
|
54
|
+
cases = intent.map { |key, weight| "WHEN #{quote(key)} THEN #{Float(weight)} * #{label_column('value')}" }.join(" ")
|
|
55
|
+
"COALESCE((SELECT SUM(CASE #{label_column('label_key')} #{cases} ELSE 0.0 END) FROM #{quoted_labels} " \
|
|
56
|
+
"WHERE #{label_scope_sql} AND #{label_column('label_key')} IN (#{intent.keys.map { |key| quote(key) }.join(', ')})), 0.0)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def sources
|
|
60
|
+
[ (:keyword if keyword_sql), (:exact if exact_sql), (:vector if text_score_sql),
|
|
61
|
+
(:labels unless encoding.empty?) ].compact
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def keywords
|
|
65
|
+
encoding.keywords(query)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def definition
|
|
71
|
+
model.truffler_definition
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def connection
|
|
75
|
+
model.connection
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def quote(value)
|
|
79
|
+
connection.quote(value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def table
|
|
83
|
+
connection.quote_table_name(model.table_name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def column(name)
|
|
87
|
+
"#{table}.#{connection.quote_column_name(name)}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def primary_key
|
|
91
|
+
column(model.primary_key)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def quoted_labels
|
|
95
|
+
connection.quote_table_name(LABELS)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def label_column(name)
|
|
99
|
+
"#{quoted_labels}.#{connection.quote_column_name(name)}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def label_scope_sql
|
|
103
|
+
tenant = definition.scoped? ? " AND #{label_column('tenant_key')} = #{quote(tenant_key)}" : ""
|
|
104
|
+
"#{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} AND #{label_column('record_id')} = #{primary_key}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def label_filter_sql(key, threshold)
|
|
108
|
+
"EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_scope_sql} AND #{label_column('label_key')} = #{quote(key)} " \
|
|
109
|
+
"AND #{label_column('value')} >= #{Float(threshold)})"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# All label terms (or a blank query) means label-only matches: every
|
|
113
|
+
# record past the hard filters is a candidate, ranked by the label
|
|
114
|
+
# term. So is an applied encoding on a model with nothing local to
|
|
115
|
+
# search its keywords with.
|
|
116
|
+
def label_only?
|
|
117
|
+
return true if keywords.empty?
|
|
118
|
+
|
|
119
|
+
!encoding.empty? && definition.keyword.blank? && !text_score_sql && definition.exact_sources.empty?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def keyword_sql
|
|
123
|
+
return @keyword_sql if defined?(@keyword_sql)
|
|
124
|
+
|
|
125
|
+
@keyword_sql = keywords.empty? ? nil : keyword_condition(definition.keyword)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def keyword_condition(source)
|
|
129
|
+
case source
|
|
130
|
+
when Array
|
|
131
|
+
return if source.empty?
|
|
132
|
+
|
|
133
|
+
keywords.map do |token|
|
|
134
|
+
pattern = quote("%#{model.sanitize_sql_like(token)}%")
|
|
135
|
+
"(#{source.map { |name| "LOWER(#{column(name)}) LIKE #{pattern} ESCAPE '\\'" }.join(' OR ')})"
|
|
136
|
+
end.join(" AND ")
|
|
137
|
+
when nil then nil
|
|
138
|
+
else membership_sql(source.call(tenant_scope, keywords))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def exact_sql
|
|
143
|
+
return @exact_sql if defined?(@exact_sql)
|
|
144
|
+
|
|
145
|
+
values = query.blank? ? [] : (query.exact_tokens + [ query.normalized ]).uniq
|
|
146
|
+
conditions = definition.exact_sources.values.flat_map do |callable|
|
|
147
|
+
values.filter_map { |value| membership_sql(callable.call(tenant_scope, value)) }
|
|
148
|
+
end
|
|
149
|
+
@exact_sql = conditions.empty? ? nil : conditions.join(" OR ")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def text_score_sql
|
|
153
|
+
return @text_score_sql if defined?(@text_score_sql)
|
|
154
|
+
|
|
155
|
+
@text_score_sql = (store.similarity_sql(model, tenant_key: tenant_key, vector: vector).to_s if vector && store)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def text_candidate_sql
|
|
159
|
+
"#{text_score_sql} > #{Float(weights[:min_similarity])}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def store
|
|
163
|
+
@store ||= Embeddings::VectorStore.for(model) if definition.embeddings
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def tenant_scope
|
|
167
|
+
definition.scoped? ? model.where(definition.tenant_column => tenant_key) : model.all
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def membership_sql(result)
|
|
171
|
+
case result
|
|
172
|
+
when ActiveRecord::Relation then "#{primary_key} IN (#{result.reselect(result.klass.arel_table[result.klass.primary_key]).to_sql})"
|
|
173
|
+
when nil then nil
|
|
174
|
+
else
|
|
175
|
+
ids = Array(result)
|
|
176
|
+
"#{primary_key} IN (#{ids.map { |id| quote(id) }.join(', ')})" if ids.any?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def score_columns
|
|
181
|
+
terms = {
|
|
182
|
+
truffler_label_score: (label_score_sql && "#{Float(weights[:label])} * #{label_score_sql}"),
|
|
183
|
+
truffler_text_score: (text_score_sql && "#{Float(weights[:text])} * #{text_score_sql}"),
|
|
184
|
+
truffler_keyword_score: (keyword_sql && "#{Float(weights[:keyword])} * (CASE WHEN #{keyword_sql} THEN 1.0 ELSE 0.0 END)"),
|
|
185
|
+
truffler_exact_score: (exact_sql && "#{Float(weights[:exact])} * (CASE WHEN #{exact_sql} THEN 1.0 ELSE 0.0 END)")
|
|
186
|
+
}.compact
|
|
187
|
+
{ truffler_score: terms.any? ? terms.values.map { |term| "(#{term})" }.join(" + ") : "0.0", **terms }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def ordering
|
|
191
|
+
order_column, direction = definition.order
|
|
192
|
+
[ Arel.sql("truffler_score DESC"), (Arel.sql("#{column(order_column)} #{direction == :asc ? 'ASC' : 'DESC'}") if order_column),
|
|
193
|
+
Arel.sql("#{primary_key} DESC") ].compact
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|