|
| 1 | +from palace.manager.search.document import ( |
| 2 | + BASIC_TEXT, |
| 3 | + BOOLEAN, |
| 4 | + FILTERABLE_TEXT, |
| 5 | + FLOAT, |
| 6 | + INTEGER, |
| 7 | + LONG, |
| 8 | + SearchMappingDocument, |
| 9 | + SearchMappingFieldType, |
| 10 | + icu_collation_keyword, |
| 11 | + keyword, |
| 12 | + nested, |
| 13 | + sort_author_keyword, |
| 14 | +) |
| 15 | +from palace.manager.search.revision import SearchSchemaRevision |
| 16 | + |
| 17 | + |
| 18 | +class SearchV8(SearchSchemaRevision): |
| 19 | + """The version 8 search schema revision. |
| 20 | +
|
| 21 | + This revision is intentionally **self-contained**: it does not inherit from |
| 22 | + any previous revision and defines its complete mapping (analyzers, filters, |
| 23 | + and fields) directly. This is how new schema revisions are meant to be defined. |
| 24 | +
|
| 25 | + We formed a bad habit for several old schema revisions where they just chained |
| 26 | + off one another (``SearchV7`` -> ``SearchV6`` -> ``SearchV5``) which meant |
| 27 | + no old revision could be removed without breaking the ones build on top of |
| 28 | + it. |
| 29 | +
|
| 30 | + From now on every revision should stand alone so that, once nothing in |
| 31 | + production is using an older version, that version's module can simply be deleted. |
| 32 | +
|
| 33 | + Changes in v8: |
| 34 | + - The shard and replica counts are set explicitly. |
| 35 | + - sets search slow-query-log thresholds |
| 36 | + """ |
| 37 | + |
| 38 | + @property |
| 39 | + def version(self) -> int: |
| 40 | + return 8 |
| 41 | + |
| 42 | + # The number of primary shards for indexes created by this revision. |
| 43 | + # This setting is immutable once an index is created. |
| 44 | + NUMBER_OF_SHARDS = 1 |
| 45 | + |
| 46 | + # The number of replicas for indexes created by this revision. |
| 47 | + NUMBER_OF_REPLICAS = 1 |
| 48 | + |
| 49 | + # Slow-query-log thresholds applied to every index this revision creates. |
| 50 | + # Keys are relative to "index". |
| 51 | + SEARCH_SLOWLOG_THRESHOLDS = { |
| 52 | + "search.slowlog.threshold.query.warn": "3s", |
| 53 | + "search.slowlog.threshold.query.info": "1s", |
| 54 | + "search.slowlog.threshold.fetch.warn": "3s", |
| 55 | + "search.slowlog.threshold.fetch.info": "1s", |
| 56 | + } |
| 57 | + |
| 58 | + # Use regular expressions to normalized values in sortable fields. |
| 59 | + # These regexes are applied in order; that way "H. G. Wells" |
| 60 | + # becomes "H G Wells" becomes "HG Wells". |
| 61 | + CHAR_FILTERS = { |
| 62 | + "remove_apostrophes": dict( |
| 63 | + type="pattern_replace", |
| 64 | + pattern="'", |
| 65 | + replacement="", |
| 66 | + ) |
| 67 | + } |
| 68 | + |
| 69 | + AUTHOR_CHAR_FILTER_NAMES = [] |
| 70 | + for name, pattern, replacement in [ |
| 71 | + # The special author name "[Unknown]" should sort after everything |
| 72 | + # else. REPLACEMENT CHARACTER is the final valid Unicode character. |
| 73 | + ("unknown_author", r"\[Unknown\]", "\N{REPLACEMENT CHARACTER}"), |
| 74 | + # Works by a given primary author should be secondarily sorted |
| 75 | + # by title, not by the other contributors. |
| 76 | + ("primary_author_only", r"\s+;.*", ""), |
| 77 | + # Remove parentheticals (e.g. the full name of someone who |
| 78 | + # goes by initials). |
| 79 | + ("strip_parentheticals", r"\s+\([^)]+\)", ""), |
| 80 | + # Remove periods from consideration. |
| 81 | + ("strip_periods", r"\.", ""), |
| 82 | + # Collapse spaces for people whose sort names end with initials. |
| 83 | + ("collapse_three_initials", r" ([A-Z]) ([A-Z]) ([A-Z])$", " $1$2$3"), |
| 84 | + ("collapse_two_initials", r" ([A-Z]) ([A-Z])$", " $1$2"), |
| 85 | + ]: |
| 86 | + normalizer = dict( |
| 87 | + type="pattern_replace", pattern=pattern, replacement=replacement |
| 88 | + ) |
| 89 | + CHAR_FILTERS[name] = normalizer |
| 90 | + AUTHOR_CHAR_FILTER_NAMES.append(name) |
| 91 | + |
| 92 | + def __init__(self) -> None: |
| 93 | + super().__init__() |
| 94 | + |
| 95 | + self._normalizers = {} |
| 96 | + self._filters = {} |
| 97 | + self._analyzers = {} |
| 98 | + |
| 99 | + # Set up character filters. |
| 100 | + # |
| 101 | + self._char_filters = self.CHAR_FILTERS |
| 102 | + |
| 103 | + # This normalizer is used on freeform strings that |
| 104 | + # will be used as tokens in filters. This way we can, |
| 105 | + # e.g. ignore capitalization when considering whether |
| 106 | + # two books belong to the same series or whether two |
| 107 | + # author names are the same. |
| 108 | + self._normalizers["filterable_string"] = dict( |
| 109 | + type="custom", filter=["lowercase", "asciifolding"] |
| 110 | + ) |
| 111 | + |
| 112 | + # Set up analyzers. |
| 113 | + # |
| 114 | + |
| 115 | + # We use three analyzers: |
| 116 | + # |
| 117 | + # 1. An analyzer based on Opensearch's default English |
| 118 | + # analyzer, with a normal stemmer -- used as the default |
| 119 | + # view of a text field such as 'description'. |
| 120 | + # |
| 121 | + # 2. An analyzer that's exactly the same as #1 but with a less |
| 122 | + # aggressive stemmer -- used as the 'minimal' view of a |
| 123 | + # text field such as 'description.minimal'. |
| 124 | + # |
| 125 | + # 3. An analyzer that's exactly the same as #2 but with |
| 126 | + # English stopwords left in place instead of filtered out -- |
| 127 | + # used as the 'with_stopwords' view of a text field such as |
| 128 | + # 'title.with_stopwords'. |
| 129 | + # |
| 130 | + # The analyzers are identical except for the end of the filter |
| 131 | + # chain. |
| 132 | + # |
| 133 | + # All three analyzers are based on Opensearch's default English |
| 134 | + # analyzer, defined here: |
| 135 | + # https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html#english-analyzer |
| 136 | + |
| 137 | + # First, recreate the filters from the default English |
| 138 | + # analyzer. We'll be using these to build our own analyzers. |
| 139 | + |
| 140 | + # Filter out English stopwords. |
| 141 | + self._filters["english_stop"] = dict(type="stop", stopwords=["_english_"]) |
| 142 | + # The default English stemmer, used in the en_default analyzer. |
| 143 | + self._filters["english_stemmer"] = dict(type="stemmer", language="english") |
| 144 | + # A less aggressive English stemmer, used in the en_minimal analyzer. |
| 145 | + self._filters["minimal_english_stemmer"] = dict( |
| 146 | + type="stemmer", language="minimal_english" |
| 147 | + ) |
| 148 | + # A filter that removes English posessives such as "'s" |
| 149 | + self._filters["english_posessive_stemmer"] = dict( |
| 150 | + type="stemmer", language="possessive_english" |
| 151 | + ) |
| 152 | + |
| 153 | + # Some potentially useful filters that are currently not used: |
| 154 | + # |
| 155 | + # * keyword_marker -- Exempt certain keywords from stemming |
| 156 | + # * synonym -- Introduce synonyms for words |
| 157 | + # (but probably better to use synonym_graph during the search |
| 158 | + # -- it's more flexible). |
| 159 | + |
| 160 | + # Here's the common analyzer configuration. The comment NEW |
| 161 | + # means this is something we added on top of Opensearch's |
| 162 | + # default configuration for the English analyzer. |
| 163 | + common_text_analyzer = dict( |
| 164 | + type="custom", |
| 165 | + char_filter=["html_strip", "remove_apostrophes"], # NEW |
| 166 | + tokenizer="standard", |
| 167 | + ) |
| 168 | + common_filter = [ |
| 169 | + "lowercase", |
| 170 | + "asciifolding", # NEW |
| 171 | + ] |
| 172 | + |
| 173 | + # The default_text_analyzer uses Opensearch's standard |
| 174 | + # English stemmer and removes stopwords. |
| 175 | + self._analyzers["en_default_text_analyzer"] = dict(common_text_analyzer) |
| 176 | + self._analyzers["en_default_text_analyzer"]["filter"] = common_filter + [ |
| 177 | + "english_stop", |
| 178 | + "english_stemmer", |
| 179 | + ] |
| 180 | + |
| 181 | + # The minimal_text_analyzer uses a less aggressive English |
| 182 | + # stemmer, and removes stopwords. |
| 183 | + self._analyzers["en_minimal_text_analyzer"] = dict(common_text_analyzer) |
| 184 | + self._analyzers["en_minimal_text_analyzer"]["filter"] = common_filter + [ |
| 185 | + "english_stop", |
| 186 | + "minimal_english_stemmer", |
| 187 | + ] |
| 188 | + |
| 189 | + # The en_with_stopwords_text_analyzer uses the less aggressive |
| 190 | + # stemmer and does not remove stopwords. |
| 191 | + self._analyzers["en_with_stopwords_text_analyzer"] = dict(common_text_analyzer) |
| 192 | + self._analyzers["en_with_stopwords_text_analyzer"]["filter"] = common_filter + [ |
| 193 | + "minimal_english_stemmer" |
| 194 | + ] |
| 195 | + |
| 196 | + # Now we need to define a special analyzer used only by the |
| 197 | + # 'sort_author' property. |
| 198 | + |
| 199 | + # Here's a special filter used only by that analyzer. It |
| 200 | + # duplicates the filter used by the icu_collation_keyword data |
| 201 | + # type. |
| 202 | + self._filters["en_sortable_filter"] = dict( |
| 203 | + type="icu_collation", language="en", country="US" |
| 204 | + ) |
| 205 | + |
| 206 | + # Here's the analyzer used by the 'sort_author' property. |
| 207 | + # It's the same as icu_collation_keyword, but it has some |
| 208 | + # extra character filters -- regexes that do things like |
| 209 | + # convert "Tolkien, J. R. R." to "Tolkien, JRR". |
| 210 | + # |
| 211 | + # This is necessary because normal icu_collation_keyword |
| 212 | + # fields can't specify char_filter. |
| 213 | + self._analyzers["en_sort_author_analyzer"] = dict( |
| 214 | + tokenizer="keyword", |
| 215 | + filter=["en_sortable_filter"], |
| 216 | + char_filter=self.AUTHOR_CHAR_FILTER_NAMES, |
| 217 | + ) |
| 218 | + |
| 219 | + self._fields: dict[str, SearchMappingFieldType] = { |
| 220 | + "summary": BASIC_TEXT, |
| 221 | + "title": FILTERABLE_TEXT, |
| 222 | + "subtitle": FILTERABLE_TEXT, |
| 223 | + "series": FILTERABLE_TEXT, |
| 224 | + "classifications.term": FILTERABLE_TEXT, |
| 225 | + "author": FILTERABLE_TEXT, |
| 226 | + "publisher": FILTERABLE_TEXT, |
| 227 | + "imprint": FILTERABLE_TEXT, |
| 228 | + "presentation_ready": BOOLEAN, |
| 229 | + "sort_title": icu_collation_keyword(), |
| 230 | + "sort_author": sort_author_keyword(), |
| 231 | + "series_position": INTEGER, |
| 232 | + "work_id": INTEGER, |
| 233 | + "last_update_time": LONG, |
| 234 | + "published": LONG, |
| 235 | + "audience": keyword(), |
| 236 | + "language": keyword(), |
| 237 | + # Added in v6. |
| 238 | + "lane_priority_level": INTEGER, |
| 239 | + } |
| 240 | + |
| 241 | + contributors = nested() |
| 242 | + contributors.add_property("display_name", FILTERABLE_TEXT) |
| 243 | + contributors.add_property("sort_name", FILTERABLE_TEXT) |
| 244 | + contributors.add_property("family_name", FILTERABLE_TEXT) |
| 245 | + contributors.add_property("role", keyword()) |
| 246 | + contributors.add_property("lc", keyword()) |
| 247 | + contributors.add_property("viaf", keyword()) |
| 248 | + self._fields["contributors"] = contributors |
| 249 | + |
| 250 | + licensepools = nested() |
| 251 | + licensepools.add_property("collection_id", INTEGER) |
| 252 | + licensepools.add_property("data_source_id", INTEGER) |
| 253 | + licensepools.add_property("availability_time", LONG) |
| 254 | + licensepools.add_property("available", BOOLEAN) |
| 255 | + licensepools.add_property("open_access", BOOLEAN) |
| 256 | + licensepools.add_property("suppressed", BOOLEAN) |
| 257 | + licensepools.add_property("licensed", BOOLEAN) |
| 258 | + licensepools.add_property("medium", keyword()) |
| 259 | + # Added in v7. |
| 260 | + licensepools.add_property("last_updated", LONG) |
| 261 | + self._fields["licensepools"] = licensepools |
| 262 | + |
| 263 | + identifiers = nested() |
| 264 | + identifiers.add_property("type", keyword()) |
| 265 | + identifiers.add_property("identifier", keyword()) |
| 266 | + self._fields["identifiers"] = identifiers |
| 267 | + |
| 268 | + genres = nested() |
| 269 | + genres.add_property("scheme", keyword()) |
| 270 | + genres.add_property("name", keyword()) |
| 271 | + genres.add_property("term", keyword()) |
| 272 | + genres.add_property("weight", FLOAT) |
| 273 | + self._fields["genres"] = genres |
| 274 | + |
| 275 | + customlists = nested() |
| 276 | + customlists.add_property("list_id", INTEGER) |
| 277 | + customlists.add_property("first_appearance", LONG) |
| 278 | + customlists.add_property("featured", BOOLEAN) |
| 279 | + self._fields["customlists"] = customlists |
| 280 | + |
| 281 | + def mapping_document(self) -> SearchMappingDocument: |
| 282 | + document = SearchMappingDocument() |
| 283 | + document.settings["analysis"] = dict( |
| 284 | + filter=dict(self._filters), |
| 285 | + char_filter=dict(self._char_filters), |
| 286 | + normalizer=dict(self._normalizers), |
| 287 | + analyzer=dict(self._analyzers), |
| 288 | + ) |
| 289 | + # Index settings |
| 290 | + document.settings["index"] = { |
| 291 | + "number_of_shards": self.NUMBER_OF_SHARDS, |
| 292 | + "number_of_replicas": self.NUMBER_OF_REPLICAS, |
| 293 | + **self.SEARCH_SLOWLOG_THRESHOLDS, |
| 294 | + } |
| 295 | + document.properties = self._fields |
| 296 | + return document |
0 commit comments