Skip to content

How-to guides

Searching by vector

This guide shows you how to find documents by semantic meaning rather than exact keyword matches. Use vector search to index embeddings generated by a machine learning model and search for nearest neighbours using the knn query clause.

The search engine does not generate vectors or call an embedding model on your behalf. You must generate vectors with your model and include them when indexing documents and running queries.

Before you begin, ensure you have:

  • An index where you can define fields and ingest documents.
  • An embedding model that produces vector embeddings with up to 4096 dimensions.

To define a vector field in your schema, add a field definition with type set to vector:

"embedding": {
"type": "vector",
"dimensions": 768,
"similarity": "cosine"
}

The dimensions property is required and must match the number of dimensions produced by your model, up to a maximum of 4096.

The dimensions and similarity properties are fixed once documents are written to the index. To change either property on an index that contains documents, roll out a new generation.

The similarity property determines the metric used to judge distance between vectors:

  • cosine (default): Compares vector direction and ignores length. This metric is standard for most text embedding models. If you submit a vector of all zeros, the engine rejects it with document:vector:value_zero.
  • dot_product: Requires less computation, but only produces valid orderings when every vector has unit length. Use this metric when your model outputs normalized vectors or when you normalize the vectors yourself.
  • euclidean: Compares geometric position for vectors where length carries meaning.

Vector fields are searched only by similarity. The engine rejects filter, sort, facet, multiple, and locales on vector fields. To filter or sort by metadata, use standard fields alongside the vector field.

To index vectors, pass the vector as an array of numbers within the document payload:

POST /v1alpha1/indexes/products/documents
Content-Type: application/x-ndjson
{"id": "1", "name": "Rain jacket", "category": "Outerwear", "embedding": [0.02, -0.13, ...]}

When indexing documents with vectors, verify the following requirements:

  • Every vector must have the exact number of dimensions declared in the field definition. If dimensions do not match, the engine returns document:vector:dimensions_mismatch.
  • Every component in the vector array must be a finite number. If a component is not finite, the engine returns document:vector:value_not_finite.
  • You must use the same model, model version, and prompt or prefix to generate document vectors and query vectors. The engine does not validate model compatibility. If you query with a vector from a different model, the returned nearest neighbours are meaningless. Changing models requires a rollout into a new generation.

A document can omit the vector field. When omitted, other search clauses can match the document, but a knn clause never matches it.

To find the nearest documents to a query vector, submit a search request with a knn clause:

{
"query": [
{ "type": "knn", "field": "embedding", "vector": [0.01, -0.2], "k": 50 }
],
"fields": ["name", "category"]
}

The k parameter sets the number of nearest neighbours to return. The value of k bounds the search results regardless of limit and offset parameters. When paginating through knn results, set k to cover all pages your interface displays rather than only the first page.

Specify the fields to return in the fields array. If you omit fields, the engine returns all stored fields, including large vector arrays.

The engine executes the similarity search and returns up to k matching documents ranked by similarity score.

To filter candidate documents before calculating nearest neighbours, place filter conditions inside the knn clause:

{ "type": "knn", "field": "embedding", "vector": [0.01, -0.2], "k": 50,
"filter": [
{ "field": "category", "match": { "value": "Outerwear" } },
{ "field": "inStock", "match": { "value": true } }
] }

The filter parameter restricts candidate documents before nearest neighbours are selected, ensuring the search returns up to k matching results.

If you place filter conditions outside the knn clause, the engine identifies the k nearest neighbours across the whole index first and then filters those results. On narrow filters, this post-filtering can return few or no documents.

