Lab VE1: Semantic Search End-to-End
You will stand up a complete dense semantic-search pipeline on a local cluster, with
no vectors ever leaving your terminal as raw floats. You register and deploy an embedding
model in ml-commons, create an ingest pipeline whose text_embedding processor
embeds text into a knn_vector field at index time, index a small corpus, then run a
neural query and compare its ranking against a plain BM25 match. By the end you
will have seen — and verified — every hop from text to stored vector to nearest-neighbour
result.
This is the concrete build of the dense pipeline diagrammed in the
masterclass index. It sits on top of
the k-NN query path (which the neural query becomes) and
HNSW algorithms (the index it walks).
Background
Keyword search matches tokens; semantic search matches meaning. The trick is to
precompute an embedding for every document and, at query time, embed the query and find
the nearest document vectors. OpenSearch does both halves for you: the text_embedding
ingest processor calls ml-commons to embed documents as they index, and the neural
query calls ml-commons to embed the query text and then runs a k-NN search. Your job is to
wire the three plugins — ml-commons (serves the model), neural-search (the processor and
query), k-NN (the knn_vector field) — into one working flow.
Why This Matters for Contributors
- Semantic search is the headline use case for OpenSearch's vector stack; bugs reported against it usually turn out to be in one specific hop (model not deployed, dimension mismatch, wrong model_id at query time). Knowing the hops lets you triage in minutes.
- The
neuralquery is a thin wrapper that becomes aKNNQueryBuilder. Once you have run this lab you can connect a user's "my neural query is slow/empty" to the exact k-NN internals in the query path chapter. - You will learn to inspect the generated vectors, which is the single most useful debugging skill for this subsystem.
Prerequisites
-
A local OpenSearch 2.13+ (or 3.x) single-node cluster with the
opensearch-knn,opensearch-ml, andneural-searchplugins (all bundled in the default distribution).bin/opensearchrunning, REST onlocalhost:9200. -
curland (optionally)jqfor reading JSON. - You have read the masterclass index, Part B.
- ~2 GB free RAM for the local model (MiniLM is small; this is comfortable).
Note: This lab uses a local model (
all-MiniLM-L6-v2, 384 dims) so you need no external API keys. The remote-connector path from the index is identical from the ingest/query side — only the_registerbody differs.
Step-by-Step Tasks
Step 1 — Relax ML settings for a single-node dev cluster
On a one-node dev box, ML models would otherwise refuse to run (they want a dedicated ML node) and URL registration is off by default. Enable both:
curl -XPUT 'localhost:9200/_cluster/settings' -H 'Content-Type: application/json' -d '{
"persistent": {
"plugins.ml_commons.only_run_on_ml_node": false,
"plugins.ml_commons.allow_registering_model_via_url": true,
"plugins.ml_commons.native_memory_threshold": 99
}
}'
Expected: {"acknowledged":true,"persistent":{"plugins":{"ml_commons":{...}}}}.
Warning: These are dev settings. In production you run models on dedicated ML nodes and do not blanket-allow URL registration. We relax them only so a laptop works.
Step 2 — Register a model group, then the model
A model group is a versioning/ACL container. Create one, capture its id:
curl -XPOST 'localhost:9200/_plugins/_ml/model_groups/_register' \
-H 'Content-Type: application/json' -d '{
"name": "semantic_search_models",
"description": "dense encoders for VE labs"
}'
Expected response:
{ "model_group_id": "Z1xQk4cB...", "status": "CREATED" }
Now register the pretrained MiniLM encoder into that group (substitute your
model_group_id). Registration is asynchronous — it returns a task_id:
curl -XPOST 'localhost:9200/_plugins/_ml/models/_register' \
-H 'Content-Type: application/json' -d '{
"name": "huggingface/sentence-transformers/all-MiniLM-L6-v2",
"version": "1.0.1",
"model_group_id": "Z1xQk4cB...",
"model_format": "TORCH_SCRIPT"
}'
Expected:
{ "task_id": "aF2Rk4cB...", "status": "CREATED" }
Step 3 — Poll the register task, capture the model_id
curl -s 'localhost:9200/_plugins/_ml/tasks/aF2Rk4cB...?pretty'
While downloading you will see "state": "RUNNING". When done:
{
"model_id": "bG3Sk4cB...",
"task_type": "REGISTER_MODEL",
"state": "COMPLETED",
"worker_node": ["..."]
}
Capture model_id — call it <MODEL_ID> for the rest of the lab. If state is
FAILED, read the error field (usually a settings or network issue → revisit Step 1).
Step 4 — Deploy the model onto the node
Registration stored the model; deploy loads it into memory so it can run inference:
curl -XPOST 'localhost:9200/_plugins/_ml/models/<MODEL_ID>/_deploy'
# -> { "task_id": "...", "status": "CREATED" } (async again)
Poll the model's state until DEPLOYED:
curl -s 'localhost:9200/_plugins/_ml/models/<MODEL_ID>?pretty'
{
"model_id": "bG3Sk4cB...",
"model_state": "DEPLOYED",
"model_config": { "embedding_dimension": 384, "framework_type": "SENTENCE_TRANSFORMERS" },
"model_format": "TORCH_SCRIPT"
}
Note embedding_dimension: 384 — you will need it for the mapping in Step 6. A
mismatch here is the #1 bug in this lab.
Step 5 — Smoke-test inference directly
Before wiring it into ingest, confirm the model embeds text. This also shows you what a raw embedding looks like:
curl -XPOST 'localhost:9200/_plugins/_ml/_predict/text_embedding/<MODEL_ID>' \
-H 'Content-Type: application/json' -d '{ "text_docs": ["a fast red sports car"] }'
Expected (truncated):
{
"inference_results": [{
"output": [{
"name": "sentence_embedding",
"data_type": "FLOAT32",
"shape": [384],
"data": [0.0421, -0.0117, 0.0683, /* ... 384 floats ... */ -0.0094]
}]
}]
}
shape: [384] confirms the dimension. If this fails, nothing downstream will work — fix
it here.
Step 6 — Create the ingest pipeline with a text_embedding processor
The processor embeds a source text field into a destination knn_vector field for every
document that flows through it:
curl -XPUT 'localhost:9200/_ingest/pipeline/nlp-ingest-pipeline' \
-H 'Content-Type: application/json' -d '{
"description": "embed `text` into `text_embedding` knn_vector",
"processors": [
{ "text_embedding": {
"model_id": "<MODEL_ID>",
"field_map": { "text": "text_embedding" }
}}
]
}'
field_map reads source_field → writes destination_field. Expected:
{"acknowledged":true}.
Step 7 — Create the index: knn_vector mapping + default pipeline
The index must (a) enable k-NN, (b) map text_embedding as a knn_vector of dimension
384 with an HNSW method, and (c) use the ingest pipeline by default so you never have to
name it per-request:
curl -XPUT 'localhost:9200/semantic-demo' -H 'Content-Type: application/json' -d '{
"settings": {
"index.knn": true,
"default_pipeline": "nlp-ingest-pipeline"
},
"mappings": {
"properties": {
"text": { "type": "text" },
"text_embedding": {
"type": "knn_vector",
"dimension": 384,
"space_type": "l2",
"method": {
"name": "hnsw",
"engine": "lucene",
"parameters": { "ef_construction": 128, "m": 16 }
}
}
}
}
}'
We use engine: lucene so the dense distance runs through Lucene's VectorUtil → Panama
SIMD (the SIMD chapter). faiss works too; see
engines. Expected: {"acknowledged":true,...}.
Warning:
dimensionmust equal the model'sembedding_dimension(384 here). If you copy a 768-dim mapping by habit, every index request fails with a dimension mismatch. This is the most common failure in the lab — see Troubleshooting.
Step 8 — Index a small corpus (embeddings auto-generated)
Bulk-index documents with only text — the pipeline embeds them. The corpus is chosen so semantic vs keyword differences show up:
curl -XPOST 'localhost:9200/semantic-demo/_bulk' \
-H 'Content-Type: application/json' -d '
{"index":{"_id":"1"}}
{"text":"A fast red sports car speeding down the highway"}
{"index":{"_id":"2"}}
{"text":"The automobile industry is shifting to electric vehicles"}
{"index":{"_id":"3"}}
{"text":"A delicious recipe for homemade margherita pizza"}
{"index":{"_id":"4"}}
{"text":"Quarterly earnings report shows strong revenue growth"}
{"index":{"_id":"5"}}
{"text":"Jaguars and cheetahs are the fastest land predators"}
{"index":{"_id":"6"}}
{"text":"Tips for tuning the engine of your motor vehicle"}
'
curl -XPOST 'localhost:9200/semantic-demo/_refresh'
Expected: a bulk response with "errors": false. If errors is true, inspect the
per-item error.reason (almost always a dimension or model-deploy issue).
Step 9 — Verify the vectors were actually generated
Confirm the processor ran by reading one document's _source — it should now contain a
384-float text_embedding you never sent:
curl -s 'localhost:9200/semantic-demo/_doc/1?pretty&_source_includes=text,text_embedding' \
| head -c 600
Expected (truncated): the original text plus a text_embedding array of 384 floats.
To check the stored vector dimension explicitly without dumping 384 numbers:
# field caps confirm it's a knn_vector; doc count confirms ingestion
curl -s 'localhost:9200/semantic-demo/_field_caps?fields=text_embedding&pretty'
curl -s 'localhost:9200/semantic-demo/_count?pretty' # -> { "count": 6, ... }
If text_embedding is missing from _source, the pipeline did not run — check that
default_pipeline is set on the index (GET /semantic-demo/_settings).
Step 10 — Run a neural query
Now the payoff. Query by meaning. Ask for "automobile" — note the corpus says "car," "vehicle," "automobile," never the exact same word across all docs:
curl -XPOST 'localhost:9200/semantic-demo/_search?pretty' \
-H 'Content-Type: application/json' -d '{
"size": 4,
"_source": ["text"],
"query": {
"neural": {
"text_embedding": {
"query_text": "automobile",
"model_id": "<MODEL_ID>",
"k": 4
}
}
}
}'
Expected top hits (scores will vary slightly by model build, but the ordering is the
point): the car/vehicle docs (1, 2, 6) rank highest, including doc 6 ("motor
vehicle") and doc 2 ("automobile industry") even though "automobile" the exact token does
not appear in doc 1 or 6. The pizza (3) and earnings (4) docs rank far lower.
{
"hits": {
"max_score": 0.73,
"hits": [
{ "_id": "2", "_score": 0.71, "_source": { "text": "The automobile industry ..." } },
{ "_id": "1", "_score": 0.64, "_source": { "text": "A fast red sports car ..." } },
{ "_id": "6", "_score": 0.58, "_source": { "text": "Tips for tuning the engine ..." } },
{ "_id": "5", "_score": 0.41, "_source": { "text": "Jaguars and cheetahs ..." } }
]
}
}
Step 11 — Compare against a plain BM25 match
Run the same query text through keyword search:
curl -XPOST 'localhost:9200/semantic-demo/_search?pretty' \
-H 'Content-Type: application/json' -d '{
"size": 4,
"_source": ["text"],
"query": { "match": { "text": "automobile" } }
}'
Expected: only doc 2 matches (it is the only one containing the literal token "automobile"); everything else scores 0 and is not returned. This is the whole point:
| Query for "automobile" | Returns | Why |
|---|---|---|
match (BM25) | doc 2 only | exact token match only |
neural (semantic) | docs 2, 1, 6, 5 ranked | "car", "vehicle", "engine"... are near "automobile" in embedding space |
You have now demonstrated semantic recall that keyword search cannot achieve. The two are complementary — fusing them is Lab VE2.
Step 12 — Confirm the SIMD path underneath (optional but instructive)
The neural query became a Lucene HNSW search whose distances ran through VectorUtil.
Confirm the node has the SIMD module loaded:
ps -ef | grep -i opensearch | grep -o "add-modules [^ ]*"
# expect: add-modules jdk.incubator.vector (OpenSearch jvm.options enables it on JDK 21)
If it is present, your 384-dim distances were computed with PanamaVectorUtilSupport. To
benchmark the difference yourself, do Lab VE4.
Deliverables
-
A deployed ml-commons model (
model_state: DEPLOYED,embedding_dimension: 384). -
An ingest pipeline
nlp-ingest-pipelinewith atext_embeddingprocessor. -
An index
semantic-demowith aknn_vectorfield +default_pipeline, holding 6 docs whose embeddings were auto-generated. -
A
neuralquery result and amatchquery result on the same text, with a written explanation of why they differ.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
_register returns FAILED task | URL registration disabled / no ML node | Step 1 settings; check tasks/<id> error |
_deploy stuck RUNNING then FAILED | not enough native memory, or native_memory_threshold too low | raise threshold (Step 1); free RAM |
Bulk index errors:true, "dimension mismatch" | mapping dimension ≠ model dim | set dimension: 384 to match embedding_dimension |
_source has no text_embedding | pipeline not applied | set index.default_pipeline, or pass ?pipeline= on write |
neural query: 400 "model not found / not deployed" | wrong or undeployed model_id | GET /_plugins/_ml/models/<id> → must be DEPLOYED |
neural query returns 0 hits | embeddings absent (pipeline never ran) on indexed docs | re-index after the pipeline exists; verify Step 9 |
| Good inference but bad ranking | different model_id/version at query vs ingest | use the same <MODEL_ID> everywhere |
First neural query very slow, then fast | cold model + cold graph load | expected; warm with a throwaway query |
Expected Output
A neural search for "automobile" returns the car/vehicle/engine documents ranked by
semantic similarity (docs 2, 1, 6 at top), while the equivalent BM25 match returns only
the single doc containing the literal token. The index holds 6 documents each carrying an
auto-generated 384-float text_embedding.
Stretch Goals
- Query for "jungle predator" and watch doc 5 (jaguars/cheetahs) surface — a document that shares no query token. Pure semantic recall.
-
Add
"k": 6and"size": 6; confirm all docs return ranked, including the unrelated pizza/earnings docs at the bottom (k-NN always returns something). -
Re-create the index with
engine: faissand re-run; results should match, and you can compare latency (took). Read engines on the difference. - Register a second model at a different dimension (e.g. a 768-dim encoder), reuse the 384-dim mapping, and observe the exact dimension-mismatch failure — then fix it. This builds the muscle memory for the #1 bug.
-
Inspect the generated HNSW vector files on disk: find the shard dir and list the
.vec/.vex/.vemfiles (see Lucene HNSW files).
Coding Exercises
So far you have run the pipeline with curl. These exercises make you write code
that drives, indexes, and grades it — the harnesses you would actually use to
evaluate or debug a semantic-search change. Use Python 3 with requests (or
opensearch-py); the cluster from the steps above is your fixture.
-
(warm-up) An encode-and-inspect script. Write
encode.pythat POSTs to_plugins/_ml/_predict/text_embedding/<MODEL_ID>for a list of texts, parses the JSON, and asserts every returned vector has length 384 and a finite L2 norm. Print the norm and the first 5 components. This is the single most useful debugging tool for this subsystem — it isolates the model hop from everything downstream. The norm/inner-product math behind it is in vector-math-foundations.md. -
(warm-up) A bulk-indexer that verifies embeddings landed. Write
index_corpus.pythat bulk-indexes a list of texts (text only) intosemantic-demo, refreshes, then reads back_doc/<id>?_source_includes=text_embeddingfor each and asserts the 384-float array is present and non-zero. Fail loudly with the doc id if any embedding is missing — exactly the "pipeline didn't run" bug from Step 9. -
(core) A recall@k evaluation harness. Write
eval_recall.pythat takes a small labeled set (5–8 queries, each with a hand-marked "relevant" doc id), runs each as aneuralquery, and computes recall@k and mean reciprocal rank (MRR) over the set. Print a table. Run it once withk=3and once withk=6and show recall rising withk. This is the harness every relevance change is judged by. -
(core) A cosine-vs-L2 contrast. Recreate the index twice, once with
space_type: cosinesimiland once withl2, indexing the same corpus through the same model. Writespace_compare.pythat runs the same query set against both and reports where the rankings diverge. In your write-up, reduce the result to the math: for unit-normalized vectors, cosine and (negated) L2 induce the same ordering — confirm or refute on your data. See vector-math-foundations.md. -
(advanced) Advanced challenge — reproduce, then assert, the #1 dimension-mismatch bug, in code. Write
dim_guard.pythat (a) reads the model'sembedding_dimensionfrom_plugins/_ml/models/<MODEL_ID>, (b) reads the index's mappedknn_vectordimension from_mapping, (c) asserts they are equal before any indexing, and (d) if you deliberately create a mismatched index (e.g. map 768 against a 384 model), captures the exact bulk-errorreasonstring and asserts it containsdimension. Deliverable: a script that turns the most common production failure into a pre-flight check + a regression assertion — the kind of guard you would propose adding to a tool or test. Bonus: confirm the distance ran on SIMD by assertingjdk.incubator.vectoris on the node's command line (parsepsoutput), tying to native-simd-and-faiss-kernels.md and Lab VE4.
Issues to Practice On
This pipeline spans three plugins; the bugs you will fix live mostly in
opensearch-project/neural-search (the text_embedding processor and neural
query) and opensearch-project/k-NN (the knn_vector field, HNSW). OpenSearch
workflow: GitHub issues + PRs, a CHANGELOG.md entry, and a DCO Signed-off-by
(git commit -s). Find work with:
gh label list --repo opensearch-project/neural-search # confirm taxonomy (labels move; check the tracker)
gh issue list --repo opensearch-project/neural-search --label "good first issue" --state open
gh issue list --repo opensearch-project/neural-search --search "text_embedding OR neural query in:title,body" --state open
gh issue list --repo opensearch-project/k-NN --label "good first issue" --state open
gh issue list --repo opensearch-project/k-NN --label "bug" --state open
Representative issue patterns:
- Processor/field-map edge cases. "
text_embeddingskips a nested field," "empty text crashes the processor." Approach: reproduce with a minimal_bulk, locate the processor viarg "class TextEmbeddingProcessor\|field_map\|fieldMap"in neural-search, add an integration test (OpenSearchIntegTestCase-style), fix, PR with CHANGELOG + DCO. - Dimension/validation messages (k-NN). "Dimension-mismatch error is unclear."
Approach: reproduce, locate the validation via
rg "dimension" src/main/java/.../mapperin k-NN, improve the message + add a test.
Planted-bug drill. Map semantic-demo with dimension: 768 against the 384-dim
model and bulk-index. The bulk response has errors:true with a per-item
dimension-mismatch reason. Capture it in dim_guard.py (exercise 5) as an
assert "dimension" in reason, then fix the mapping to 384 and confirm the assertion
flips to a clean index. You have now built the regression check that would have caught
the most common failure in this lab.
Etiquette: claim the issue first, reproduce before coding; every neural-search / k-NN PR needs a test + a CHANGELOG.md entry + DCO
Signed-off-by(git commit -s). See community-interaction.md.
Validation: Self-check
- List every hop a document takes from
_bulkbody to a storedknn_vector. Which plugin owns each hop? - List every hop a
neuralquery takes from REST to ranked hits. At which hop does it become a k-NN query, and what class is it then? (See query path.) - Why must the mapping
dimensionequal the model'sembedding_dimension? What is the exact symptom when they disagree, and at which step does it surface? - Why must the same
model_idbe used at ingest and query time? What goes wrong silently if they differ? - Explain, using the "automobile" example, why
neuralreturns documents thatmatchcannot — in terms of embedding-space geometry. - Where does the serialized model live, and which node coordinates its deployment? (See the index.)
- Confirm, two ways, that the dense distances underneath ran on SIMD rather than the scalar fallback.
When you can answer all seven and reproduce the two contrasting result sets, you have built and understood a dense semantic-search pipeline end to end. Next, fuse it with BM25 in Lab VE2: Hybrid Search.