Embeddings find similar meaning, identifiers find the same thing: how we merge both in one similarity engine
Rafael
A ticket reads "Bracket P/N 4471-B cracked at the weld during overhaul". Which records are similar to it? A field report about a hairline crack near a weld on a mounting bracket is similar in meaning. A supplier notice that names 4471-B and says nothing about cracks is about the same part. A good "more like this" needs both, and the two come from different techniques: embeddings find the first kind, and entity extraction with linking finds the second.
This post compares them, says where each one fails, and shows how the similarity engine in Curiosity Studio merges them into one ranked list. The part numbers and tickets in the figures are invented for the post.
What embeddings do well
An embedding model turns a text into a vector, and vector search returns the records whose vectors are close. The vector search page in our documentation lists where that fits: users who describe what they want in their own words, long descriptive text such as notes and transcripts, "similar items" screens, and content in several languages when the model is multilingual.
The advantages follow from that. A paraphrase matches without sharing a word. Nobody has to write a rule or a dictionary. And it works on text you have never looked at.
What embeddings do badly
- Identifiers. The same page calls vector search a poor fit for short identifier lookups such as ticket numbers and serial numbers, where text search wins on precision. The reason is that 4471-B and 4471-C are almost the same string, so an embedding model tends to place them next to each other, and they are different parts. Stage 1 of Figure 1, further down, shows it with invented scores: the maintenance log for 4471-C ranks above the supplier notice for 4471-B.
- Reasons. A match is a distance. It does not say which words or which part made two records close, so you cannot show a user why a result is there.
- Thresholds. Without a minimum similarity, the search returns k results even when none are similar, according to the documentation. Choosing the cutoff is a tuning job.
- Model lock in. Vectors from different models are not comparable. Switching models means embedding the whole corpus again.
- The wrong kind of match. Piotr Migdał shows in Don't use cosine similarity carelessly a query that matched another question rather than an answer. Close in vector space is not always what you meant.
What entity extraction and linking do
Named entity recognition, NER, finds spans of text that name a person, place, organization or product and gives each a type. spaCy's documentation shows the classic labels: people, organizations, places, money. A statistical NER model is trained on those generic types, which is why part numbers are usually handled by a pattern or a dictionary instead.
Our documentation lists four kinds of extractor, and the first two do most of the work on industrial identifiers:
| Extractor | What it matches | Strength | Weakness |
|---|---|---|---|
| Spotter | A curated list of terms and aliases | High precision, fast, deterministic | Misses anything not on the list |
| Pattern spotter | A regular expression for IDs and codes | Catches identifiers a list cannot enumerate | Fires on unrelated numbers unless you give it context |
| ML / NER | Generic types such as person, organization, date | Covers unknown entities | Uneven on domain terms: "Apple" in a fruit catalog is not a company |
| LLM extraction | Anything a prompt can describe | Reaches the long tail: intents and structured fields | Runs from code, such as a connector or an endpoint, not from the settings page |
Extraction finds the span. Linking is the step after it: the span is mapped to a node in the graph, so a document gets an edge to the entity it mentions. spaCy describes the same step as resolving a textual entity to a unique identifier in a knowledge base. In Curiosity, the edge is a Mentions edge from the document to the entity node. Every document that mentions part 4471-B is then connected to one Part node, and "same part" is a hop on the graph. No distance is involved.
This is the stored shape of a pipeline, from the documentation's entity extraction page, with a pattern spotter for ticket IDs:
pipeline:
language: auto
spotters:
- kind: pattern
name: ticket-id
regex: "TICKET-\\d{4,6}"
link_to_type: SupportCase
link_strategy: by-key
A part number spotter has the same shape. This one is our sketch based on that page, so check the keys against your version:
- kind: pattern
name: part-number
regex: "\\b\\d{4}-[A-Z]\\b"
context_must_include: ["P/N", "part", "bracket"]
link_to_type: Part
link_strategy: by-key
The documentation also covers Graph.Parsing.GetLinksAsync, which runs the same pipeline against any string and returns the edges linking would create, without storing a node. It is the quickest way to test a pattern.
What entity extraction does badly
- Recall depends on the rules. A list or a pattern only finds what its author thought of. "4471 B" with a space, or a part named in prose, is missed unless a rule covers it.
- Patterns over-fire. The documentation's example is
\d{4}, which matches every year, postcode and asset ID. Context words and exclusions narrow it. - Wrong edges cost more than missing ones. The documentation recommends auto linking only at a confidence of 0.85 or higher. Before a rule is promoted, it suggests reviewing a sample of 100 to 200 documents for precision and recall, with a precision of 0.95 as the floor for auto linking.
- It does not know meaning. A report that describes the failure and never names the part gets no edge.
- Rules need maintenance. Adding a spotter means re-processing the existing corpus, or it only applies to new documents.
Both, in one ranked list
Figure 1 steps through the idea on six invented documents. In stage 1, embeddings alone put the field report and the 4471-C log first. In stage 2, the part number is spotted once and linked, and the three documents that share the node light up. 4471-C does not match, which is correct because it is a different part. In stage 3 the two are merged, and the documents that are close in meaning and share the part rise together.
The similarity engine does the merge. In the documentation's words, it builds a ranking by combining multiple signals, each producing candidates from a different source: text embeddings, graph traversals or an external lookup. It fuses their scores into one list and applies optional rules. Each signal is a few lines. This is the shape of the endpoint, adapted from the documentation's product example to tickets and parts. N.Part and E.Mentions are the names in our example schema, so use the ones in yours:
var result = await Graph.Query()
.StartAt(seedUID)
.ToSimilarity(o => o.MaxCandidates(200))
// 1. Meaning: embedding similarity over the ticket body
.AddSignal("SimilarText", s => s
.Describe("Body embedding similarity")
.Weight(1.0f)
.FromAsync(async ctx =>
(await ctx.Graph.Query().StartAtSimilarTextAsync(
bodyText, count: 100,
nodeTypes: new[] { N.Ticket.Type },
applyCutoff: false))
.Except(ctx.Subjects)))
// 2. Identity: other tickets that mention the same part
.AddSignal("SamePart", s => s
.Describe("Tickets that mention the same part number")
.Weight(0.5f)
.From(ctx => ctx.Graph.Query()
.StartAt(ctx.Subjects)
.Out(N.Part.Type, E.Mentions)
.Out(N.Ticket.Type, E.Mentions)
.Except(ctx.Graph.Query().StartAt(ctx.Subjects))))
.Fuse(Fusion.Sum)
.FilterAsUser(CurrentUser)
.ExecuteAsync(CancellationToken);
The first signal returns scored candidates, and the engine uses those scores. The second is a graph traversal with no per-document scores, so the engine ranks its candidates by their position in the list. That is why the weights are set by measurement and not by reasoning. The figures use flat bonuses to keep the idea readable, and the engine's own scale for position based signals differs.
Each result comes back with its breakdown. ScoreInfo.Components holds the contribution of every signal by name, and for the built in fuses the components add up to the final score. A negative signal shows up as a negative entry. The search interface can draw the same data as a contribution bar on each hit. A user can then see that a result is there because of its text, its part or both.
Choosing how to fuse
Weights are one knob. The fuse function is the other.
| Fuse | Effect | Use it when |
|---|---|---|
Fusion.Sum |
Adds the weighted scores. The default | Both signals should add evidence |
Fusion.Max |
Keeps the strongest signal | One signal is the source of truth and the other breaks ties |
Fusion.Product |
Multiplies the scores: a soft AND | A candidate must score on every signal |
Fusion.Euclidean |
Square root of the sum of squares: a soft OR | Either signal alone is enough |
| Reciprocal rank | Scores by 1 / (k + rank) | The signals' scales are not comparable |
Reciprocal rank fusion is configured on a signal with UsingReciprocalRankFusion(...) and combines that signal's ranked sources. Its appeal is that only ranks count. John Wang at Assembled describes the problem in Better RAG results with reciprocal rank fusion and hybrid search. Weighting keyword and vector scores was hard because the distribution of vector scores was unknown and varied between customers. Rank fusion outperformed the more complex methods they evaluated.
The second figure lets you move the weight and switch the fuse.
Two more engine features matter for this use. Negative signals demote a candidate, for example a ticket the user already closed, and a rule filters the merged list, for example to one product line. Rules only filter. They never change a score.
Permissions
Signals run against the admin level graph, so the result can contain records the calling user may not see. The engine does not enforce access on results unless you ask. The documentation gives two ways. Query as the user inside a signal, or add .FilterAsUser(CurrentUser) to the scenario, as the code above does. It drops anything that user cannot access before the result is returned.
Which signal for which question
| The question | Signal |
|---|---|
| Records that read like this one | Embeddings |
| Everything that mentions part 4471-B | Identifier link |
| Records like this one about the same part | Both, fused |
| An exact ID typed into the search box | Keyword search first. Our search layers whitepaper makes the same point |
What this does not do
It does not find a relationship nobody modeled. If 4471-C replaced 4471-B, neither signal knows, until the graph has a supersedes edge and a third signal follows it. It does not fix a weak pattern: a regular expression that misses "4471 B" leaves the link out, and a sample review is how you find that. And the weights it takes are yours to measure. The golden set from the previous post is how we check whether adding a signal helped.
Further reading
- Entity extraction, Curiosity documentation. Spotters, patterns, linking, confidence thresholds and the review workflow.
- Similarity engine, Curiosity documentation. Signals, fusion, negative signals, rules and access control, with the full product example.
- Better RAG results with reciprocal rank fusion and hybrid search, John Wang, Assembled. Why merging by rank held up where weighting raw scores did not.
- Linguistic features: named entities, spaCy. What NER labels look like, and linking to a knowledge base.
Read next
Articles on context graphs, enterprise search and industrial AI