Curiosity

Similarity Search with IQuery

IQuery exposes four entry points for vector search. They all read from the same embedding indexes (Sentence, PageSpace, Raw), and they all return a normal IQuery you can continue chaining — filter, project, emit.

Method Starts from Returns
IQuery.Similar(IndexUID indexUID, int count) UIDs already in the chain Neighbors of each current UID, ranked.
IQuery.StartAtSimilar(UID128 uid, ...) / StartAtSimilar(IEnumerable<UID128> uids, ...) One or more seed UIDs Neighbors of the seed UID(s), ranked — StartAt + Similar in one call.
IQuery.StartAtSimilarTextAsync(string text, int count, string[] nodeTypes, IndexUID indexUID, bool applyCutoff) Text Nearest nodes to the encoded text vector.
IQuery.ToSimilarity(...) UIDs A multi-signal scenario — see Similarity Engine.

Everything on this page assumes you're inside a custom endpoint, a code index, an AI tool, or the shell — i.e. anywhere Graph and Q() are in scope.


Similar — neighbors of the current set

Similar takes the UIDs currently in the chain and replaces them with their nearest neighbors:

// Find 20 products similar to a seed.
return Q().StartAt(productUID)
          .Similar(count: 20)
          .EmitWithScores();

With no indexUID, the workspace picks every ISimilarityIndex registered for the seed's node type and merges their results. That's convenient but rarely what you want once you have more than one index per type.

Narrowing to a specific index

Pin the lookup to one index by passing its UID. The cleanest way is the auto-generated Indexes helper, which exposes every index in your workspace as a strongly-typed IndexUID — no runtime lookup, and a typo fails to compile rather than silently picking the wrong index:

return Q().StartAt(productUID)
          .Similar(indexUID: Indexes.Product.SentenceEmbeddingsIndex_Name_ArcticXS, count: 20)
          .EmitWithScores();

Helper members follow Indexes.<NodeType>.<IndexType>_<Field>[_<EncoderModel>], so the sentence-embeddings index on Product.Name encoded with ArcticXS (registered in the endpoint example below) becomes Indexes.Product.SentenceEmbeddingsIndex_Name_ArcticXS. IntelliSense lists the exact members available for your schema.

Pass an indexUID whenever multiple indexes for the same node type exist (e.g. you index both Description and Name, or you have a RawEmbeddingsIndex from an external provider alongside the built-in SentenceEmbeddingsIndex). With no indexUID, every ISimilarityIndex registered for the seed's node type is used and their results are merged.

Chaining after Similar

Similar emits scored UIDs and rebuilds the chain. You can keep filtering and traversing as usual:

return Q().StartAt(productUID)
          .Similar(indexUID: Indexes.Product.SentenceEmbeddingsIndex_Name_ArcticXS, count: 100)
          .ExceptType(N.DiscontinuedProduct.Type)
          .Where(N.Product.Status, "Available")
          .Take(20)
          .EmitWithScores();

EmitWithScores() is the only emitter that preserves the similarity score; the rest emit results without it.


StartAtSimilar — seed and neighbors in one call

StartAtSimilar is shorthand for StartAt(...).Similar(...). Pass a seed UID (or a set of UIDs) and the same indexUID / count arguments Similar accepts; it initializes the chain at the seed(s) and immediately replaces them with their nearest neighbors.

// These two are equivalent:
Q().StartAt(productUID).Similar(count: 20);
Q().StartAtSimilar(productUID, count: 20);

Use it when you already have the seed UID in hand and don't need the intermediate StartAt step for anything else:

// One seed.
return Q().StartAtSimilar(productUID, count: 20)
          .EmitWithScores();

// Several seeds — neighbors of every seed are merged and ranked.
return Q().StartAtSimilar(cartProductUIDs, indexUID: Indexes.Product.SentenceEmbeddingsIndex_Name_ArcticXS, count: 50)
          .ExceptType(N.DiscontinuedProduct.Type)
          .Take(20)
          .EmitWithScores();

It returns the same scored chain as Similar, so everything in Chaining after Similar applies — keep filtering, traversing, and finish with EmitWithScores() to preserve the score. The seed-resolution rules match StartAt: a UID that doesn't resolve to a known node type is dropped before the similarity lookup runs.


Encodes a piece of text and returns its nearest neighbors. This is the building block for "semantic search" UIs that don't need full BM25 + hybrid fusion.

var query = await Q().StartAtSimilarTextAsync(
    text:      "battery drains overnight",
    count:     20,
    nodeTypes: new[] { N.SupportCase.Type });

return query.EmitWithScores();

The lookup runs against every ITextSimilarityIndex whose NodeType matches the filter and that has AISearchEnabled = true. The defaults are tuned for the search UI; when calling from code:

