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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.agents/skills/diataxis/SKILL.md +142 -0
  3. data/.agents/skills/diataxis/references/examples.md +420 -0
  4. data/.agents/skills/diataxis/references/explanation-template.md +96 -0
  5. data/.agents/skills/diataxis/references/framework.md +400 -0
  6. data/.agents/skills/diataxis/references/how-to-guide-template.md +105 -0
  7. data/.agents/skills/diataxis/references/reference-template.md +110 -0
  8. data/.agents/skills/diataxis/references/tutorial-template.md +101 -0
  9. data/.agents/skills/diataxis/scripts/generate_index.py +139 -0
  10. data/.rspec +3 -0
  11. data/.standard.yml +3 -0
  12. data/AGENTS.md +120 -0
  13. data/CHANGELOG.md +5 -0
  14. data/Dockerfile.opensearch +4 -0
  15. data/Increase_Coverage.md +311 -0
  16. data/README.md +143 -0
  17. data/Rakefile +27 -0
  18. data/Steepfile +23 -0
  19. data/adrs/ADR-000-template.md +87 -0
  20. data/adrs/ADR-001-simpledelegator-for-client.md +138 -0
  21. data/adrs/ADR-002-facade-pattern-for-index.md +126 -0
  22. data/adrs/ADR-003-repository-pattern-for-models.md +148 -0
  23. data/adrs/ADR-004-integration-tests-no-mocking.md +91 -0
  24. data/adrs/ADR-005-exceptions-over-result-objects.md +107 -0
  25. data/adrs/ADR-006-ssl-on-by-default.md +95 -0
  26. data/adrs/ADR-007-selective-sugar-surface.md +118 -0
  27. data/adrs/ADR-008-integration-test-design.md +178 -0
  28. data/compose.yml +2 -0
  29. data/compose_opensearch.yml +31 -0
  30. data/docs/HOWTO.md +844 -0
  31. data/docs/REFERENCE.md +725 -0
  32. data/docs/TUTORIAL.md +327 -0
  33. data/docs/alias-api-design-notes.md +119 -0
  34. data/lib/opensearch/sugar/client.rb +300 -0
  35. data/lib/opensearch/sugar/index/include/utilities.rb +6 -0
  36. data/lib/opensearch/sugar/index.rb +339 -0
  37. data/lib/opensearch/sugar/models.rb +209 -0
  38. data/lib/opensearch/sugar/version.rb +8 -0
  39. data/lib/opensearch/sugar.rb +61 -0
  40. data/old_docs/DELEGATED_METHODS_ANALYSIS.md +361 -0
  41. data/old_docs/EXPLANATION.md +685 -0
  42. data/old_docs/README.md +155 -0
  43. data/old_docs/docs/CLI-PROPOSAL.md +257 -0
  44. data/old_docs/docs/HOWTO.md +798 -0
  45. data/old_docs/docs/REFERENCE.md +901 -0
  46. data/old_docs/docs/TUTORIAL.md +493 -0
  47. data/sig/opensearch/sugar.rbs +162 -0
  48. 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/)