Vector Search

When you type laptop nodeType:Story namespace:ACME into a mesh query, something interesting happens: the bare word laptop drives a semantic cosine-similarity search through Postgres pgvector's HNSW index, while nodeType:Story and namespace:ACME remain as precise SQL filters. The result is a ranked list of Story nodes in ACME's subtree — nearest semantically to "laptop" — served in sub-linear time.

This page explains how the routing decision is made, what the write/read loop looks like, the schema it depends on, and where the edges are. Query Input e.g. laptop nodeType:Story Token Parser bare text → TextSearch field:value → Filter Vector Path TextSearch present + IEmbeddingProvider registered SQL Path Structured filters only TextSearch? HNSW cosine pgvector index + access-control WHERE ILIKE / B-tree regular SQL + filters merge same ranked result set to caller Query routing: bare-text tokens activate HNSW vector search; structured field filters stay on the regular SQL path — both apply access-control and return the same ranked result interface.


Routing: structured vs. vector

The query parser splits tokens into two buckets:

Token shape Bucket Example
bare text TextSearch laptop
field:value Filter nodeType:Story

When the parsed query has a non-empty TextSearch and an IEmbeddingProvider is registered, the query is ranked by cosine similarity on both routes a query can take:

🚨 Until MeshWeaver.Plugins#1493 (2026-09-08) the unpinned route was lexical only. GenerateTextSearchClause demanded every whitespace-separated term as an ILIKE substring of name/path/description/node_type, AND-ed, so a natural-language query returned nothing mesh-wide while the same words pinned to one partition found their documents — measured on both production portals on 2026-09-07 (asynchronous calls observable subscribe never await task namespace:Doc scope:descendants → 3 hits; the same words unpinned → 0; the hit count shrank with every added word). The 80k stored embeddings were consulted exactly when a caller already knew where to look. A host with no embedding provider, and the Snowflake fan-out, keep the lexical contract — the table below describes a Postgres host with a provider.

🚨 The live half: a semantic query must not be gated by a lexical matcher

Ranking the first read semantically is only half of it. A Query is live — it re-reads when a change notification looks like it could alter the result set — and that relevance decision is taken by the other executor of the query language, in memory: PostgreSqlPartitionedMeshQuery's IsRelevant asks core's QueryEvaluator.Matches whether a newly written node could enter.

QueryEvaluator.GetFuzzyScore requires every term to be a case-insensitive substring of the node's searchable text. That is precisely the predicate the vector index replaced. So for one day after #1493 the two executors disagreed in the direction that drops rows: SQL ranked a semantic neighbour in, the in-memory gate answered false for the same node, the notification was dropped, and the live query never re-read. A search that works on first paint and then silently stops updating is the same "returns nothing" symptom, deferred — and it is invisible to any test that only measures the Initial read.

The rule is now one predicate, TakesSemanticRoute(parsed, semanticCapable), read by both the routing site and the relevance gate, so they cannot drift apart again:

This is not a new idiom. SqliteVectorMeshQuery — the SQLite-local vector provider — has always filtered its cosine-ranked candidates with parsed with { TextSearch = null }, the identical expression, for the identical reason: once a vector ranks the rows, the evaluator's job is the structured half and nothing else. The Postgres fan-out's live gate was simply the one place the pattern had not reached.

Pinned by SemanticRelevanceGateParityTests in MeshWeaver.Plugins, which carries the no-provider control and a nodeType: control — without the second, a gate that had been widened to "always relevant" would score identically on the first.

Routing examples:

Query TextSearch Vector path?
laptop "laptop" yes — full semantic search
laptop nodeType:Story "laptop" yes — ranks Story rows by similarity to "laptop"
nodeType:Story namespace:ACME null no — pure structured filter, regular SQL
name:*laptop* null no — explicit LIKE filter, regular SQL
path:ACME scope:descendants null no — pure scope query

A caller can force the regular path by expressing the term as a field filter (name:laptop instead of bare laptop).


Call sites — no API change required

The same call sites every consumer already uses continue to work without modification:

// Search bar in the portal
meshService.Query<MeshNode>(new MeshQueryRequest { Query = "laptop", Limit = 20 });

// MCP `Search` tool (Mcp/McpMeshPlugin.cs)
ops.Search("laptop", basePath: "@graph");

// Agent `Search` tool (AI/MeshPlugin.cs)
ops.Search("laptop");

The vector path activates transparently when both conditions hold: a bare-text token is present in the query, and an embedding provider is registered in the DI container — whether or not the query names a partition (see the two routes above).


Explicit invocation

There are two ways to reach vector semantics:

(a) Implicit — pass a query with bare-text content to any existing call site (shown above).

(b) Explicit — resolve IVectorSearchProvider from DI when you want vector semantics regardless of how the query string parses:

var vec = sp.GetService<IVectorSearchProvider>();
vec?.Search(queryText, options, namespacePath: "@graph", topK: 20)
    .Subscribe(nodes => ..., ex => logger.LogWarning(ex, "vector search failed"));

Search is reactive — one snapshot emission of the top-K nodes. The embedding round-trip and the HNSW SQL pump run inside the provider's IIoPool; cancellation is subscription disposal. There is no IAsyncEnumerable surface.

IVectorSearchProvider is registered as a singleton shared with PostgreSqlMeshQuery — the same instance appears under both interfaces. GetService returns null when no PG-backed mesh is registered, so the null check is required.


The closed write/read loop

Every node write has generated an embedding vector since the PG adapter shipped:

// PostgreSqlStorageAdapter.BuildUpsertAsync (paraphrased) — shared by
// WriteAsyncCore (single command) and WriteMany (batched), so the two can't drift.
var embeddingText = string.Join(" ",
    new[] { node.Name, node.NodeType }.Where(s => !string.IsNullOrEmpty(s)));
var embeddingVector = await _embeddingProvider.GenerateEmbeddingAsync(embeddingText);
// bound to the `embedding` column of the INSERT … ON CONFLICT upsert

Before vector search was wired, that column was write-only — the embedding HTTP call was paid per write, but the stored vectors were never read. Now the same model that generated vectors at write time generates the query embedding (the provider is injected from the same DI registration), and the closed loop yields meaningful cosine similarity.


Schema requirements

Vector search depends on three things being in place:

The dimension {dim} is configured via PostgreSqlStorageOptions.EmbeddingDimensions.

The provider's Dimensions must match the column type, or you will get an Npgsql cast error on the Vector parameter. The schema initializer migrates the column automatically when dimensions change — it reads the current atttypmod off mesh_nodes.embedding and, on a mismatch, runs DROP INDEX idx_mn_embedding; ALTER TABLE mesh_nodes ALTER COLUMN embedding TYPE vector({dim}) USING NULL; then rebuilds the HNSW index (PostgreSqlSchemaInitializer, in both the base-schema and per-partition DDL blocks).


Fallback when no embedding provider is registered

When AddEmbeddings registers nothing, PostgreSqlMeshQuery holds a null IEmbeddingProvider, so the vector intercept is skipped outright and the query takes the GenerateTextSearchClause ILIKE path. The intercept is also skipped when a provider is registered but returns null for this query (a transient failure) — same fallback, so callers get results instead of an empty page. NullEmbeddingProvider is the write-side stand-in: PostgreSqlStorageAdapter substitutes NullEmbeddingProvider.Instance when no provider is injected, and its GenerateEmbeddingAsync returns null, leaving the embedding column NULL. Tests that do not wire an embedding provider get the regular ILIKE behaviour automatically.

🚨 The fallback is announced, not silent

Degrading to ILIKE is a supported configuration, not a fault — but a capability that degrades in silence is indistinguishable from one that is broken. TryAddEmbeddingProvider therefore records the decision as a singleton EmbeddingCapability and registers EmbeddingCapabilityReporter, which writes exactly one Information line at host start, in both directions:

Semantic (vector) search ENABLED: provider=Ollama, endpoint=…, model=bge-m3, dimensions=1024.
Semantic (vector) search DISABLED — no Embedding:Endpoint is configured. Free-text queries fall
back to an ILIKE substring scan and content indexing stays inert; this is a supported
configuration, not a fault.

A line emitted only when the capability is ON would be no better than the silence, so the disabled branch logs too — and names the configuration key that would turn it on (Embedding:Endpoint, or Embedding:ApiKey for the keyed cloud backend). Before this, an operator seeing plainly-lexical results could not tell "never configured" from "configured and failing", which is how issue #1642 came to be triaged from a stack trace.

EmbeddingCapability.IsEnabled is taken from whether a provider was actually created, never re-derived from the options — a second copy of CreateEmbeddingProvider's branch logic could drift and then advertise a capability the host does not have.

🚨 Do not "fix" the null by registering NullEmbeddingProvider as a default. The PRESENCE of an IEmbeddingProvider registration is the capability signal every consumer reads — both storage backends resolve it with GetService, and the content-indexing module's resolve-time enabledWhen gate asks the same question. A Null default answers yes on a host that has no embeddings, which lights the indexing pipeline up against an embedder that can never embed.


Provider backends

