> ## Documentation Index
> Fetch the complete documentation index at: https://agents.nanonets.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# LLM Match

> Semantic entity-matching tool exposed through the Agents Platform.

This document covers `llm_match`, the semantic entity-matching tool exposed through the Agents Platform. It matches free text (a vendor name from an invoice, a PO line-item description) against a catalog synced from a connected system (SAP, NetSuite, Postgres, SQL Server, …) via the internal LLM matching service.

## Supported integrations

`llm_match` can only bind to an integration whose service has a registered match adapter. Today that is:

| Integration  | `service_name` | Notes                                                                                                                                                       |
| ------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PostgreSQL   | `postgres`     | Optional `schema` becomes the sync's namespace, so a non-`public` source table syncs.                                                                       |
| SQL Server   | `mssql`        | SQL Authentication only. Defaults to the `dbo` schema. See [mssql\_execute\_query.md](/docs/tools/mssql_execute_query#authentication) for the credential fields. |
| Sage Intacct | `sage_intacct` | "Table" is an object type (`VENDOR`, `ITEM`, `APBILL`, …) rather than a database table.                                                                     |

Binding the tool to any other integration is rejected with `LLM matching not supported for <service>`.

Two SQL Server credential fields are mapped when the config is handed to the matching service: `trust_server_certificate` is sent as `trust_cert`, and `host_name_in_certificate` is **dropped** — the matching service has no counterpart for it. A SQL Server whose certificate only validates via that field may connect from `mssql_execute_query` but fail to sync here.

## Authentication and enablement

Configured tool: requires an integration binding (`integration_id`), a table (`table_name`), and the column to match on (`embedding_key`). Optionally bind `unique_key_column` — the catalog column holding each product's unique identity (e.g. `KUNNR`, `MATNR`, or a derived concat column built on the data side for composite keys). When bound, it is surfaced on results as `unique_key_column`, always survives `return_columns` projection, and downstream review/learnings use it as the correction key. The bound integration's catalog is synced into the matching service before the tool becomes usable (status must be `ready`; a still-syncing config returns a retriable error). Off by default — enable it in the agent's Tools panel. The prompt itself and the candidate pool are configurable per tool — see [Prompt and retrieval tuning](#prompt-and-retrieval-tuning-configured-tool-only).

## Inputs

Required (one of):

* `search_text` — a single text to match.
* `search_texts` — batch mode: multiple texts matched in one call, one result row per text in input order. Prefer this over calling the tool once per line item (N line items stay one step). When set, `search_text` is ignored.

Optional:

* `max_candidates` (default 1) — how many close matches to return per input. Set 3–5 when the result feeds a human review step so alternatives render as a dropdown.
* `return_columns` — project matched rows (and candidate products) to these columns. Use when catalog rows are wide: an untrimmed batch result can exceed the tool-output cap and truncate. The matched column is always retained.
* `filters` — structured `$and`/`$or` filter over catalog attributes, applied to every input (batch included). See [Filters](#filters).

## Filters

A filter scopes the catalog the matcher searches: the root is a logical operator (`$and` or `$or`) whose value is an array of conditions `{field, op, value|values}`. It applies to every input of the call, batch included, and travels per request — no re-sync.

```json theme={null}
{
  "$and": [
    { "field": "status", "op": "$eq", "value": "ACTIVE" },
    { "field": "category", "op": "$in", "values": ["A", "B"] }
  ]
}
```

A condition may also be a **nested group** — its own `$and`/`$or` array — which the matching service evaluates recursively, so `status = ACTIVE AND (region = EU OR region IN (US, CA))` is expressible:

```json theme={null}
{
  "$and": [
    { "field": "status", "op": "$eq", "value": "ACTIVE" },
    {
      "$or": [
        { "field": "region", "op": "$eq", "value": "EU" },
        { "field": "region", "op": "$in", "values": ["US", "CA"] }
      ]
    }
  ]
}
```

Groups nest up to 5 levels; deeper is rejected. Only the flat form is declared on the LLM-facing schema (constrained decoding cannot emit a nested condition), so **groups are an admin/binding feature** — the model itself only ever produces one level.

In the tool setup form the field renders as a builder: one row per condition (column · operator · value), an all-of / any-of selector per level, and "Add group" for nesting. The LLM can still fill the field itself when it is left on "let your agent generate a value".

<img src="https://mintcdn.com/nanonets-fb7e8f2a/sLWDSG7TuKCZBVeR/tools/images/llm-match-filters-rows.png?fit=max&auto=format&n=sLWDSG7TuKCZBVeR&q=85&s=8ea928f1d8537fb31d74bb7178abe126" alt="The filters field as condition rows" width="672" height="452" data-path="tools/images/llm-match-filters-rows.png" />

"Add group" nests the other combinator inside the current one:

<img src="https://mintcdn.com/nanonets-fb7e8f2a/sLWDSG7TuKCZBVeR/tools/images/llm-match-filters-groups.png?fit=max&auto=format&n=sLWDSG7TuKCZBVeR&q=85&s=34e7d9c7636a04778c6cc8fbbd88eb50" alt="A nested any-of group inside the all-of root" width="672" height="604" data-path="tools/images/llm-match-filters-groups.png" />

An "Edit as JSON" escape hatch sits underneath, and a filter the tree cannot state truthfully — an operator outside the table below, a group mixing nested logic with field conditions, nesting past the cap — opens directly in JSON, with the reason and no way back to the rows, so nothing is silently rewritten:

<img src="https://mintcdn.com/nanonets-fb7e8f2a/sLWDSG7TuKCZBVeR/tools/images/llm-match-filters-json.png?fit=max&auto=format&n=sLWDSG7TuKCZBVeR&q=85&s=37249dea425a3a21673f5531b79aceab" alt="The JSON fallback for a filter the builder cannot represent" width="672" height="413" data-path="tools/images/llm-match-filters-json.png" />

One consequence of grouping: review-form dropdowns inherit the filter the matcher ran under only when it is a conjunction. Nested all-of groups are flattened and inherited as usual, but an any-of group anywhere in the tree stamps **nothing** — a chip row cannot state a disjunction, and half of one would claim a narrowing the matcher never performed — so those dropdowns offer the unfiltered catalog.

| Operator                        | Means                                                       | Value                                                                                              |
| ------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `$eq` / `$ne`                   | equals / not equals, compared as **text**                   | `value`                                                                                            |
| `$gt` / `$gte` / `$lt` / `$lte` | ordered comparison, cast to `numeric`                       | `value` — the column must hold numbers                                                             |
| `$in` / `$nin`                  | in / not in a value list                                    | `values` (non-empty)                                                                               |
| `$exists`                       | the column is present on the row                            | `value` `"true"` / `"false"`                                                                       |
| `$isNull` / `$isNotNull`        | the column is empty / not empty                             | none                                                                                               |
| `$contains`                     | JSONB containment against the **whole row**, not one column | `value` — a plain scalar never matches; the column name is ignored. Prefer `$in` for a value list. |

Every comparison runs on `product ->> '<column>'`, so values match the stored text exactly (`false`, not `No`). An invalid filter fails the call fast with a message naming the condition rather than silently matching everything.

## Prompt and retrieval tuning (configured tool only)

Five knobs shape the prompt the matching service sends and the candidate pool it picks from. All are **admin-bound** (`x-exclude-from-llm`) — the model can neither read nor set them — and all are optional: leave one blank and the matching service keeps its own default. They travel per request, so a change takes effect on the next call with no re-sync.

| Binding                      | UI          | Default                                                                                                                              | What it does                                                                                                    |
| ---------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `custom_prompt_template`     | Textarea    | the service's built-in prompt (a different one for `max_candidates` > 1)                                                             | Replaces the **entire** match prompt.                                                                           |
| `product_list_template`      | JSON editor | `{"template":"Description: %s, Database Identifier: %s\n","keys":["embedding_key","database_identifier"]}`                           | How one retrieved candidate renders into `{{productList}}`.                                                     |
| `previous_mappings_template` | JSON editor | `{"template":"Search Text: %s, Database Identifier: %s, MatchType: %s\n","keys":["search_text","database_identifier","match_type"]}` | How one learned mapping renders into `{{previousMappings}}`.                                                    |
| `similar_products_limit`     | Number      | 200 (or the value stored on the match config)                                                                                        | How many candidates the vector search retrieves before the LLM picks.                                           |
| `num_batches`                | Number      | 1 (one call)                                                                                                                         | Hierarchical matching: N per-batch LLM calls plus an aggregation call. **Single-input calls only** — see below. |

### The prompt

The service substitutes three placeholders and sends whatever is left:

* `{{searchText}}` — the text being matched. **Required.**
* `{{productList}}` — the retrieved candidates, one per line, rendered with `product_list_template`. **Required.**
* `{{previousMappings}}` — learned mappings from past reviewer corrections, rendered with `previous_mappings_template`. Optional; omit it to ignore learnings for this tool.

A prompt missing either required placeholder is rejected before the call — upstream it would only surface as an opaque "no product identifier found in response". The prompt must instruct the model to return **only** the Database Identifier of the best match (one per line when `max_candidates` > 1); the identifiers are extracted from the reply and anything else is discarded. The assembled prompt is truncated at 300,000 characters by the service.

```
Given the search query '{{searchText}}', pick the single best matching customer.

Prior confirmed mappings (prefer a positive match here over the list below):
{{previousMappings}}

Candidates:
{{productList}}

Return only the Database Identifier. Never guess: if nothing is a plausible
match, return NO_MATCH.
```

### The row templates

Both take the matching service's `TemplateConfig` shape — a `fmt` format string plus the ordered `keys` filling its verbs, one rendering per row:

```json theme={null}
{ "template": "Name: %s, City: %s, ID: %s\n", "keys": ["NAME", "CITY", "database_identifier"] }
```

* One verb per key, in order. Use `%s` for text and `%v` where the column may be numeric (JSON numbers rendered with `%s` come out as `%!s(float64=…)`). `%%` is a literal percent.
* Resolved key names: `embedding_key` (the matched column's value), `database_identifier` (the product's identity), `search_text` and `match_type` (learned mappings only).
* Any other key is read as a **catalog column** and renders blank when that column is absent — a typo yields empty fields, not an error.
* A verb/key count mismatch is rejected up front; upstream it would silently bake `%!s(MISSING)` into every candidate line.
* The binding UI stores the JSON editor's content as a string; both the string and a real object form are accepted.

Widening `product_list_template` is the usual fix for a match that needs a disambiguator the embedding column doesn't carry (two vendors with the same name in different cities). Raising `similar_products_limit` is the fix for the right record existing in the catalog but never reaching the prompt — at the cost of tokens and latency on every match.

### `num_batches` caveat

`num_batches` is honoured on the single-input (`search_text`) path only. The matching service accepts it on the async endpoint the `search_texts` batch path uses, but never persists it on the job row, so a batch call always runs unbatched — the tool logs a warning (`num_batches is ignored on batch (search_texts) calls`) rather than letting the binding look effective.

## Output

Structured content is the shared match-result envelope (same shape as LLM `csv_lookup`):

```json theme={null}
{
  "status": "success",
  "match_type": "llm",
  "match_config_id": "<llm_match_configs row id>",
  "match_count": 1,
  "field_table": "CUSTOMER",
  "match_field": "CustomerName",
  "matches": [
    {
      "values": { "Customer": "0001", "CustomerName": "ACME Corp GmbH" },
      "matched_values": { "search_text": "acme gmbh" },
      "match_identifier": "ACME Corp GmbH",
      "confidence_score": 0.92,
      "additional_candidates": [
        {
          "match": "ACME Inc",
          "match_identifier": "ACME Inc",
          "confidence_score": 0,
          "product": { "Customer": "0002", "CustomerName": "ACME Inc" }
        }
      ]
    }
  ]
}
```

* `values` / candidate `product` hold the bare catalog columns (products-master wrappers and embedding vectors are stripped).
* `match_identifier` is the matched product's opaque unique identity — carry it verbatim; never parse or reconstruct it.
* Single-input calls additionally keep the legacy flat keys (`field_match`, `product_data`, …) for pre-envelope consumers.
* In batch mode a failed or unmatched input still produces a row (empty `values`, `error` set) so rows stay positionally aligned with inputs.

The result registers a task variable (`${VAR_N}`) holding `{match_type, config_kind, match_config_id, matches}`. A `review_form` field or table column referencing `$VAR.matches...` with `"editor": "select"` auto-renders the matched column as a dropdown of the candidates. Reviewer corrections on those dropdowns (picks or typed values) are submitted back to the matching service as match learnings — asynchronously, after the review is saved — so repeated corrections teach the matcher.

## Limits

* `max_candidates` ≤ 10.
* Batch requests fan out to the matching service with bounded concurrency (10) and a 30s per-input polling timeout; the call errors only when every input fails.
* Not-ready configs self-heal inline for up to \~12s, then return a retriable "still syncing" error.

## Side effects

None on the connected system — read-only matching against the synced catalog.

## Expected errors

* `search_text (or a non-empty search_texts batch) is required` — no usable input.
* `No match configuration found…` — the integration/table/field binding has no synced config.
* `Match data is still syncing (status: …)` — retriable; the sync hasn't completed.
* `Match configuration sync failed: …` — terminal; re-trigger the sync from tool settings.
* `all N LLM matching requests failed: …` — batch mode, every input errored.
* `custom_prompt_template is missing required placeholder(s) …` — the bound prompt has no `{{searchText}}` and/or `{{productList}}`.
* `product_list_template has N format verb(s) but M key(s)` / `… is missing a non-empty "template"` — malformed row template binding.