Parameter Meaning
text The query text. Empty/whitespace returns an empty query.
count Max neighbors per index. With three matching indexes you can get up to 3 × count candidates merged.
nodeTypes Restrict to indexes whose NodeType is in this set.
indexUID Bypass AISearchEnabled and target exactly one index. Set this when you want a specific encoder regardless of admin config.
applyCutoff If true, hits below the index's InjectResultCutoff are dropped before being returned.

Targeting a specific index

var arctic = Graph.Indexes
    .OfType<SentenceEmbeddingsIndex>(N.SupportCase.Type)
    .First(i => i.SentenceEncoderModel == SentenceEncoderModel.ArcticXS);

var query = await Q().StartAtSimilarTextAsync(
    text:      input.Query,
    count:     50,
    nodeTypes: new[] { N.SupportCase.Type },
    indexUID:  arctic.UID,
    applyCutoff: true);

return query.Take(20).EmitWithScores();

This is also the right call to use from a similarity engine signal: wrap it in s.FromAsync(...) to feed text-encoded candidates into a multi-signal scenario.


A complete endpoint — /similar-products

A typical endpoint takes a seed UID, narrows to a specific index, and returns the top-K with scores. The example below uses a sample Product dataset modelled after the workspace's similarity-engine tests:

Node Key fields Edges
Product Name, Description, SKU, Status → Manufacturer (ManufacturedBy), → Tag (HasTag), → Category (InCategory)
Manufacturer Name ← Product (Manufactures)
Category Name ← Product (InCategory)
Tag Name ← Product (HasTag)

1. Register a sentence-embeddings index on Product.Name

A migration or one-off shell call sets up the index — see Sentence Embeddings for the full options reference.

await Graph.Indexes.AddSentenceEmbeddingsIndexAsync(
    nodeType:  N.Product.Type,
    fieldName: N.Product.Name,
    model:     SentenceEncoderModel.ArcticXS);

2. The endpoint

public record SimilarRequest(string ProductId, int TopK = 10);

public record ScoredProductDto(
    string Id,
    string Name,
    string Manufacturer,
    double Score);

var input = Body.FromJson<SimilarRequest>();

if (input is null || string.IsNullOrWhiteSpace(input.ProductId))
    return BadRequest("`productId` is required.");

if (!Graph.TryGetReadOnlyContent<Product>(N.Product.Type, input.ProductId, out var seed))
    return NotFound($"Product '{input.ProductId}' not found.");

// Run the similarity lookup and shape the response. The index is referenced
// through the auto-generated Indexes helper, so a missing or renamed index is
// a compile error rather than a runtime null check.
var hits = Q().StartAt(seed.UID)
              .Similar(indexUID: Indexes.Product.SentenceEmbeddingsIndex_Name_ArcticXS, count: input.TopK + 1)
              .AsScoredUIDEnumerable()
              .Where(s => s.UID.UID != seed.UID)            // drop the seed itself
              .Take(input.TopK)
              .Select(scored =>
              {
                  Graph.TryGetReadOnlyContent<Product>(scored.UID.UID, out var p);

                  var manufacturer = Q().StartAt(scored.UID.UID)
                                        .Out(N.Manufacturer.Type, E.ManufacturedBy)
                                        .AsEnumerable()
                                        .FirstOrDefault();

                  return new ScoredProductDto(
                      Id:           p?.Key ?? scored.UID.UID.ToString(),
                      Name:         p?.Name ?? "",
                      Manufacturer: manufacturer?.GetString(N.Manufacturer.Name) ?? "",
                      Score:        scored.Score);
              })
              .ToList();

return Ok(new { source = input.ProductId, hits }.ToJson(), "application/json");

A request like POST /endpoints/similar-products {"productId": "P-2199", "topK": 5} returns:

{
  "source": "P-2199",
  "hits": [
    { "id": "P-2207", "name": "Wireless Mouse Pro",     "manufacturer": "Logitech",  "score": 0.871 },
    { "id": "P-2153", "name": "Ergonomic Mouse",        "manufacturer": "Logitech",  "score": 0.812 },
    { "id": "P-2244", "name": "Wireless Trackball",     "manufacturer": "Kensington","score": 0.768 },
    { "id": "P-2031", "name": "Bluetooth Mouse Lite",   "manufacturer": "Microsoft", "score": 0.752 },
    { "id": "P-2412", "name": "Gaming Mouse RGB",       "manufacturer": "Razer",     "score": 0.741 }
  ]
}

The next page shows how to take the same scored-neighbors output for many seeds and turn it into a clusterable WeightedGraph.


See also

© 2026 Curiosity. All rights reserved.