IEmbeddingProvider has three implementations; the active one is chosen by the Embedding:Provider config key and wired by PostgreSqlExtensions.AddEmbeddings(EmbeddingOptions):

Embedding:Provider Implementation Backend Needs
AzureFoundry (default) AzureFoundryEmbeddingProvider Cohere embed-v4 via Azure AI Foundry (cloud) Endpoint and ApiKey
Ollama / OpenAICompatible OllamaEmbeddingProvider any OpenAI-compatible /v1/embeddings — e.g. a local Ollama Endpoint (+ Model); no key
(none — no Endpoint, or AzureFoundry without ApiKey) (nothing registered) falls through to the ILIKE path; the adapter writes NULL embeddings via NullEmbeddingProvider.Instance

AddEmbeddings registers nothing when Endpoint is empty (so search stays on ILIKE), and the default cloud path additionally needs an ApiKey. The same EmbeddingOptions is bound by both the portal (Memex.Portal.Distributed/Program.cs) and the migration (Memex.Database.Migration/Program.cs) — they must agree, because the migration sizes the pgvector column from Embedding:Model and the portal generates the query vectors.

Config keys

Key Meaning
Embedding:Provider backend selector (table above)
Embedding:Endpoint provider URL — for Ollama the OpenAI-compatible base, e.g. http://ollama:11434/v1
Embedding:Model model name; drives the column dimension
Embedding:ApiKey required for AzureFoundry (without it nothing is registered); optional for the OpenAI-compatible provider — it is sent as the bearer when set, and a dummy ollama bearer is used when unset
Embedding:Dimensions override; otherwise auto-derived from Model
Embedding:TimeoutSeconds OpenAI-compatible request timeout (default 30) — a finite bound so a hung leaf never pins an IIoPool slot

Model → dimension defaults: embed-v-4-0=1536, text-embedding-3-large=3072, bge-m3=1024, nomic-embed-text=768, mxbai-embed-large=1024.


Running embeddings locally (Ollama)

The local/self-host stack already runs Ollama on the host for the chat model (the in-cluster ollama Service → host gateway). The same server hosts embedding models, so vector search runs fully on-host with no cloud round-trip — reuse the server, not the chat model (a generation model makes poor retrieval vectors and has a huge hidden dimension; pull a dedicated embedding model instead).

  1. Pull a dedicated embedding model into the same Ollama: ollama pull bge-m3 (1024-dim, multilingual). It coexists with the chat model — one server, two models.
  2. Point both the portal and the migration at it:
    Embedding__Provider = Ollama
    Embedding__Endpoint = http://ollama:11434/v1
    Embedding__Model    = bge-m3
    
    In the helm chart these flow through config.memex_portal.Embedding__* and config.memex_migration.Embedding__*.
  3. Restart the portal. Schema init (PostgreSqlSchemaInitializer, run by the portal on connect — not only by the migration job) sees the new dimension and re-migrates: DROP INDEX idx_mn_embedding; ALTER TABLE mesh_nodes ALTER COLUMN embedding TYPE vector(1024) USING NULL; then rebuilds the HNSW index. This runs for the base schema and every already-provisioned partition.
  4. Run the migration so existing rows get embedded — see "Re-embedding existing content" below. Search keeps working without it (hybrid recall), but pre-existing rows are lexical-only until it runs.

Why not just point the cloud provider at Azure from local? AzureFoundryEmbeddingProvider constructs its EmbeddingsClient with no explicit timeout or retry configuration, so it inherits the Azure SDK defaults (a per-attempt network timeout plus automatic retries) rather than a short bound. If the configured endpoint is unreachable from the cluster, every bare-text query blocks on the embedding round-trip long enough that search appears frozen. OllamaEmbeddingProvider sets a finite HttpClient.Timeout (Embedding:TimeoutSeconds, default 30 s) for exactly this reason. Never wire embeddings at an endpoint the cluster can't reach.

Re-embedding existing content

Rows are embedded only at node-write time (PostgreSqlStorageAdapter.BuildUpsertAsync), so on a stack that previously had no provider every existing row's embedding is NULL — and the column re-migration in step 3 nulls anything that was there. Two mechanisms keep that from breaking search:

1. Hybrid recall — un-embedded rows do not disappear. When the query carries a bare-text term, GenerateVectorSearchQuery makes a row eligible if it has an embedding OR it lexically matches the term:

WHERE (n.embedding IS NOT NULL
       OR LOWER(COALESCE(n.name,''))        LIKE '%' || LOWER(@lexTerm) || '%'
       OR LOWER(COALESCE(n.id,''))          LIKE '%' || LOWER(@lexTerm) || '%'
       OR LOWER(COALESCE(n.description,'')) LIKE '%' || LOWER(@lexTerm) || '%')

