Curiosity

Similarity Search with IQuery

IQuery exposes three 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(IndexTypes? indexType, IndexUID indexUID, int count) UIDs already in the chain Neighbors of each current UID, ranked.
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 indexType or 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

There are two ways to pin the lookup to one index — by type or by UID:

// 1. By index type — match any PageSpace embeddings index for this node type.
return Q().StartAt(customerUID)
          .Similar(IndexTypes.PageSpaceEmbeddingsIndex, count: 20)
          .EmitWithScores();

// 2. By explicit UID — pinpoint exactly one index instance.
var indexUID = Graph.Indexes
    .OfType<SentenceEmbeddingsIndex>(N.Product.Type)
    .First(i => i.FieldName == N.Product.Description)
    .UID;

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

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).

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: productNameIndex, 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.


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.");

// Pick the specific index we want to consume.
var nameIndex = Graph.Indexes
    .OfType<SentenceEmbeddingsIndex>(N.Product.Type)
    .FirstOrDefault(i => i.FieldName == N.Product.Name);

if (nameIndex is null)
    return Problem("Sentence embeddings index on Product.Name is not registered.");

// Run the similarity lookup and shape the response.
var hits = Q().StartAt(seed.UID)
              .Similar(indexUID: nameIndex.UID, 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.