Vector & Hybrid Search

Standard text search matches words and patterns. Vector search matches meaning. The two approaches complement each other — hybrid search lets you blend them.

When to Use It

Text search is precise and fast. It excels at names, codes, partial words, and typo-tolerant lookups — the things Indx is already very good at. But it can miss results when the user's phrasing doesn't match the document's wording.

Vector search closes that gap. A query like "something comfortable for long flights" can surface "noise cancelling over-ear headphones" even though none of those words overlap. This is especially valuable when:

  • Your data is content-rich — product descriptions, articles, documentation, support tickets, legal or medical text
  • Users don't know the exact terminology and search with natural language
  • You want a "more like this" or "related items" feature
  • Your data is multilingual and users may search in a different language than the documents

Vector search is less useful for short structured fields, product codes, or cases where exact matching is the goal — text search handles those better.

  • No truncation point. Text search with coverage produces a clear boundary between confirmed matches and loose pattern hits — results below the truncation index are lower confidence. Vector search returns a ranked list by similarity with no such boundary; every result has a score, but there is no reliable threshold that separates "relevant" from "not relevant".
  • No facets. Facet counts are computed from coverage hits. Vector search does not produce coverage results, so facet aggregations are not available.

These limitations make pure vector search a poor fit for faceted navigation or UIs that rely on the truncation point for quality signals. Hybrid search is the recommended default — it runs the text search path alongside vector search and merges the results, so you retain truncation points, facet support, and typo tolerance while gaining semantic reach.


How Vector Search Works

Instead of indexing words, vector search stores each document as a high-dimensional float array (an embedding) produced by an embedding model. At search time, the query is converted to a vector by the same model, and the engine finds the documents whose vectors are closest to the query vector using approximate nearest-neighbour (HNSW) search. Similarity is measured as cosine similarity — a score in [0, 1] where 1 means identical.

Indx stores and searches vectors. It does not generate them. You are responsible for producing the float arrays using an embedding model of your choice — OpenAI, sentence-transformers, Cohere, or any other. Indx accepts vectors as a JSON array field on each document.


Choosing a Mode

ModeWhen to use
Text search
Precise lookups, product codes, names, exact phrases, typo-tolerant queries
Vector search
Semantic similarity — "find products like this one", concept search, multilingual queries
Hybrid search
Most search UIs — combines the precision of text with the semantic reach of embeddings

Hybrid search is the recommended default when you have embeddings. Pure vector search is better suited to "more like this" features or cases where the query is itself a document rather than a text string.


Choosing an Embedding Model

The most important rule: use the same model for documents and queries. Vectors from different models are not compatible.

ModelDimsNotes
OpenAI text-embedding-3-small
1536
Good starting point — low cost, strong quality
OpenAI text-embedding-3-large
3072
Higher quality, higher cost

For most applications, text-embedding-3-small is the right choice.

Token Limits

All models have a maximum input length. OpenAI's models support up to 8191 tokens per input. If your documents are longer, split them into chunks before embedding — each chunk becomes a separate document in Indx.

Vector Length

Vectors of any length are accepted. Indx brings each vector to unit length when it is stored and when it is searched for, so a score is always the cosine similarity. No normalising step is needed on your side, whichever model you use.


Getting Vectors — C# NuGet Options

OpenAI — official .NET SDK

bash
dotnet add package OpenAI
csharp
using OpenAI.Embeddings;

var client = new EmbeddingClient("text-embedding-3-small", Environment.GetEnvironmentVariable("OPENAI_API_KEY"));
Embedding result = await client.GenerateEmbeddingAsync("comfortable wireless headphones");
float[] vector = result.ToFloats().ToArray();

Setup Overview

1. Mark the Field as Embeddable

csharp
engine.GetField("embedding")!.Embeddable = true;

Embeddable fields are excluded from the text search index. The field must contain a JSON array of floats.

2. Store Vectors in Your Documents

Each document must include the embedding as a JSON array:

json
{
  "id": 1,
  "title": "Wireless Headphones",
  "description": "Over-ear noise cancelling headphones",
  "embedding": [0.12, -0.45, 0.33, 0.08, ...]
}

The dimensionality is detected from the first document loaded and must be consistent across all documents. A document without the field, or with null, simply has no vector.

3. Generate Embeddings at Query Time

When a user searches, embed the query text with the same model you used for the documents:

csharp
// Example using any embedding client
float[] queryVector = await myEmbeddingClient.EmbedAsync("comfortable wireless headphones");

Vector Search (C#)

After Load() and Index(), access the embedding index via EmbeddingFields:

csharp
var index = engine.EmbeddingFields["embedding"];
var results = index.Search(queryVector, maxResults: 10);

foreach (var (documentKey, score) in results)
    Console.WriteLine(
quot;key={documentKey} similarity={score:F3}");

Returns IReadOnlyList<(long documentKey, float score)> ordered by descending cosine similarity.


Keeping the Index Up to Date (C#)

Insert, update and delete work on a dataset with vector fields, as on any other. An update that leaves the vector unchanged costs the vector index nothing. Removed vectors leave room behind in the search graph, and the graph is rebuilt in the background once enough of them have gathered. Set EmbeddingSetup.AutomaticRebuild to false to decide yourself: IEmbeddingIndex.RebuildDue then tells you when, and Index() rebuilds.

Saving the Graph Between Runs

Building the search graph is the slow part of loading vectors. Save it, and the next load reads it instead of building it:

csharp
engine.SaveEmbeddings("products.graphs", out string error);

// Next run: after the field configuration is set, before Load
engine.LoadEmbeddings("products.graphs", out error);
engine.Load(stream);

The saved file does not have to match the documents exactly. The graph is brought in line with the vectors that are loaded, and is built as usual when too much has changed. IEmbeddingIndex.RestoredFromSaved tells you which happened. On 43 000 products with vectors of 1 536 numbers, the load took 25 s from a saved graph against 41 s building it. The Indx server does this for you: a dataset that goes to sleep keeps its graph on disk and wakes without rebuilding it.

Memory

ConfigurationParameters.EmbeddingSetup decides how vectors are held in memory:

  • GraphVectors: the search graph's copy, as 16-bit integers (VectorStorage.Int16, the default) or Float32. 16-bit halves the memory and makes search and build about a third faster, with no measurable loss in recall.
  • DocumentVectors: the document's own copy, as Float32 (the default), Int16, Text or NotKept. With Float32 a number may come back spelled differently (0.10 as 0.1); choose Text if you compare documents as text. NotKept keeps no copy in the document at all.
csharp
var config = ConfigurationParameters.Default.With(
    embeddingSetup: EmbeddingSetup.Default.With(documentVectors: DocumentVectorStorage.Text));

Hybrid Search (C#)

Run text search and vector search separately, then merge:

csharp
var textResult     = engine.Search(new Query("comfortable headphones", 50));
var embeddingIndex = engine.EmbeddingFields["embedding"];
var vectorResults  = embeddingIndex.Search(queryVector, maxResults: 50);

var merged = IEmbeddingIndex.MergeHybrid(
    textResults:      textResult.Records,
    embeddingResults: vectorResults,
    alpha:            0.6f   // 0 = pure text, 1 = pure embedding
);

The combined score is:

score = alpha × embeddingScore + (1 − alpha) × (textScore / 65535)

Documents that appear in only one result set receive a zero contribution from the missing side. alpha around 0.5–0.7 works well in most cases.


HTTP API

See Search for the search/vector and search/hybrid endpoint reference.


See Also