opensearch-sugar 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 +7 -0
- data/.agents/skills/diataxis/SKILL.md +142 -0
- data/.agents/skills/diataxis/references/examples.md +420 -0
- data/.agents/skills/diataxis/references/explanation-template.md +96 -0
- data/.agents/skills/diataxis/references/framework.md +400 -0
- data/.agents/skills/diataxis/references/how-to-guide-template.md +105 -0
- data/.agents/skills/diataxis/references/reference-template.md +110 -0
- data/.agents/skills/diataxis/references/tutorial-template.md +101 -0
- data/.agents/skills/diataxis/scripts/generate_index.py +139 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/AGENTS.md +120 -0
- data/CHANGELOG.md +5 -0
- data/Dockerfile.opensearch +4 -0
- data/Increase_Coverage.md +311 -0
- data/README.md +143 -0
- data/Rakefile +27 -0
- data/Steepfile +23 -0
- data/adrs/ADR-000-template.md +87 -0
- data/adrs/ADR-001-simpledelegator-for-client.md +138 -0
- data/adrs/ADR-002-facade-pattern-for-index.md +126 -0
- data/adrs/ADR-003-repository-pattern-for-models.md +148 -0
- data/adrs/ADR-004-integration-tests-no-mocking.md +91 -0
- data/adrs/ADR-005-exceptions-over-result-objects.md +107 -0
- data/adrs/ADR-006-ssl-on-by-default.md +95 -0
- data/adrs/ADR-007-selective-sugar-surface.md +118 -0
- data/adrs/ADR-008-integration-test-design.md +178 -0
- data/compose.yml +2 -0
- data/compose_opensearch.yml +31 -0
- data/docs/HOWTO.md +844 -0
- data/docs/REFERENCE.md +725 -0
- data/docs/TUTORIAL.md +327 -0
- data/docs/alias-api-design-notes.md +119 -0
- data/lib/opensearch/sugar/client.rb +300 -0
- data/lib/opensearch/sugar/index/include/utilities.rb +6 -0
- data/lib/opensearch/sugar/index.rb +339 -0
- data/lib/opensearch/sugar/models.rb +209 -0
- data/lib/opensearch/sugar/version.rb +8 -0
- data/lib/opensearch/sugar.rb +61 -0
- data/old_docs/DELEGATED_METHODS_ANALYSIS.md +361 -0
- data/old_docs/EXPLANATION.md +685 -0
- data/old_docs/README.md +155 -0
- data/old_docs/docs/CLI-PROPOSAL.md +257 -0
- data/old_docs/docs/HOWTO.md +798 -0
- data/old_docs/docs/REFERENCE.md +901 -0
- data/old_docs/docs/TUTORIAL.md +493 -0
- data/sig/opensearch/sugar.rbs +162 -0
- metadata +240 -0
data/docs/HOWTO.md
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
# How-to Guides
|
|
2
|
+
|
|
3
|
+
Practical recipes for common tasks with opensearch-sugar. If you are just getting
|
|
4
|
+
started, see the [Tutorial](TUTORIAL.md) first.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## How to connect with explicit credentials
|
|
9
|
+
|
|
10
|
+
... rather than relying on environment variables.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "opensearch/sugar"
|
|
14
|
+
|
|
15
|
+
client = OpenSearch::Sugar::Client.new(
|
|
16
|
+
host: "https://search.example.com:9200",
|
|
17
|
+
user: "myuser",
|
|
18
|
+
password: "mypassword"
|
|
19
|
+
)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
To override transport options (timeouts, SSL):
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
client = OpenSearch::Sugar::Client.new(
|
|
26
|
+
host: "https://localhost:9200",
|
|
27
|
+
retry_on_failure: 3,
|
|
28
|
+
request_timeout: 10,
|
|
29
|
+
transport_options: {
|
|
30
|
+
ssl: {
|
|
31
|
+
verify: true,
|
|
32
|
+
ca_file: "/path/to/ca.pem"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For local development with a self-signed certificate, SSL verification is disabled
|
|
39
|
+
by default. Do not disable it in production.
|
|
40
|
+
|
|
41
|
+
### See also
|
|
42
|
+
|
|
43
|
+
- [Reference: Client constructor](REFERENCE.md#constructor)
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## How to access the raw OpenSearch client
|
|
48
|
+
|
|
49
|
+
`OpenSearch::Sugar::Client` is a `SimpleDelegator` — every method not defined by
|
|
50
|
+
Sugar is forwarded automatically to the underlying `OpenSearch::Client`. You do not
|
|
51
|
+
need `raw_client` for most calls:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# These all work directly on the Sugar client:
|
|
55
|
+
client.cluster.health
|
|
56
|
+
client.indices.get_alias(index: "products")
|
|
57
|
+
client.search(index: "products", body: { query: { match_all: {} } })
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If you need the unwrapped client explicitly:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
raw = client.raw_client
|
|
64
|
+
raw.cluster.health
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### See also
|
|
68
|
+
|
|
69
|
+
- [Reference: Client#raw_client](REFERENCE.md#clientraw_client)
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## How to manage indexes
|
|
74
|
+
|
|
75
|
+
### Create an index
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# KNN (vector search) enabled — the default
|
|
79
|
+
index = OpenSearch::Sugar::Index.create(client: client, name: "products")
|
|
80
|
+
|
|
81
|
+
# Without KNN
|
|
82
|
+
index = OpenSearch::Sugar::Index.create(client: client, name: "products", knn: false)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Raises `ArgumentError` if the index already exists. Use `open_or_create_index` for
|
|
86
|
+
idempotent setup scripts.
|
|
87
|
+
|
|
88
|
+
### Open an existing index
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
index = client["products"]
|
|
92
|
+
# equivalent to:
|
|
93
|
+
index = OpenSearch::Sugar::Index.open(client: client, name: "products")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Raises `ArgumentError` if the index does not exist.
|
|
97
|
+
|
|
98
|
+
### Open or create (idempotent)
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
index = client.open_or_create_index("products")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Check existence
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
client.has_index?("products") #=> true or false
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### List all user indexes
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
client.index_names #=> ["products", "orders"]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Delete an index
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
client.delete_index!("products")
|
|
120
|
+
# or, if you have an Index object:
|
|
121
|
+
index.delete!
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Both permanently delete the index and all its documents.
|
|
125
|
+
|
|
126
|
+
### Count documents
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
index.count #=> 42
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### See also
|
|
133
|
+
|
|
134
|
+
- [Reference: Index management methods](REFERENCE.md#opensearchsugarclient)
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## How to work with documents
|
|
139
|
+
|
|
140
|
+
### Index a single document
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
index.index_document({ title: "Dune", author: "Frank Herbert" }, "isbn-0441013597")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
For auto-generated IDs, use the client rather than the index object.
|
|
147
|
+
I should make this easier, but I never use it.
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
response = client.index(index: "products", body: { title: "Dune" })
|
|
151
|
+
puts response["_id"]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Get a document by ID
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
response = client.get(index: "products", id: "isbn-0441013597")
|
|
158
|
+
document = response["_source"]
|
|
159
|
+
|
|
160
|
+
# or
|
|
161
|
+
|
|
162
|
+
index = client["products"]
|
|
163
|
+
index["isbn-0441013597"]
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Update a document
|
|
168
|
+
|
|
169
|
+
Partial update (merge fields). This is all the underlying client,
|
|
170
|
+
put here because people are gonna want to do this sort of thing,
|
|
171
|
+
at least for testing.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
client.update(
|
|
175
|
+
index: "products",
|
|
176
|
+
id: "isbn-0441013597",
|
|
177
|
+
body: { doc: { price: 12.99 } }
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Full replacement:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
client.index(index: "products", id: "isbn-0441013597", body: { title: "Dune", price: 12.99 })
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Delete a document by ID
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
index.delete_by_id("isbn-0441013597")
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Delete all documents (keep index)
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
deleted = index.clear!
|
|
197
|
+
puts "Deleted #{deleted} documents"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Bulk index documents
|
|
201
|
+
|
|
202
|
+
For large datasets, use the raw `client.bulk` API instead of `index_document`.
|
|
203
|
+
OpenSearch uses a ... unique approach, with the file sent up
|
|
204
|
+
being jsonl, and the lines interpreted pairwise (i.e, each pair of
|
|
205
|
+
even and odd line are a single record.)
|
|
206
|
+
|
|
207
|
+
The `{ index: { _index: "products", _id: doc[:id] }` is the magic
|
|
208
|
+
line that will allow you to then index the following line
|
|
209
|
+
(hence add that thing, then the document)
|
|
210
|
+
|
|
211
|
+
If you've already made a file like that, use `index_jsonl_file`.
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
operations = []
|
|
216
|
+
documents.each do |doc|
|
|
217
|
+
operations << { index: { _index: "products", _id: doc[:id] } }
|
|
218
|
+
operations << doc
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
response = client.bulk(body: operations)
|
|
222
|
+
|
|
223
|
+
if response["errors"]
|
|
224
|
+
response["items"].each do |item|
|
|
225
|
+
if (err = item.dig("index", "error"))
|
|
226
|
+
puts "Error on #{item["index"]["_id"]}: #{err["reason"]}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Send a bunch of documents one-by-one from a _non_-pairwise jsonl file
|
|
233
|
+
|
|
234
|
+
`index_jsonl` takes a file that we assume is json, and
|
|
235
|
+
the name of the field within that json that holds the
|
|
236
|
+
unique id. It then parses the JSON, pulls out the id,
|
|
237
|
+
and sends the results over to the single-record
|
|
238
|
+
`#index_document`. One by one. This isn't fast,
|
|
239
|
+
just convenient.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
index.index_jsonl_file("/data/products.jsonl", id_field: :sku)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Bulk index documents
|
|
246
|
+
|
|
247
|
+
This is all working from the offical-gem client. None of my code.
|
|
248
|
+
Included here because people are gonna want to know.
|
|
249
|
+
|
|
250
|
+
OpenSearch uses a ... unique approach, with the file sent up
|
|
251
|
+
being jsonl, and the lines interpreted pairwise (i.e, each pair of
|
|
252
|
+
even and odd line are a single record.)
|
|
253
|
+
|
|
254
|
+
The `{ index: { _index: "products", _id: doc[:id] }` is the magic
|
|
255
|
+
line that will allow you to then index the following line
|
|
256
|
+
(hence add that thing, then the document)
|
|
257
|
+
|
|
258
|
+
Note that this does _not_ stream the results up to
|
|
259
|
+
OpenSearch -- it sends the whole big batch at once,
|
|
260
|
+
so make sure your server can handle whatever size
|
|
261
|
+
data you send it.
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
operations = []
|
|
265
|
+
documents.each do |doc|
|
|
266
|
+
operations << { index: { _index: "products", _id: doc[:id] } }
|
|
267
|
+
operations << doc
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
response = client.bulk(body: operations)
|
|
271
|
+
|
|
272
|
+
if response["errors"]
|
|
273
|
+
response["items"].each do |item|
|
|
274
|
+
if (err = item.dig("index", "error"))
|
|
275
|
+
puts "Error on #{item["index"]["_id"]}: #{err["reason"]}"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Make documents immediately searchable
|
|
282
|
+
|
|
283
|
+
After indexing, call `refresh` before querying in scripts and tests:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
index.refresh
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### See also
|
|
290
|
+
|
|
291
|
+
- [Reference: Index document methods](REFERENCE.md#indexindex_document)
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## How to search
|
|
296
|
+
|
|
297
|
+
opensearch-sugar delegates `search` and all other query methods to the underlying
|
|
298
|
+
client. Use them directly:
|
|
299
|
+
|
|
300
|
+
### Full-text search
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
response = client.search(
|
|
304
|
+
index: "products",
|
|
305
|
+
body: {
|
|
306
|
+
query: {
|
|
307
|
+
match: { title: "dune" }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
response["hits"]["hits"].each do |hit|
|
|
313
|
+
puts "#{hit["_source"]["title"]} (score: #{hit["_score"]})"
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Multi-field search with boosting
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
client.search(
|
|
321
|
+
index: "products",
|
|
322
|
+
body: {
|
|
323
|
+
query: {
|
|
324
|
+
multi_match: {
|
|
325
|
+
query: "science fiction",
|
|
326
|
+
fields: ["title^3", "description^2", "categories"],
|
|
327
|
+
type: "best_fields"
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`^3` means 3× weight for that field.
|
|
335
|
+
|
|
336
|
+
### Aggregations
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
response = client.search(
|
|
340
|
+
index: "products",
|
|
341
|
+
body: {
|
|
342
|
+
size: 0,
|
|
343
|
+
aggs: {
|
|
344
|
+
by_category: {
|
|
345
|
+
terms: { field: "category", size: 10 }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
response["aggregations"]["by_category"]["buckets"].each do |bucket|
|
|
352
|
+
puts "#{bucket["key"]}: #{bucket["doc_count"]}"
|
|
353
|
+
end
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### See also
|
|
357
|
+
|
|
358
|
+
- [OpenSearch Query DSL](https://opensearch.org/docs/latest/query-dsl/)
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## How to use an embedding pipeline when indexing
|
|
363
|
+
|
|
364
|
+
Once you have created an ingest pipeline (see
|
|
365
|
+
[How to deploy and use ML models](#how-to-deploy-and-use-ml-models)), pass its name
|
|
366
|
+
via the `pipeline:` parameter when indexing:
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
client.index(
|
|
370
|
+
index: "products",
|
|
371
|
+
pipeline: "book-embeddings",
|
|
372
|
+
body: {
|
|
373
|
+
title: "Dune",
|
|
374
|
+
description: "A science fiction epic set on the desert planet Arrakis."
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
OpenSearch runs the pipeline automatically. The `title_embedding` (or whatever target
|
|
380
|
+
field you configured in `field_map`) is populated before the document is stored.
|
|
381
|
+
|
|
382
|
+
This also works with bulk operations:
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
client.bulk(
|
|
386
|
+
body: operations,
|
|
387
|
+
pipeline: "book-embeddings"
|
|
388
|
+
)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### See also
|
|
392
|
+
|
|
393
|
+
- [How to deploy and use ML models](#how-to-deploy-and-use-ml-models)
|
|
394
|
+
- [OpenSearch ingest pipelines](https://opensearch.org/docs/latest/api-reference/ingest-apis/index/)
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## How to handle errors
|
|
399
|
+
|
|
400
|
+
### Index not found
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
begin
|
|
404
|
+
index = client["nonexistent"]
|
|
405
|
+
rescue ArgumentError => e
|
|
406
|
+
puts e.message
|
|
407
|
+
index = client.open_or_create_index("nonexistent")
|
|
408
|
+
end
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Connection failure
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
begin
|
|
415
|
+
client.ping
|
|
416
|
+
rescue OpenSearch::Transport::Transport::Error => e
|
|
417
|
+
puts "Could not reach cluster: #{e.message}"
|
|
418
|
+
end
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Bulk operation partial failures
|
|
422
|
+
|
|
423
|
+
`client.bulk` does not raise on partial failures — check `response["errors"]`:
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
response = client.bulk(body: operations)
|
|
427
|
+
if response["errors"]
|
|
428
|
+
failed = response["items"].select { |item| item.dig("index", "error") }
|
|
429
|
+
failed.each do |item|
|
|
430
|
+
puts "Failed #{item["index"]["_id"]}: #{item.dig("index", "error", "reason")}"
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Analyzer not found
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
begin
|
|
439
|
+
tokens = index.test_analyzer_by_name(analyzer: "missing", text: "hello")
|
|
440
|
+
rescue ArgumentError => e
|
|
441
|
+
puts e.message
|
|
442
|
+
puts "Available: #{index.analyzers.join(", ")}"
|
|
443
|
+
end
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Retrying with exponential backoff
|
|
447
|
+
|
|
448
|
+
The client retries automatically (`retry_on_failure: 5` by default). For application-level
|
|
449
|
+
retry logic with backoff:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
retries = 0
|
|
453
|
+
begin
|
|
454
|
+
client.index(index: "products", id: "1", body: { title: "Dune" })
|
|
455
|
+
rescue OpenSearch::Transport::Transport::Error => e
|
|
456
|
+
retries += 1
|
|
457
|
+
raise if retries >= 3
|
|
458
|
+
sleep(2**retries)
|
|
459
|
+
retry
|
|
460
|
+
end
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### See also
|
|
464
|
+
|
|
465
|
+
- [Reference: Errors](REFERENCE.md#errors)
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## How to configure custom analyzers
|
|
470
|
+
|
|
471
|
+
For when `text` and `string` just aren't enough.
|
|
472
|
+
See the [OpenSearch analysis reference](https://opensearch.org/docs/latest/analyzers/)
|
|
473
|
+
|
|
474
|
+
### Before you start
|
|
475
|
+
|
|
476
|
+
- A running OpenSearch cluster
|
|
477
|
+
- An existing index (or use `open_or_create_index`)
|
|
478
|
+
- Settings must be applied **before** you index documents, or before you remap fields that use those analyzers
|
|
479
|
+
|
|
480
|
+
### Steps
|
|
481
|
+
|
|
482
|
+
#### Define the analyzer in settings
|
|
483
|
+
|
|
484
|
+
```ruby
|
|
485
|
+
index.update_settings(
|
|
486
|
+
settings: {
|
|
487
|
+
analysis: {
|
|
488
|
+
filter: {
|
|
489
|
+
my_stop: {
|
|
490
|
+
type: "stop",
|
|
491
|
+
stopwords: ["the", "a", "an"]
|
|
492
|
+
},
|
|
493
|
+
my_stem: {
|
|
494
|
+
type: "stemmer",
|
|
495
|
+
language: "english"
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
analyzer: {
|
|
499
|
+
my_english: {
|
|
500
|
+
type: "custom",
|
|
501
|
+
tokenizer: "standard",
|
|
502
|
+
filter: ["lowercase", "my_stop", "my_stem"]
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
`update_settings` automatically closes the index, applies the settings, and reopens
|
|
511
|
+
it. Do not close the index yourself.
|
|
512
|
+
|
|
513
|
+
#### Verify the analyzer is registered
|
|
514
|
+
|
|
515
|
+
```ruby
|
|
516
|
+
puts index.analyzers
|
|
517
|
+
# => ["my_english"]
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
#### Apply the analyzer to a field
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
index.update_mappings(
|
|
524
|
+
mappings: {
|
|
525
|
+
properties: {
|
|
526
|
+
body: { type: "text", analyzer: "my_english" }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
)
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Troubleshooting
|
|
533
|
+
|
|
534
|
+
**`ArgumentError: Analyzer 'x' does not exist in index 'y'`**
|
|
535
|
+
The analyzer was not registered before calling `test_analyzer_by_name`. Run `update_settings`
|
|
536
|
+
first, then verify with `index.analyzers`.
|
|
537
|
+
|
|
538
|
+
**Settings update fails with a 400 error**
|
|
539
|
+
Some settings (e.g. `number_of_shards`) cannot be changed after creation. Analysis
|
|
540
|
+
settings can always be updated.
|
|
541
|
+
|
|
542
|
+
### See also
|
|
543
|
+
|
|
544
|
+
- [How to debug text analysis](#how-to-debug-text-analysis)
|
|
545
|
+
- [How to define field mappings](#how-to-define-field-mappings)
|
|
546
|
+
- [OpenSearch analysis reference](https://opensearch.org/docs/latest/analyzers/)
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## How to define field mappings
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
### Before you start
|
|
554
|
+
|
|
555
|
+
- Custom analyzers referenced in the mappings must already be registered (see
|
|
556
|
+
[How to configure custom analyzers](#how-to-configure-custom-analyzers))
|
|
557
|
+
- Mappings can be added to after creation, but existing field types cannot be changed
|
|
558
|
+
|
|
559
|
+
### Steps
|
|
560
|
+
|
|
561
|
+
#### Apply mappings
|
|
562
|
+
|
|
563
|
+
```ruby
|
|
564
|
+
index.update_mappings(
|
|
565
|
+
mappings: {
|
|
566
|
+
properties: {
|
|
567
|
+
title: { type: "text", analyzer: "my_english" },
|
|
568
|
+
author: { type: "text" },
|
|
569
|
+
isbn: { type: "keyword" },
|
|
570
|
+
published: { type: "date" },
|
|
571
|
+
page_count: { type: "integer" },
|
|
572
|
+
categories: { type: "keyword" },
|
|
573
|
+
embedding: { type: "knn_vector", dimension: 384 }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
#### Inspect current mappings
|
|
580
|
+
|
|
581
|
+
```ruby
|
|
582
|
+
pp index.mappings
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
The response is keyed by field name:
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
{
|
|
589
|
+
"books" => {
|
|
590
|
+
"mappings" => {
|
|
591
|
+
"properties" => {
|
|
592
|
+
"title" => { "type" => "text", "analyzer" => "my_english" },
|
|
593
|
+
# ...
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### Troubleshooting
|
|
601
|
+
|
|
602
|
+
**Mapping conflict error on update**
|
|
603
|
+
You cannot change an existing field's type. Create a new index with the correct mapping
|
|
604
|
+
and reindex your data.
|
|
605
|
+
|
|
606
|
+
**Field not found when calling `test_analyzer_by_fieldname`**
|
|
607
|
+
The field must exist in the mappings and must have an `analyzer` key. `keyword` fields
|
|
608
|
+
have no analyzer — use `test_analyzer_by_name` with an explicit analyzer name instead.
|
|
609
|
+
|
|
610
|
+
### See also
|
|
611
|
+
|
|
612
|
+
- [Reference: Index#update_mappings](REFERENCE.md#indexupdate_mappings)
|
|
613
|
+
- [OpenSearch field types](https://opensearch.org/docs/latest/field-types/)
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## How to create and use index aliases
|
|
618
|
+
|
|
619
|
+
Use aliases when you want a stable name for an index that may be replaced (e.g. during
|
|
620
|
+
a reindex operation), or when you want one name to point to multiple indexes.
|
|
621
|
+
|
|
622
|
+
### Steps
|
|
623
|
+
|
|
624
|
+
#### Add an alias
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
index.create_alias("products_current")
|
|
628
|
+
# => ["products_current"]
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
#### Verify aliases
|
|
632
|
+
|
|
633
|
+
```ruby
|
|
634
|
+
puts index.aliases
|
|
635
|
+
# => ["products_current"]
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
#### Use the alias to access the index
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
current = client["products_current"]
|
|
642
|
+
puts current.count
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
#### Swap an alias between two indexes (blue/green reindex)
|
|
646
|
+
|
|
647
|
+
This uses the raw client for the atomic swap, which opensearch-sugar delegates:
|
|
648
|
+
|
|
649
|
+
```ruby
|
|
650
|
+
client.indices.update_aliases(
|
|
651
|
+
body: {
|
|
652
|
+
actions: [
|
|
653
|
+
{ remove: { index: "products_v1", alias: "products_current" } },
|
|
654
|
+
{ add: { index: "products_v2", alias: "products_current" } }
|
|
655
|
+
]
|
|
656
|
+
}
|
|
657
|
+
)
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
### Troubleshooting
|
|
661
|
+
|
|
662
|
+
**`BadRequest` when creating alias**
|
|
663
|
+
An alias with the same name already exists on a different index. Either remove it from
|
|
664
|
+
the other index first, or use `update_aliases` for an atomic swap.
|
|
665
|
+
|
|
666
|
+
### See also
|
|
667
|
+
|
|
668
|
+
- [Reference: Index#create_alias](REFERENCE.md#indexcreate_alias)
|
|
669
|
+
- [OpenSearch aliases](https://opensearch.org/docs/latest/opensearch/index-alias/)
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
673
|
+
## How to deploy and use ML models
|
|
674
|
+
|
|
675
|
+
This guide shows you how to register a pre-trained sentence-embedding model and create
|
|
676
|
+
an ingest pipeline that generates embeddings automatically at index time.
|
|
677
|
+
|
|
678
|
+
### Before you start
|
|
679
|
+
|
|
680
|
+
- The ML Commons plugin must be enabled on your cluster
|
|
681
|
+
- The cluster must have enough memory to hold the model (typically 1–2 GB)
|
|
682
|
+
- For local development, start OpenSearch with `plugins.ml_commons.only_run_on_ml_node: false`
|
|
683
|
+
|
|
684
|
+
### Steps
|
|
685
|
+
|
|
686
|
+
#### Register and deploy the model
|
|
687
|
+
|
|
688
|
+
```ruby
|
|
689
|
+
model = client.models.register(
|
|
690
|
+
name: "all-MiniLM-L6-v2",
|
|
691
|
+
version: "1.0.0",
|
|
692
|
+
format: "TORCH_SCRIPT"
|
|
693
|
+
)
|
|
694
|
+
puts "Model ID: #{model.id}"
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
`register` is idempotent — calling it again returns the existing model without
|
|
698
|
+
re-registering. It polls until deployment completes (about 30–120 seconds first time).
|
|
699
|
+
|
|
700
|
+
#### List deployed models
|
|
701
|
+
|
|
702
|
+
```ruby
|
|
703
|
+
client.models.list.each do |m|
|
|
704
|
+
puts "#{m.name} v#{m.version} (#{m.id})"
|
|
705
|
+
end
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
#### Look up a model by name or partial name
|
|
709
|
+
|
|
710
|
+
```ruby
|
|
711
|
+
m = client.models["MiniLM"] # partial, case-insensitive
|
|
712
|
+
m = client.models["all-MiniLM-L6-v2"] # exact name
|
|
713
|
+
m = client.models["abc123xyz"] # by ID
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
#### Create an embedding ingest pipeline
|
|
717
|
+
|
|
718
|
+
```ruby
|
|
719
|
+
client.models.create_pipeline(
|
|
720
|
+
name: "book-embeddings",
|
|
721
|
+
model: "all-MiniLM-L6-v2",
|
|
722
|
+
description: "Generate title embeddings for semantic search",
|
|
723
|
+
field_map: { "title" => "title_embedding" }
|
|
724
|
+
)
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
`field_map` maps source text fields to target vector fields. The pipeline runs
|
|
728
|
+
automatically when documents are indexed.
|
|
729
|
+
|
|
730
|
+
#### Delete a model
|
|
731
|
+
|
|
732
|
+
```ruby
|
|
733
|
+
client.models.delete!("all-MiniLM-L6-v2") # undeploys then deletes
|
|
734
|
+
client.models.undeploy!("all-MiniLM-L6-v2") # unloads from memory only
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
#### Delete a pipeline
|
|
738
|
+
|
|
739
|
+
```ruby
|
|
740
|
+
client.models.delete_pipeline!("book-embeddings")
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### Troubleshooting
|
|
744
|
+
|
|
745
|
+
**`register` raises with a FAILED state**
|
|
746
|
+
Check cluster logs for memory or plugin errors. Make sure `only_run_on_ml_node` is
|
|
747
|
+
configured appropriately.
|
|
748
|
+
|
|
749
|
+
**`NoMethodError` from `undeploy!` or `delete!`**
|
|
750
|
+
The model name or ID was not found. Use `client.models.list` to confirm the exact name.
|
|
751
|
+
|
|
752
|
+
### See also
|
|
753
|
+
|
|
754
|
+
- [Reference: Models](REFERENCE.md#models)
|
|
755
|
+
- [OpenSearch ML Commons plugin](https://opensearch.org/docs/latest/ml-commons-plugin/)
|
|
756
|
+
- [OpenSearch semantic search](https://opensearch.org/docs/latest/ml-commons-plugin/semantic-search/)
|
|
757
|
+
|
|
758
|
+
---
|
|
759
|
+
|
|
760
|
+
## How to debug text analysis
|
|
761
|
+
|
|
762
|
+
Once you've made a custom analyzer (see above), installed a pipeline,
|
|
763
|
+
etc., you're going to want to test it.
|
|
764
|
+
|
|
765
|
+
This guide shows you how to inspect the tokens that OpenSearch produces from a string,
|
|
766
|
+
so you can diagnose unexpected search behaviour.
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
### Steps
|
|
770
|
+
|
|
771
|
+
#### List analyzers defined on the index
|
|
772
|
+
|
|
773
|
+
```ruby
|
|
774
|
+
puts index.analyzers
|
|
775
|
+
# => ["my_english", "my_exact"]
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
#### Inspect tokens from a named analyzer
|
|
779
|
+
|
|
780
|
+
```ruby
|
|
781
|
+
tokens = index.test_analyzer_by_name(
|
|
782
|
+
analyzer: "my_english",
|
|
783
|
+
text: "The Running Foxes jumped quickly"
|
|
784
|
+
)
|
|
785
|
+
puts tokens.inspect
|
|
786
|
+
# => ["run", "fox", "jump", "quick"]
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
#### Inspect tokens using a field's configured analyzer
|
|
790
|
+
|
|
791
|
+
```ruby
|
|
792
|
+
tokens = index.test_analyzer_by_fieldname(
|
|
793
|
+
field: "title",
|
|
794
|
+
text: "The Running Foxes jumped quickly"
|
|
795
|
+
)
|
|
796
|
+
puts tokens.inspect
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
This uses whatever analyzer is configured on the `title` field's mapping, so the
|
|
800
|
+
results exactly match what OpenSearch stores at index time.
|
|
801
|
+
|
|
802
|
+
#### Compare index-time and search-time analyzers
|
|
803
|
+
|
|
804
|
+
If a field uses different analyzers for indexing and querying (`analyzer` vs
|
|
805
|
+
`search_analyzer`), call `test_analyzer_by_name` explicitly for each:
|
|
806
|
+
|
|
807
|
+
```ruby
|
|
808
|
+
index_tokens = index.test_analyzer_by_name(analyzer: "my_index_analyzer", text: query)
|
|
809
|
+
search_tokens = index.test_analyzer_by_name(analyzer: "my_search_analyzer", text: query)
|
|
810
|
+
puts "Indexed as: #{index_tokens}"
|
|
811
|
+
puts "Queried as: #{search_tokens}"
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
Mismatches here are a common cause of zero-result queries.
|
|
815
|
+
|
|
816
|
+
#### Understand grouped tokens
|
|
817
|
+
|
|
818
|
+
When multiple tokens share a position in the token stream (e.g. from a synonym filter),
|
|
819
|
+
they are returned as a nested array. For example, if you're
|
|
820
|
+
using a synonyms file and you specify that `fast` and `rapid` are
|
|
821
|
+
synonyms, you'll get, for "brown fast fox":
|
|
822
|
+
|
|
823
|
+
```ruby
|
|
824
|
+
# => ["brown", ["fast", "rapid"], "fox"]
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
Both `"fast"` and `"rapid"` occupy the same token position (2).
|
|
828
|
+
|
|
829
|
+
### Troubleshooting
|
|
830
|
+
|
|
831
|
+
**`ArgumentError: Analyzer 'x' does not exist`**
|
|
832
|
+
The analyzer is not defined on this index. Use `index.analyzers` to see what is
|
|
833
|
+
available. Built-in analyzers (e.g. `standard`, `english`) cannot be referenced with
|
|
834
|
+
`test_analyzer_by_name` — use the raw client's `indices.analyze` API directly for those.
|
|
835
|
+
|
|
836
|
+
**`ArgumentError: No analyzer specified for field 'x'`**
|
|
837
|
+
The field exists but has no `analyzer` key in its mapping (e.g. it is a `keyword`
|
|
838
|
+
field). Use `test_analyzer_by_name` with an explicit analyzer name instead.
|
|
839
|
+
|
|
840
|
+
### See also
|
|
841
|
+
|
|
842
|
+
- [Reference: Index#test_analyzer_by_name](REFERENCE.md#indextest_analyzer_by_name)
|
|
843
|
+
- [Reference: Index#test_analyzer_by_fieldname](REFERENCE.md#indextest_analyzer_by_fieldname)
|
|
844
|
+
- [OpenSearch text analysis](https://opensearch.org/docs/latest/analyzers/)
|