Only a pure-semantic call (no lexical term — i.e. IVectorSearchProvider.Search invoked with no text to blend) keeps the embedding-only filter. The ORDER BY then puts an exact name match first, then name-prefix, then id-prefix, then name-substring, with cosine distance breaking ties inside each tier — so typing an exact node name cannot be buried past the LIMIT by a semantically closer neighbour.

2. MeshNodeEmbeddingBackfill — the general backfill exists. The migration (Memex.Database.Migration/Program.cs) runs DocumentationBackfill (the doc schema) and MeshNodeEmbeddingBackfill, which walks every schema holding a mesh_nodes table, reconciles the embedding column to the provider's dimension (resizing + rebuilding the HNSW index), and embeds every row with a NULL embedding from name + node_type — the same text the write path uses. It runs whenever a provider is configured, is idempotent (only NULL-embedding rows are touched), and logs-and-skips individual embedding failures rather than aborting the migration.

Consequences:

Apple Intelligence / on-device — not a fit here

There is no Apple service you can call from a containerized .NET portal to get embeddings or a vector index. The Natural Language framework's NLEmbedding is in-process macOS/iOS only; the Foundation Models framework (on-device Apple Intelligence) is Swift-only, exposes tool calling but no embeddings API, and its vector space wouldn't match the server's index anyway. The local answer is pgvector (already installed via the pgvector/pgvector:pg17 image) plus a local Ollama embedding model, as above.


Caveats

First-write race. A node written and queried in the same millisecond may not appear in results — HNSW indexes are eventually consistent (documented by pgvector). Reads-after-writes via workspace.GetMeshNodeStream(path) are unaffected because they hit the row directly.

Embedding text is Name + NodeType only. Content body is not embedded today. Two nodes with the same Name and different Content rank identically. Extending WriteAsyncCore's embeddingText to include body content is the right fix — but be aware that re-embedding full content on every write is expensive.

Routing is binary; recall inside the vector path is hybrid. The route decision is still all-or-nothing — TextSearch present (+ a provider) → vector SQL; otherwise → regular SQL. But the vector SQL itself is hybrid: it ORs in a lexical LIKE on name/id/description and ranks exact/prefix name matches ahead of pure-semantic neighbours (see "Re-embedding existing content"). What is still missing is issuing both queries and merging by score across providers.

Per-user access control is honoured. VectorSearchAsync applies the same access-control WHERE clause via the userId parameter that regular QueryAsync uses — the HNSW index ranks the access-filtered subset, not the full table.


Tests

test/MeshWeaver.Hosting.PostgreSql.Test/VectorSearchTests.cs pins three behaviours:

  1. IVectorSearchProvider.Search returns the bucket-matching node for a deterministic stub embedding.
  2. QueryAsync with TextSearch and a namespace filter routes through the vector path AND preserves the structured filter.
  3. Structured-only queries do not invoke the embedding provider — the intercept is gated on TextSearch being non-empty.

StubEmbeddingProvider maps text to sparse 1536-dim float vectors via text.GetHashCode() % 1536. Same input always produces the same vector, which is sufficient for wiring tests without requiring realistic semantics.


Why ILIKE was not enough

The previous text-search path used LOWER(name||path||description||node_type) ILIKE '%term%' per term. Two problems made it unsuitable at scale:

  1. No semantic match. A search for "phone" would not surface a node named "iPhone 15" or "smartphone review".
  2. ILIKE cannot use a B-tree index. Every search performed a sequential scan of the entire mesh_nodes table — acceptable in tests, unacceptable in production with millions of rows.

HNSW gives sub-linear search time and semantic ranking. The vector column was already being written; it just was not being read.


Live query-routing demo

The cell below illustrates how the parser classifies tokens — the same logic PostgreSqlMeshQuery uses to decide whether to route through the vector path:

var examples = new[]
{
    ("laptop",                        "\"laptop\"",     true),
    ("laptop nodeType:Story",         "\"laptop\"",     true),
    ("nodeType:Story namespace:ACME", "null",           false),
    ("name:*laptop*",                 "null",           false),
    ("path:ACME scope:descendants",   "null",           false),
};

var rows = examples.Select(e =>
    $"| `{e.Item1}` | `{e.Item2}` | {(e.Item3 ? "**vector**" : "SQL")} |");

var table = string.Join("\n",
    new[]
    {
        "| Query | TextSearch | Path |",
        "|---|---|---|",
    }.Concat(rows));

MeshWeaver.Layout.Controls.Markdown(table)
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.