To index and search documents that are too long for a single embedding, store them as a list of chunks.

  1. Define the chunks as a nested object field with a vector inside:

    "chunks": {
    "type": "object",
    "multiple": true,
    "mode": "nested",
    "fields": {
    "text": { "type": "string" },
    "lang": { "type": "string", "filter": {} },
    "embedding": { "type": "vector", "dimensions": 768 }
    }
    }
  2. Search the chunks by placing a knn clause inside a nested clause:

    {
    "query": [
    { "type": "nested", "path": "chunks", "clauses": [
    { "type": "knn", "field": "chunks.embedding", "vector": [0.01, -0.2], "k": 20 }
    ] }
    ]
    }

    Because k counts chunks rather than documents, ask for more chunks than the number of documents you want returned.

  3. Return the chunks themselves as hits by setting hits to the same path:

    {
    "query": [
    { "type": "nested", "path": "chunks", "clauses": [
    { "type": "knn", "field": "chunks.embedding", "vector": [0.01, -0.2], "k": 20 }
    ] }
    ],
    "hits": { "path": "chunks" },
    "fields": ["title"]
    }

    Each hit returns the chunk value and its parent document fields.

  4. Narrow candidate chunks before selecting nearest neighbours by adding a filter on inner fields inside the knn clause:

    { "type": "knn", "field": "chunks.embedding", "vector": [0.01, -0.2], "k": 20,
    "filter": [ { "field": "chunks.lang", "match": { "value": "en" } } ] }

The filter names inner fields only. Place a condition on a field of the index, such as a tenant or a publication state, beside the nested clause. Such a condition applies after the nearest chunks are picked, so raise k to cover what it removes.

To perform a hybrid search, combine text and vector rankings inside a fuse clause:

{
"query": [
{ "type": "fuse", "depth": 100,
"rankings": [
{ "clauses": [ { "type": "text", "text": "waterproof jacket", "fields": { "name": null } } ] },
{ "clauses": [ { "type": "knn", "field": "embedding", "vector": [0.01, -0.2], "k": 100 } ] }
],
"filter": [ { "field": "published", "match": { "value": true } } ] }
]
}

The engine runs each ranking separately up to depth results, then scores documents by their position across rankings using reciprocal rank fusion. Documents that rank well in multiple rankings score highest. Because the engine evaluates only rank positions, you do not need to normalize BM25 text scores and vector similarity scores into a shared scale.

Configure the fusion clause with these properties:

  • depth: Sets how many results to read from each ranking (default: 100). This bounds total merged results, facet counts, and pagination depth. Set depth to cover all pages you display.
  • rankConstant: Controls how much rank differences matter (default: 60.0). Lower values give higher weight to top results in each ranking. Higher values flatten rank differences so that appearing in multiple rankings matters more than rank position in one ranking.
  • weight: Scales the contribution of a ranking relative to others (default: 1.0). Use weight to reduce the influence of less reliable rankings, such as vector rankings based on user history.
  • filter: Restricts candidate documents before each ranking is cut to depth. A knn ranking inside the fusion uses these filters as pre-filters. If you place filters outside the fuse clause, the engine filters the merged list after cutting to depth, which can return few or no results.

To combine text and vector queries by adding scores together instead of fusing ranks, place them inside an or clause:

{
"query": [
{ "type": "or", "clauses": [
{ "type": "text", "text": "waterproof jacket", "fields": { "name": null } },
{ "type": "knn", "field": "embedding", "vector": [0.01, -0.2], "k": 50 }
] }
]
}

The engine sums the scores from all matching clauses. Because text and vector scores use different scales, wrap individual clauses in a boost and test weights against queries with expected results. Use the or form when you want a single score you tune yourself, and fuse otherwise.

To tune index size, indexing speed, and search accuracy, configure Hierarchical Navigable Small World (HNSW) graph parameters and vector quantization on the field definition:

"embedding": {
"type": "vector",
"dimensions": 768,
"hnsw": { "m": 16, "efConstruction": 100 },
"quantization": "int8"
}

Configure these settings based on your requirements:

  • hnsw.m: Sets the number of bidirectional connections per node.
  • hnsw.efConstruction: Sets the size of the dynamic candidate list evaluated during index construction. Increase m and efConstruction if searches miss relevant neighbours. The defaults match Lucene defaults.
  • quantization: Reduces vector precision in storage to save memory and disk space. Supported values are none (default), int8, and int4. Quantization applies to segments written after configuration and takes full effect when all segments are rewritten.

Vector indexes use memory-mapped files. Allocate system memory to page cache rather than Java Virtual Machine (JVM) heap. For more information, see Page cache and heap.

Vector distance computations use the JVM vector module, which is enabled in the default JVM configuration. If the module is not enabled, the node logs a message at startup.

Exofind is built by Level Four AB and is available under the Apache License 2.0.