Search
The routed search.query endpoint - typed filters, NL-to-SQL, and semantic similarity behind one door, with the search.fields vocabulary catalog
POST /search/query is one routed call over your warehouse. Every ask lands on one of four lanes (typed filter predicates, the fuzzy NL→SQL writer, semantic vector similarity, or lexical exact-term matching), picked by an auto-router unless you force a mode. The response always tells you which lane actually ran (mode_ran), the rows or matches, and any compiled SQL, so the call is a receipt as well as an answer.
This page covers search.query, its field catalog search.fields, and the orientation read search.overview: the whole synchronous search surface. For a question that needs several reads and one cited answer, send it to Research; for market data or a written deliverable, start a Chat.
| Operation | REST | MCP | Scope | |
|---|---|---|---|---|
| The routed verb | search.query | POST /search/query | search_customers | data:read |
| The field catalog | search.fields | GET /search/fields | list_search_fields | data:read |
| The data overview | search.overview | GET /search/overview | resource search-overview://current | data:read |
/search/queryauth requiredThe routed verb. Every ask lands on one of four lanes; the response names the lane that actually ran.
/search/fieldsauth requiredThe filterable field catalog, derived from the same schema the filter compiler validates against - so it cannot drift.
/search/overviewauth requiredRow counts and time spans for the three warehouse surfaces. The read to take before your first query: it tells you whether there is anything to search.
Check what is there first: search.overview
GET /search/overview answers "does this workspace have data, and how recent is it" before you compose a query. It needs only data:read and works on an empty workspace:
{
"data": {
"surfaces": [
{ "surface": "interactions", "row_count": 41822, "earliest_at": "2025-01-06T15:02:11.000Z", "latest_at": "2026-09-27T21:40:03.000Z", "time_field": "timestamp" },
{ "surface": "deals", "row_count": 612, "earliest_at": "2024-11-30T00:00:00.000Z", "latest_at": "2026-12-31T00:00:00.000Z", "time_field": "close_date" },
{ "surface": "deal_qualification", "row_count": 188, "earliest_at": "2026-06-02T08:15:40.000Z", "latest_at": "2026-09-28T06:01:12.000Z", "time_field": "computed_at" }
]
}
}row_count: 0is a real zero: nothing has synced to that surface yet, and no query against it will return a row.row_count: nullmeans the read for that surface failed. It is not a zero. Retry before telling anyone the workspace is empty.- The span is measured on each surface's own time field (
time_field), sodeals.latest_atcan sit in the future: it is the latest close date, not the latest sync. - Each surface is one warehouse read, so the overview spends up to three reads from the per-user warehouse-read budget. A surface refused by that budget reports
null.
Over MCP it is the resource search-overview://current. No tool reads it, so a tools-only client should run a small filter-lane count with search_customers instead.
The modes
mode | What runs | Reach for it when |
|---|---|---|
auto (default) | The router picks, in this order: filters or an aggregation with no free-text query → filter lane; a query containing a quoted phrase ("pricing", two characters or more between double quotes) → lexical; a filter-shaped ask → fuzzy; meaning-shaped wording ("what do customers say about…") → semantic; everything else → fuzzy. | You want the cheapest correct lane chosen for you. Branch on mode_ran. |
filter | Typed {field, op, value} predicates compiled into one SELECT. | You know exactly which fields and conditions you want, and the result must be deterministic — no model interpreting wording. |
fuzzy | The NL→SQL writer: it plans your ask into sub-questions and writes SQL for each. | Your ask is plain language and countable ("open deals with no activity in 21 days"). |
semantic | Your query embedded and ranked against the conversation corpus by meaning. | The thing you want is a concept no column encodes ("sounds like pricing pushback"). |
lexical | Exact-term matching over the same utterance mirror the semantic lane reads, ranked by how rare each matched term is in your corpus. No embedding call. | The exact words matter: a product name, a competitor, a phrase a buyer used. |
Any query that contains a quoted phrase is routed to lexical under auto, so what do buyers say about "onboarding" returns lexical matches from the utterance mirror, not an NL→SQL answer or a meaning-ranked one. Read mode_ran and corpus.search_lexemes (the terms actually searched) before reading anything into the result. To force the other lanes, drop the quotes or set mode.
mode: "lexical" can be forced over MCP (the search_customers tool accepts it). Over REST the published mode enum does not include it yet, so "mode": "lexical" is refused with invalid_input; use a quoted phrase under auto to reach the lane.
Because the default limit differs per lane, pass limit explicitly whenever the row count matters — an auto-routed question that lands on fuzzy caps at 50, not 100.
The lanes do not slice one corpus
They read different stores, at different grains, holding different text. A result set from one is not interchangeable with a result set from another:
| Lane | Store it reads | One row is… | Text you get |
|---|---|---|---|
filter | the V2 warehouse — interactions, deals, or deal_qualification | one warehouse row | full content on interactions; none on deals or deal_qualification |
fuzzy | the same warehouse, through SQL the lane writes (usually over interactions; corpus.surface names the surface it read), or the theme index for a themes ask | one warehouse row (one theme, from the index) | full content when it read interactions; none on any other surface or on a theme |
semantic | a pgvector mirror of interactions, bounded to speaker_type = 'external' at ingest | one external utterance | a truncated content_preview |
semantic, degraded | the BigQuery theme index | one clustered theme | the theme's own summaries |
lexical | the same pgvector mirror, through its full-text index | one external utterance | the first 400 characters (corpus.content: "preview"); a term past that point is not findable |
Two consequences worth internalising. The semantic lane searches a strict subset of what the filter and fuzzy lanes search — the mirror holds customer-side speech only, so your own reps' phrasing is not in it to be found. And a semantic match carries a truncated preview plus four predicate hooks (company_id, occurred_at, speaker_type, and the ids) — not quotable text, and none of the label columns you would attribute a quote by, so pass hydrate: true when the match has to become a citation.
You never have to infer which of these answered. Every lane that actually reads a store returns a corpus block naming it, along with the grain, the row count, whether your filters really ran, and why an empty result is empty.
One known gap while we close it: the mirror the semantic lane reads is not currently filtered by your workspace's excluded-interaction settings, which the filter and fuzzy lanes do respect. So a semantic match can come from an interaction you have excluded, and the proportion is material on some workspaces. Treat semantic results as the wider set until this note goes away; if exclusions matter to your use case, confirm a match against the filter lane before acting on it.
Request shape
| Field | Type | Default | Notes |
|---|---|---|---|
query | string (≤2000) | — | Free-text ask, for the fuzzy and semantic lanes. |
mode | auto | filter | fuzzy | semantic | auto | Lane override. The lexical lane is reached through auto with a quoted phrase over REST, or forced with mode: "lexical" over MCP. See The modes. |
max_subqueries | int | 3 | Fuzzy lane: how many sub-questions the ask may fan out into. Max 5 synchronous, 12 with async: true. |
async | boolean | false | Fuzzy lane: run as a background job and get a job handle instead of blocking. Another lane answers inline and names async in corpus.ignored_params. |
job_id | uuid | — | Collect a job started with async: true. Send it alone (no query). |
surface | interactions | deals | deal_qualification | interactions | Which warehouse surface the filter lane slices. The fuzzy and semantic lanes ignore it and name it in corpus.ignored_params. |
audience | all | customer_voice | all | Whose voice counts. customer_voice splices the platform's own canonical customer-voice predicate server-side. interactions only. Turns hydrate on for the semantic lane; a request that would land on the fuzzy lane is rerouted to semantic with hydrate; refused on the lexical lane. See Audience. |
hydrate | boolean | false | Semantic lane only: bridge each match back to its full warehouse row — one row per match, containing the matched utterance, with quotable content and label columns. Ignored (and named in corpus.ignored_params) on the other lanes. See From match to citation. |
coverage | boolean | false | Add gap detection to corpus.coverage. The block itself is always there and always free; this buys the gaps array at the cost of one extra read. See How much your answer can speak for. |
filters | array (max 32) | [] | {field, op, value} predicates, ANDed together — there is no OR. For the customer-voice disjunction use audience. |
order_by | {field, dir} | — | A vocabulary field, or a metric alias in aggregate mode. |
limit | int | lane-dependent | Row / match cap, max 1000. A larger value is clamped to 1000, not refused, and the response reports it on limit_clamped. Default 100 on the filter and semantic lanes, 50 on the fuzzy lane (which is where an unforced plain-language question usually lands). On semantic, a fall back to the BigQuery theme index clamps the effective cap to 50. |
group_by | array (max 8) | — | Filter lane: group fields; requires at least one metric. |
metrics | array (max 8) | — | Filter lane: {fn, field} aggregations. fn ∈ count, count_distinct, sum, avg, min, max; every fn except count needs a field; sum / avg need a numeric one. |
Operator → value shapes: a scalar for eq / neq / gt / gte / lt / lte / contains (case-insensitive substring), an array for in / not_in (max 200 values), a two-element [low, high] for between, and no value at all for is_null / not_null. Timestamps and dates take ISO strings.
An empty request (no query, no filters), a wrong type, an out-of-range value and an unknown filter field all come back as a typed error naming the problem. The one exception is a limit above 1000: it runs at 1000.
What the server adjusted: notes, limit_clamped, rerouted_from
When the server changes your request rather than refusing it, or cuts the result, the response says so in three top-level fields. Each is absent when it has nothing to report.
| Field | Notes |
|---|---|
notes | Plain-language sentences, one per adjustment: a clamped limit, a reroute, hydrate turned on for audience, or a filter-lane result cut by its row limit. Read it before you read the rows. |
limit_clamped | { "requested": 5000, "applied": 1000 } when your limit was above the maximum. |
rerouted_from | The lane the router picked when the request was moved off it. Today only fuzzy, for an audience: "customer_voice" request (see Audience). mode_ran is the lane that answered. |
The published schema declares additionalProperties: false, but the REST validator does not enforce it and the handler strips keys it does not know. A typo such as "filter": [...] for "filters": [...] is silently ignored; if the request also carries a query, the call runs unfiltered. Check compiled (and corpus.filters_applied on the semantic and lexical lanes) to confirm your filters ran. Inside a filter object, an unknown field is refused.
The corpus block
corpus is the provenance of the answer you are holding — which store answered, at what grain, over how many rows, and why a zero is a zero. It rides on every lane that actually reads a store:
| Field | Notes |
|---|---|
store | warehouse | pgvector | theme_index — which physical store answered. |
grain | row (one warehouse row) | utterance (one mirrored external utterance) | theme (one cluster, not an utterance). |
surface | The warehouse surface, when the answer came from one. On the fuzzy lane it is the surface the SQL that ran actually read, which is not always interactions (deals, competitor_mentions, …); absent when no SQL ran. |
row_count | Rows actually returned to you. |
match_count | Matches before hydration. With hydrate: true, row_count ≤ match_count; see reconciling the counts for what the difference is. |
hydrated_grain | Present only when hydrate ran. turn = one row per match, containing the matched sentence. call = the turn lookup failed and these are arbitrary turns from the right conversations; treat them as context, not as the matches. |
unresolved_matches | Matches whose utterance belongs to no grouped turn, so no warehouse row contains them. A real absence, not a failure. Present whenever hydrate ran, including as 0. |
collapsed_matches | Matches that resolved to a turn another match had already claimed — they share a row. Not missing, not substituted, and normally the largest part of the gap. Present whenever hydrate ran, including as 0. |
truncated | true when a cap cut the result — either the 200-id bridge cap or your limit binding on the returned rows. On the filter lane it is measured, not guessed: see When the filter lane hits its limit. |
total_count | Filter lane only: every row your filters match, when a row read filled the 1000-row maximum and a count ran to settle whether anything was left. Absent otherwise; absent never means zero. |
content | full (quotable text plus label columns) or preview (the mirror's truncated content_preview). Absent when the rows have no content text to describe: every warehouse surface except interactions (deals, deal_qualification, and whatever else a fuzzy answer read) and the theme index, whose rows are clusters, not utterances. |
speaker_scope | Whose speech the store that answered can hold: all (internal + external + unknown — the warehouse lanes and the theme index) or external_only (the pgvector mirror, bounded to customer-side speech at ingest). Present on every lane whose surface has a speaker dimension, all included; absent on every surface without a speaker column, which is every warehouse surface except interactions (deals, deal_qualification, …). Read it before reporting an internal-vs-external split. |
filters_applied | Whether your filters reached the store. Always false on the fuzzy lane (which refuses filters outright) and when you sent none; the case to watch is the semantic lane's theme-index degrade, where you sent filters and the store has nowhere to put them. |
ignored_params | Params the schema accepted but the lane that ran does not honour — surface on fuzzy and semantic, hydrate anywhere but semantic. |
audience_applied | Whether the customer_voice predicate was spliced in. |
empty_reason | Populated whenever the result conveys no matches: no_match | weak_matches_only (rows came back but none cleared the relevance bar) | scope_disjoint (your data scope admits nothing this read could return: your filter is disjoint from it, or your access holds no rows of the table it read) | not_synced (the mirror holds no rows for this workspace yet) | spelling_variants_unsearched (lexical lane: another spelling of your term could not be searched, so retrying returns the same result) | read_failed. On the fuzzy lane, a query that failed after its repair round reports read_failed and a writer that declined (the ask does not fit one SELECT, so nothing ran) reports unsupported; only a query that ran and matched nothing is no_match. Fires on bare aggregates too; see below. |
min_relevance | The relevance bar the lane applied, on the lexical and semantic lanes. The two measure different things under one name — lexical scores how distinguishing your matched term is, semantic scores vector distance — so read it beside mode_ran rather than comparing the numbers across lanes. |
weak_matches_filtered | How many rows the bar removed. Present whenever a bar ran, 0 included: an absent field would make "nothing was cut" indistinguishable from a build that does not report it. |
degraded | true when the lane fell back off its primary store. |
coverage | How much of the queried time range this answer can speak for. See How much your answer can speak for. |
empty_reason: "read_failed" is the one to branch on. It means the underlying read broke, not that nothing matched — the rest are honest empties. Treat it as an error; retry rather than reporting a zero.
no_match and weak_matches_only are different answers. no_match is the only one licensing "your corpus holds nothing about this". weak_matches_only means rows came back and none of them cleared the bar — your corpus may hold plenty on the topic in words this query did not reach. The first says stop; the second says rephrase, or widen with hydrate.
A workspace with nothing connected: setup_needed
A workspace that has never connected a source has nothing to search, and its empty result looks the same as "nothing matched". So an empty answer from such a workspace also carries setup_needed, on the result beside results. A count over nothing is empty too: it comes back as one row of zeros ([{ "count": 0 }]), and that row carries the field as well.
{
"data": {
"success": true,
"mode_ran": "fuzzy",
"results": [],
"setup_needed": {
"reason": "no_sources_connected",
"message": "This workspace has no data connected yet, so there is nothing to search. Connect your CRM or call recorder at https://console.amdahl.ai/acme/settings/connections, then ask again.",
"connect_url": "https://console.amdahl.ai/acme/settings/connections"
}
}
}| Field | Notes |
|---|---|
reason | Always no_sources_connected. |
message | Plain words for the person who asked. Show it as written. |
connect_url | The Connections page of this workspace in the console, where a member connects a source. |
The call still answers 200 with success: true and every field it had before. setup_needed is not an error.
- Show the user
messageandconnect_url. Do not report "no results", and do not answer the question from general knowledge as if it came from their data. - It is absent in every other case: when the workspace has a connected source (also one that is broken, or one that was disconnected after it synced), when rows came back, and when the server could not check. An empty result without it is an honest empty: read
corpus.empty_reason. - It goes away on the next call after the first source connects. The data itself appears once that source's first sync lands; until then an empty result carries no field.
- Do not retry until a source is connected, and do not answer from your own knowledge. The full handling, with the
409form research and evals use, is in Errors. Common questions are in the FAQ. research.start,evals.runandmessages.optimizewithevidence: "workspace"carry the same field for the same reason. See Connections.
truncated and degraded are true-or-absent, never false — the same encoding as cached. An explicit false would assert that a cap was evaluated and not hit on lanes where no cap applies at all.
speaker_scope is the opposite encoding, deliberately: it is always stated. It used to appear only as external_only, which left all and "this build does not say" as the same absence — so a caller counting internal versus external speech off the warehouse lanes had nothing telling them the count was whole. Now every lane with a speaker dimension declares its coverage, all included, and absence means one thing only: this surface has no speaker dimension (any warehouse surface other than interactions, such as deals). It describes the store, never your query — a search you narrowed with speaker_type: "external" still reports all, because the question you asked cannot tell you what the corpus behind it holds. How much data your workspace actually holds is a different question again, answered by coverage.
content uses the same absence encoding. It says something about a column, so it is present only where the column is: full on interactions rows, preview on the pgvector mirror, and absent on deals, deal_qualification, every other surface and the theme index. Absent does not mean "unknown"; it means these rows hold no content text, so there is nothing to call full or truncated. It describes the surface, not your projection, so an interactions aggregate that selects no content still reports full. Test corpus.content === "preview" to detect a truncated read; do not treat a missing value as full.
{
"store": "warehouse",
"grain": "row",
"surface": "interactions",
"row_count": 42,
"content": "full",
"speaker_scope": "all",
"filters_applied": true,
"audience_applied": false
}The async handshake carries no corpus — a handle is not a read. A completed job poll does: the rows are in hand, so it returns a corpus block (including coverage) alongside them. A poll that is still queued or running carries none. The full fast-search envelope is on detail.internal either way.
Why an empty aggregate says it is empty
A bare aggregate returns one row even over an empty population, so row_count alone cannot tell you the population was empty. empty_reason fires whenever the result conveys no matches: a count of 0, or a max of null, over nothing carries empty_reason: "no_match" alongside row_count: 1. Aggregates with a group_by return zero rows over an empty population, since no matching rows means no groups.
How much your answer can speak for
Every answer that reads a store carries corpus.coverage: the time range the returned evidence actually covers, and how old it is. It exists because a result set is equally confident and equally well-formed whether it read a complete corpus or one with a hole in it.
The lane where this bites hardest is semantic, and not hypothetically. It ranks by similarity, which carries no temporal guarantee at all — so a workspace whose newest call landed this morning can ask about pricing objections and get its three best matches from March, April and June. Nothing about that response looked stale.
| Field | Notes |
|---|---|
basis | matched_set (these numbers describe your whole match set) | returned_rows (a cap cut the result, so they describe a slice) | unavailable (not measurable — never read this as zero). |
basis_reason | Present unless basis is matched_set: truncated | aggregate | no_time_field | no_rows. |
max_timestamp | Newest event time among the covered rows, ISO-8601. null when basis is unavailable. |
min_timestamp | Oldest event time among the covered rows. |
age_buckets | All four bands, always, including zeros: last_7d, last_30d, last_90d, older. |
dated_row_count | How many covered rows carried a readable event time — the histogram's denominator. |
gaps_status | not_requested (default) | ok | unavailable. |
gaps | Present when gaps_status is ok. An empty array genuinely means none were found. |
gaps_baseline | Which population the gaps were measured against: interactions, or interactions_external for the pgvector mirror. |
Read basis before you read anything else. Coverage is not uniformly derivable: an aggregate carries no event-time column, a truncated result is an arbitrary slice of a larger set, and the fuzzy lane's SQL is written per call so its projection may contain no timestamp at all. Each of those reports basis: "unavailable" with a reason, and every number under it is nulled or zeroed as unmeasured, not as measured zero. Rendering an absent corpus size as 0 is how someone ends up debugging an integration that is fine.
The event-time field is read from a fixed allowlist — timestamp and occurred_at. Nothing else is guessed at, deliberately: a column-name heuristic would happily pick up close_date, which is a projected deal close rather than an event time, and report a confident wrong answer. On the warehouse the allowlist applies to interactions rows only: an answer read from any other surface (a fuzzy query over deals, say) reports basis: "unavailable" with no_time_field, whatever its columns are called, so coverage: true never reports gaps for it against your conversations.
What counts as a gap
A gap is a window where the corpus held rows and your result set had none — not simply a window your matches skip.
That distinction is the whole feature. Conversation volume is bursty, and empty days are business-normal: one healthy week in this corpus carried 276 calls with zero on Saturday, Sunday and Tuesday. A detector that flagged every empty bucket would fire constantly on a perfectly healthy workspace, and anything gating on it would learn to ignore it. Comparing against the corpus is what separates nothing happened here from something happened here and your query did not see it.
Two consequences worth knowing:
- The window is yours when you name one. If your
filterscarry a range ontimestamporoccurred_at, gaps are measured across that range. Otherwise they are measured across your matched set's own span — which cannot see an edge gap, because it never looks past its own last row. If you care whether your evidence runs out before the period you asked about, pass the range explicitly. - A failed baseline reads as
unavailable, never as "no gaps". The extra read is best-effort and can never fail your search, sogaps_status: "unavailable"withgaps_reason: "baseline_failed"is distinct fromgaps_status: "ok"with an emptygapsarray.
"coverage": {
"basis": "matched_set",
"max_timestamp": "2026-08-10T17:22:04.000Z",
"min_timestamp": "2026-03-26T19:30:00.000Z",
"age_buckets": [
{ "bucket": "last_7d", "row_count": 0 },
{ "bucket": "last_30d", "row_count": 2 },
{ "bucket": "last_90d", "row_count": 5 },
{ "bucket": "older", "row_count": 11 }
],
"dated_row_count": 18,
"gaps_status": "ok",
"gaps": [{ "start": "2026-08-11", "end": "2026-08-12", "corpus_row_count": 13 }],
"gaps_baseline": "interactions"
}That last_7d: 0 is the point. It is emitted rather than omitted, so "this answer surfaced no recent evidence" is something the envelope states instead of something you have to notice.
Audience: whose voice counts
audience answers a question the filter DSL structurally cannot. Filters are ANDed, and the platform's canonical customer-voice rule is a disjunction — so the segmentation the field catalog advertises was not expressible against it, and callers who wanted it re-derived it locally, each slightly differently. Stating the intent instead lets the server splice its own predicate, so your slice cannot drift from the one the page templates use.
audience: "customer_voice" keeps accounts the CRM types as customer or sales_target, plus accounts it has not typed at all; it drops investor, advisor, media, competitor, vendor, partner, reseller and other. It then rescues a genuine buyer sitting on a dropped account — someone on a live opportunity, or a scored champion or economic buyer — so a venture partner's advisory feedback is dropped while the buyer evaluating you from that same firm is kept.
| Lane | Behaviour |
|---|---|
filter | Applied directly. interactions only — asking for it on deals or deal_qualification is a typed refusal, because the columns it reads live on interactions alone. |
semantic | Applied on the hydrated warehouse leg, because the mirror carries none of the account-relationship or deal-attachment columns the predicate reads. If you leave hydrate out it is turned on for you and notes says so. Send hydrate: false and you get a refusal rather than a silently dropped scope. |
fuzzy | Rerouted to semantic with hydrate: true. The fuzzy lane writes its own WHERE, so a spliced predicate would be overwritten. The response carries mode_ran: "semantic", rerouted_from: "fuzzy" and a line in notes. The rows are the customer utterances closest in meaning to your query, not a computed answer: for a count or a breakdown, send filters or metrics with the same audience so the filter lane runs. Refused instead if you sent hydrate: false. |
lexical | Refused. You asked for exact terms, and the semantic lane that can apply the audience matches by meaning, so rerouting would answer a different question. |
corpus.audience_applied confirms it actually ran. The default all is byte-identical to the pre-audience behaviour: this is a filter you opt into, not a corpus purge — top-of-funnel and no-deal voices stay in the warehouse for every query that wants them.
Step 0 — discover the vocabulary
Never guess at field names. The catalog lists every filterable field per surface — name, type, description, and the operators that field admits — and it is derived from the same schema the compiler validates against, so it cannot drift:
curl "https://app.amdahl.ai/api/platform/v1/search/fields" \
-H "X-API-Key: $AMDAHL_KEY"Over MCP, the list_search_fields tool, with no arguments:
{}The full catalog is large. To list one surface only, add surface (?surface=deals over REST, "surface": "deals" over MCP). Any other value is refused with the valid surfaces named (400 invalid_query_parameter over REST).
What comes back — one entry per surface (interactions, deals, deal_qualification), or only the one you asked for:
{
"data": {
"surfaces": [
{
"surface": "deals",
"fields": [
{
"name": "deal_amount",
"type": "FLOAT",
"description": "The CRM's amount field ...",
"operators": [
"eq",
"neq",
"in",
"not_in",
"gt",
"gte",
"lt",
"lte",
"between",
"is_null",
"not_null"
]
},
{
"name": "deal_stage_status",
"type": "STRING",
"description": "Funnel OUTCOME of the deal's stage: \"open\" | \"won\" | \"lost\" ...",
"operators": ["eq", "neq", "in", "not_in", "contains", "is_null", "not_null"],
"sample_values": ["open", "won", "lost"]
}
]
}
]
}
}A field advertised with an empty operators list (JSON / repeated columns) exists but is not filterable — you will see it in results, you just cannot predicate on it.
sample_values — read this before filtering on a coded field
Fields drawn from a fixed pipeline vocabulary carry sample_values: the spellings the pipeline can emit. Use it rather than guessing, because a filter on a value that does not exist is indistinguishable from one that exists and matches nothing — both return zero rows, and only one of them means what you will read it to mean. A caller building on this API invented four pushback_type names that were not in the vocabulary, got clean empty results for all four, and separately never queried build_vs_buy or incumbent, which between them carry thousands of utterances.
Two properties worth knowing:
- A listed value is not a promise your workspace has rows carrying it. It promises the spelling is right and the pipeline can produce it.
build_vs_buyis real vocabulary; whether your buyers raised it is what the query answers. - Absence of
sample_valuesis deliberate, not an omission. Fields whose values come from your own CRM configuration (deal_stage_normalized, stage labels, ids, free text) carry none, because any list we published would be your tail rather than a closed set — and reading a partial list as complete is the exact failure above. For those,GROUP BYthe column with ametricsquery and see what you actually have.
The filter lane
The config-DSL lane: declarative predicates, compiled into one tenant-scoped SELECT that runs through the same access-checked query gate as everything else. The open pipeline, largest first:
curl -X POST "https://app.amdahl.ai/api/platform/v1/search/query" \
-H "X-API-Key: $AMDAHL_KEY" \
-H "Content-Type: application/json" \
-d '{
"surface": "deals",
"filters": [
{ "field": "deal_stage_status", "op": "eq", "value": "open" },
{ "field": "deal_amount", "op": "gte", "value": 50000 }
],
"order_by": { "field": "deal_amount", "dir": "desc" },
"limit": 25
}'Over MCP, the search_customers tool:
{
"surface": "deals",
"filters": [
{ "field": "deal_stage_status", "op": "eq", "value": "open" },
{ "field": "deal_amount", "op": "gte", "value": 50000 }
],
"order_by": { "field": "deal_amount", "dir": "desc" },
"limit": 25
}The response carries which lane ran, the rows, and the compiled SQL as the receipt. It is the query as you asked for it, not the statement that reached the warehouse: the gate resolves the surface name to its fully-qualified warehouse table and injects the tenant filter and any data-scope predicate before it runs, so treat compiled.sql as a record of intent rather than something you can paste back verbatim.
{
"data": {
"success": true,
"mode_ran": "filter",
"results": [
{
"deal_id": "9214…",
"deal_name": "Northwind expansion",
"deal_amount": 180000,
"deal_stage_label": "Contract Sent",
"deal_stage_status": "open",
"company_id": "1852…"
}
],
"compiled": {
"sql": "SELECT `deal_id`, `deal_name`, `deal_amount`, … FROM deals WHERE `deal_stage_status` = 'open' AND `deal_amount` >= 50000 ORDER BY `deal_amount` DESC",
"filters": [
{ "field": "deal_stage_status", "op": "eq", "value": "open" },
{ "field": "deal_amount", "op": "gte", "value": 50000 }
]
},
"timing": { "total_ms": 900 }
}
}A top-level cached rides on that envelope as true when the 5-minute query cache served the rows instead of a fresh warehouse run — the field that makes a suspiciously fast timing.total_ms attributable rather than a mystery. It is emitted on the filter and fuzzy lanes, and only on a hit: a miss omits the key rather than asserting false. The semantic lane never emits it at all, because that path embeds and searches live on every call — there is no result cache to hit, so a false there would imply one that missed.
Group + aggregate: the funnel in one call
group_by + metrics turns the slice into an aggregation. Metric aliases are count for {"fn":"count"} and <fn>_<field> otherwise (sum_deal_amount), and order_by can target an alias:
{
"surface": "deals",
"filters": [{ "field": "deal_stage_status", "op": "eq", "value": "open" }],
"group_by": ["deal_stage_label"],
"metrics": [{ "fn": "count" }, { "fn": "sum", "field": "deal_amount" }],
"order_by": { "field": "sum_deal_amount", "dir": "desc" }
}{
"data": {
"success": true,
"mode_ran": "filter",
"results": [
{ "deal_stage_label": "Contract Sent", "count": 14, "sum_deal_amount": 1240000 },
{ "deal_stage_label": "Discovery", "count": 32, "sum_deal_amount": 890000 }
],
"compiled": {
"sql": "SELECT `deal_stage_label`, COUNT(*) AS `count`, SUM(`deal_amount`) AS `sum_deal_amount` FROM deals WHERE …"
}
}
}When the filter lane hits its limit
A row read that has more matches than its limit returns the first limit rows and says so: corpus.truncated: true and a line in notes. The lane measures this rather than inferring it from row_count == limit:
- Below
1000, it asks the warehouse for one row past yourlimitand drops it before answering. If that row came back, more rows exist. A population that lands exactly on yourlimitis complete and carries notruncated. - At
1000, the warehouse maximum, there is no room for that extra row. When a row read fills it, the lane runs oneCOUNT(*)with the same filters andaudienceand reports the result ascorpus.total_count, sonotesreads likeResults truncated at the 1000-row maximum: 1000 of 2341 matching rows returned.A population of exactly1000comes back withouttruncated. The count reads only the filter columns, so it costs no more than the read it follows, and it counts as one more read against your per-user warehouse-read budget. If the count cannot run, the response still saystruncated: trueandnotessays more rows may exist. - An aggregate at
1000is not counted (its rows are groups, not matches);notessays more groups may exist.
{
"data": {
"success": true,
"mode_ran": "filter",
"results": ["… 1000 rows …"],
"corpus": { "row_count": 1000, "truncated": true, "total_count": 2341 },
"notes": [
"Results truncated at the 1000-row maximum: 1000 of 2341 matching rows returned. Narrow the filters, or use group_by + metrics to count or sum the whole population instead of reading rows."
]
}
}Rules the compiler enforces (each violation is a named invalid_argument): group_by requires at least one metric; sum / avg need a numeric field; in aggregate mode order_by must be a group field or a metric alias. Aggregate in the query, not over a capped row set — limit caps rows at 1000, and a metric computed client-side over a capped slice is a metric over an arbitrary subset.
The fuzzy lane
Plain language in, SQL out. This is the lane the retired POST /search ran, unchanged:
{ "query": "open deals with no activity in the last 21 days, biggest first", "mode": "fuzzy" }The fuzzy lane takes a free-text query only — structured filters cannot ride along with it. Send both and land on fuzzy, and you get a typed refusal telling you to fold the constraint into the question or use the filter / semantic lanes.
Multi-intent asks split. "The objections we hit from 11x, and how are deals with Acme going?" is two independent questions. The lane plans the ask into the fewest sub-questions (3 by default), writes SQL for each, and runs them in parallel — so each gets a well-formed query instead of one conflated WHERE. The whole call still settles in seconds.
Splitting never rewords the question. A question that needs no split reaches the SQL writer word for word, and each part of a split may only reuse your own words. If a split would add a word you did not write, it is dropped: the whole question runs as one query, and detail.uncovered carries a planner_rewrote entry saying the answer may cover only part of it, with retry_guidance.action = "narrow_query" (ask the parts separately).
Name what you mean, with a capital. A question that names a company, person, product, segment, region or quarter ("What objections did Acme raise?", "pain points from Enterprise accounts", "objections in Q3") goes to the SQL writer, which filters on that name, so the answer is about it. The fast curated lists recognize a name by its capital letter, quotes, an email address or a domain, so write "Acme", not "acme". A capitalized word anywhere after the first is read as a name, so a question typed in Title Case skips the fast lists and takes the slower path.
A fast list answers only what it can apply. If a question carries something the list has no way to apply, it goes to the SQL writer, which applies it. That covers a topic ("pricing objections"), a segment ("enterprise"), a grouping ("by region", "per call"), a record type ("from emails", "in meetings"), "my" or "mine", a name in lower case, a second question ("and which accounts…"), an ordering the list does not do ("most recent champions"), or a time window it cannot read ("in 2024", "since January"). The fast lists read "last 30 days", "past 3 months", "last two weeks", "in the last month", "this quarter", "year to date", "today" and "yesterday" as a window running back from now or from the start of the period, and "last month", "last quarter", "last week" and "last year" as the previous calendar period. A question that names two windows, or one beside a word that reverses it ("other than last month"), goes to the SQL writer. The competitor landscape and win-rate lists have no per-call date, so a question that names a window for them goes to the SQL writer too. "Top 5" or "give me 10" caps the rows.
Breadth is a knob. max_subqueries raises the fan-out: up to 5 synchronously and up to 12 with "async": true. Values above the applicable cap are clamped, not rejected. Raise it when a reply comes back with retry_guidance.action = "raise_max_subqueries" — parts of your question were cut by the cap, and retry_guidance.suggested_params carries the value to send.
corpus describes the query that ran. Most fuzzy answers read interactions, but a deal question can be answered from deals, a competitor question from competitor_mentions, and a themes ask from the theme index. corpus.surface names the surface the SQL that ran actually read (that SQL is compiled.sql, or detail.internal.sql on a collected job). speaker_scope is omitted on a surface with no speaker column, and coverage reports basis: "unavailable" on any surface other than interactions. A themes answer reports store: "theme_index" and grain: "theme", with no surface.
The detail envelope. Fuzzy returns the full warehouse envelope on detail alongside the flat results / compiled.sql:
{
"data": {
"success": true,
"mode_ran": "fuzzy",
"results": [{ "objection": "pricing", "n": 42 }],
"compiled": { "sql": "SELECT objection, COUNT(*) AS n FROM interactions WHERE …" },
"detail": {
"internal": {
"status": "ok",
"rows": [{ "objection": "pricing", "n": 42 }],
"row_count": 1,
"truncated": false,
"cached": false,
"repaired": false,
"note": null
},
"groups": [
{
"label": "Objections",
"question": "objection-tagged calls in the last 30 days",
"internal": { "status": "ok", "row_count": 1 }
}
],
"message": "Found 1 row(s) in your workspace data — every row that matched (under your 50-row cap).",
"coverage": {
"latest_event_at": "2026-05-19T05:01:50Z",
"total_rows": 67538,
"days_behind": 1,
"is_stale": false
},
"escalate_to_chat": false,
"escalate_reason": null
}
}
}Read these fields off it:
detail.internal.status—ok(rows came back) /empty(the ask is answerable, nothing matched) /unsupported(does not fit one query over one table, so no SQL was written) /failed(SQL ran and failed after one self-repair round).noteexplains every non-okstatus in plain language; the raw database error is never surfaced.detail.internal.truncated—truewhen more rows matched than yourlimitallowed, so what came back is a slice and not the whole answer. The lane asks the warehouse for one row past your cap and drops it before answering, sorow_countnever exceeds thelimityou sent — the flag is the only place the overflow shows, anddetail.messagesays the same thing in plain language. Any number you compute over a slice is real-but-wrong: raiselimit, or fold the aggregation into the question itself ("how many, by stage") so the warehouse counts the whole population and hands back a few rows. Do not infer truncation fromrow_count == limit— below the engine cap, a population that lands exactly on yourlimitis complete andtruncatedisfalsefor it. The one exception islimit: 1000, the engine's own maximum: there is no room to ask for a row past it, so a result that fills it always readstruncated: true. At that value the flag means "there may be more", not "there certainly is" — the only conservative reading in the field, and deliberately so.detail.internal.cached— the fuzzy lane's copy of the cache flag it also hoists to the top-levelcached(see the filter lane). Read the top-level one, so cache attribution looks the same whichever lane ran.detail.groups— one entry per planned sub-question, each with its ownlabel,question, and result. A single-intent ask yields one group.detail.coverage— how much data you have and how current it is:total_rows(interaction rows the workspace holds at all),latest_event_at(the newest conversation),latest_ingest_at(the newest sync — the pair distinguishes "no calls happened" from "no sync ran"),days_behind, andis_stale. Render it as a freshness badge; when your data is materially behind "now" and a recent-window ask came back empty,messagefolds in a plain-language note so an empty result reads as a coverage gap rather than a failure.total_rows: 0withlatest_event_at: nullis the tell for a workspace that has not finished its first sync — see Once your conversations are synced in the Quickstart.detail.retry_guidance— the single most useful change to make, machine-readable onaction:raise_max_subqueries(parts were cut by the breadth cap),run_async(parts ran out of time),narrow_query(the ask does not fit this lane), ornone(full coverage).suggested_paramscarries the params to merge into the same query on the retry, andreasonis one line you can show a human.detail.escalate_to_chat/escalate_reason—truewhen the ask is out of this lane's reach and Chat is the better door: any sub-question came backunsupported/failed, or the planner spotted a non-data intent (advice, a recommendation, a "what should I say"). The data groups it could answer still come back either way.
The async lane
The synchronous fuzzy path is bounded at roughly 15 seconds. For a deliberately broad, multi-part ask, hand it off: pass "async": true and you get a handle back straight away instead of a blocked connection. The job runs against a three-minute budget rather than the caller's patience, so it also allows a higher max_subqueries — 12, versus 5 synchronously.
{
"query": "objections by segment, and which of them close anyway",
"mode": "fuzzy",
"async": true,
"max_subqueries": 8
}{
"data": {
"success": true,
"mode_ran": "fuzzy",
"results": [],
"timing": { "total_ms": 40 },
"job": {
"job_id": "3f1c…",
"status": "queued",
"poll": "Call search.query again with `job_id` (and no `query`) to collect the result."
}
}
}Collect it by calling the same verb with the job_id alone — no query, no filters:
{ "job_id": "3f1c…" }job.status walks queued → running → complete | failed. While it is queued or running, poll again; on complete the full fuzzy envelope is on detail and the rows are on results. A polled result does not re-emit compiled.sql — read the SQL off detail.internal.sql instead. Handles are kept for one hour; after that the poll returns a typed invalid_argument telling you to re-run the original query.
The semantic lane
Meaning over the call corpus — for the asks where literal filters fail (nobody's CRM has an objection_flavor column):
{
"query": "What do customers say about onboarding friction and time-to-value?",
"mode": "semantic",
"limit": 20
}{
"data": {
"success": true,
"mode_ran": "semantic",
"results": [
{
"id": "e2b1…",
"source_row_id": "78dbcf40b5f2bdea3670e1519db9fd54",
"interaction_id": "129889885",
"parent_interaction_id": "129889885",
"company_id": "1852…",
"company_name": "Northwind Logistics",
"occurred_at": "2026-06-11T15:20:00Z",
"speaker_type": "external",
"content_preview": "honestly the rollout took longer than the eval — six weeks before the team saw value…",
"similarity": 0.83
}
],
"freshness": { "source": "pgvector", "synced_at": "2026-07-21T04:12:09Z" },
"corpus": {
"store": "pgvector",
"grain": "utterance",
"row_count": 20,
"content": "preview",
"speaker_scope": "external_only",
"filters_applied": true
},
"timing": { "total_ms": 640, "embed_ms": 210 }
}
}Read these fields before you use the results:
mode_ran— which lane actually executed. If an auto-routed ask landed onfuzzy, you got SQL-derived rows (andcompiled.sql), not similarity matches. Branch on this, don't assume.freshness.source— where the matches came from.pgvector= the fast vector mirror, withsynced_attelling you how recently it synced (a result cannot contain a call from after that stamp).bigquery= the lane transparently fell back to the warehouse theme index — the results are then theme-level matches (source_id,label,similarity,member_count,interestingness_score, plusdescription/insight/representative_quotes) rather than utterance-level rows, and the row cap is clamped to 50 on that path. The fallback is honest coverage, not an error: it fires when the fast mirror is not enabled or has not synced for your workspace yet.corpus— the same story in machine-readable form, and the only place two of its cases are distinguishable.store: "theme_index"withdegraded: trueis the fallback above; on that pathfilters_appliedisfalse, because the theme index takes a query and a limit and has nowhere to put acompany_idoroccurred_atpredicate. Andempty_reason: "read_failed"marks a read that broke rather than one that honestly matched nothing — the two used to be the same empty array.corpus.weak_matches_filtered— how many neighbours were too distant to return. Nearest-neighbour search ranks; it does not judge. Ask about something your corpus does not cover and the store still returns its closest rows, which is why this lane applies a relevance bar and reports what it cut. A high count next to few results means the topic is thin in your data, not that the query failed.
Scoping a semantic query
Semantic mode accepts a narrow filter set — enough to scope the similarity search without breaking it:
| Field | Operators |
|---|---|
company_id | eq, in |
occurred_at | gte, gt, lte, lt, between |
speaker_type | eq |
speaker_type is accepted here but has nothing left to do: the mirror is external-only by construction, so eq external narrows nothing and eq internal is an empty result rather than a narrower search. When what you want is buyer voice separated from the investor, advisor and partner speech that merely shares an account record, reach for audience instead.
"Customer-voice pricing pushback at these three accounts, this quarter":
{
"query": "pushback and hesitation about pricing or contract terms",
"mode": "semantic",
"audience": "customer_voice",
"hydrate": true,
"filters": [
{ "field": "company_id", "op": "in", "value": ["1852…", "9b03…", "77aa…"] },
{ "field": "occurred_at", "op": "gte", "value": "2026-04-01" }
],
"limit": 25
}hydrate: true is what applies the audience there: it reads account-relationship and deal-attachment columns the mirror does not carry, so on this lane the predicate can only be applied on the hydrated warehouse leg. Leave hydrate out and it is turned on for you (notes says so); send hydrate: false and the request is refused.
Any other field or operator in semantic mode returns a typed invalid_argument naming the supported set. (Note the semantic lane's time field is occurred_at; the filter lane's interactions catalog calls the same event time timestamp.)
From match to citation
A semantic match is a good find and a poor citation. The mirror stores a truncated content_preview and four predicate hooks — no speaker_title, no pushback_type, none of the buyer-signal columns. There is not enough there to quote a customer or say who they were.
Semantic and lexical rows do name the account. Amdahl keeps a list of your workspace's account names and adds company_name beside each company_id from it, so naming adds no time to a search once the list is loaded. The name is the one the interactions surface shows: the CRM name, else the first part of the company's domain. The key has three states. A string is the account's name. null means the account has no name, or the row has no company. An absent key means the name is unknown: the list was still loading (the workspace's first search in a day can be one), or it does not hold that company yet. When any name is unknown, notes says how many rows, and how to look them up with a filter search. The search never fails because of it. Rows do not name a deal, because a company can have several.
hydrate: true closes that gap by bridging each match back to its warehouse row through the same gated read the filter lane uses, so you get full content and every label column in one call:
{
"mode": "semantic",
"query": "what worries customers about onboarding",
"hydrate": true,
"audience": "customer_voice",
"limit": 25
}One row per match, containing the matched utterance. The bridge resolves each match to the exact conversational turn it came from, so corpus.row_count is at most corpus.match_count and the two are worth comparing. Confirm which bridge ran on corpus.hydrated_grain:
hydrated_grain | What you are holding |
|---|---|
turn | The answer you want: one row per match, and that row contains the matched sentence. |
call | A fallback, flagged with degraded: true. The turn lookup failed, so these are arbitrary turns from the right conversations — usable as context, not as the matches. |
Hydrating widens the corpus, and speaker_scope says so. The matches were found in the external-only mirror, but the rows you get back are full warehouse turns — so a hydrated response reports "speaker_scope": "all" where the un-hydrated one reported "external_only". Matching is still customer-side; the text you are now holding is not.
Reconciling the counts
row_count is normally lower than match_count, and that is expected rather than a sign anything was dropped. Two things account for it, both counted:
match_count = row_count + collapsed_matches + unresolved_matchescollapsed_matches— usually the bigger term. Several matched utterances routinely land in the same turn, so they share one row. Grouped turns hold 2.88 atomic utterances on average and 78.3% hold more than one, and semantic neighbours are especially likely to sit next to each other — so roughly a quarter of matches collapsing is normal. Nothing is missing; one row carries several of your matches.unresolved_matches— usually small. A matched utterance never folded into a grouped turn has no row oninteractionsat all (1–12% of external utterances depending on the workspace). These are counted rather than back-filled with a neighbouring row, so a citation you build from a hydrated result is always the sentence that actually matched. If every match is unresolvable, the response says so and skips the warehouse read entirely.
Both fields are present whenever hydrate ran, including when they are 0 — so their absence means hydration did not run, never that nothing collapsed. The identity can still leave a remainder if the gated warehouse read itself withheld rows (an audience predicate, an excluded interaction, or your limit binding); truncated and audience_applied tell you when that happened.
What you trade for the labels. Hydrated rows are warehouse rows, so they carry no per-match similarity and do not come back in similarity order — when the ranking is what you care about, read the un-hydrated matches first and hydrate second. And on the theme-index degrade there is no id to bridge on at all, so hydration is skipped and hydrate shows up in corpus.ignored_params rather than failing the search. Beyond 200 distinct ids the list is capped and corpus.truncated says so.
If you would rather bridge yourself
Only one join resolves from the response alone:
| To join a semantic match to… | Use |
|---|---|
its call on interactions | parent_interaction_id → parent_interaction_id ✅ |
| the exact turn that matched | hydrate: true — the key is not on the response |
Two joins look right and are not, and the reason is worth understanding once — it will stop you trying the third.
These ids are different grains of one lineage, not unrelated keys. Several atomic utterances are grouped into one conversational turn, so source_row_id identifies an atomic utterance while the filter lane's interaction_id identifies the grouped turn it belongs to. They are both 32-character hex because they are minted by the same hash function at different levels — which is exactly why they look interchangeable and are not.
So: do not join interaction_id to interaction_id (parent call on one side, grouped turn on the other), and do not reach for source_row_id because its shape matches (atomic, one level below the turn). Both return zero rows silently, which reads like "no data" rather than "wrong key".
The turn id that would join is resolvable, and hydrate: true is how you get it — that path returns the matched turn directly. We deliberately do not resolve it on every semantic call: it costs an extra warehouse read, and this lane exists to answer from the vector mirror alone in well under a second.
The lexical lane
Exact-term matching over the same customer-side utterance mirror the semantic lane reads, ranked by how rare each matched term is in your corpus. No embedding call is made. You reach it by putting a phrase in double quotes under mode: "auto", or over MCP by setting mode: "lexical"; the REST mode enum does not accept lexical today.
{ "query": "who asked about \"SSO\"", "limit": 10 }{
"data": {
"success": true,
"mode_ran": "lexical",
"results": [
{
"source_row_id": "utt_8f21…",
"parent_interaction_id": "c7d0…",
"company_id": "4b19…",
"company_name": "Contoso Health",
"occurred_at": "2026-08-14T16:03:22.000Z",
"speaker_type": "external",
"content_preview": "we cannot roll this out to the whole org without SSO",
"score": 7.42,
"relevance": 1,
"matched_lexemes": ["sso"]
}
],
"corpus": {
"store": "pgvector",
"grain": "utterance",
"row_count": 1,
"content": "preview",
"speaker_scope": "external_only",
"filters_applied": false,
"search_lexemes": ["sso"],
"min_relevance": 0.6,
"weak_matches_filtered": 3
}
}
}- A quoted phrase scopes the search to what you quoted. The words around it do not compete with it.
corpus.search_lexemesis what was actually searched, after normalizing and after dropping terms too common to distinguish anything (listed indropped_lexemes). Read it before you read a zero.relevance(0 to 1) says how much of your query's distinguishing weight a row matched;scoreonly ranks. Rows undercorpus.min_relevanceare removed and counted inweak_matches_filtered, and when every row was removed the answer saysempty_reason: "weak_matches_only"rather thanno_match.- It reads a 400-character preview. A term past the first 400 characters of a long utterance is not findable here. Use the filter lane's
containsoperator oncontentfor long text. - Filters are the semantic subset:
company_id,occurred_atandspeaker_type.hydrateandsurfaceare reported inignored_params, andaudience: "customer_voice"is refused. parent_interaction_idis call grain. It joins to the warehouse'sinteractions.parent_interaction_id, not to the filter lane's turn-graininteraction_id.
Prompts to hand an agent
Structured slice, discovery-first:
Structured slice of our data — use the search_customers tool with typed
filters, not a plain-language search. First call list_search_fields with
surface deals and check the exact field names. Then run: open deals with amount
>= 50000, ordered by amount descending, top 25. Show me the filters you sent
AND the compiled SQL that came back, so I can reuse the exact slice later.Meaning search:
Meaning search over our call corpus — use the search_customers tool in
semantic mode. Find what customers say about onboarding friction and
time-to-value since April. Report which lane ran (mode_ran), the freshness
stamp, and the corpus block (store, grain, content), then list the strongest
3 matches with their similarity scores. Now re-run the same query with
hydrate true to pull those matches back as full warehouse rows, and quote
from those — the previews are truncated.Tips
- Read
search.fieldsonce, then trust the errors. A wrong field or operator returnsinvalid_argumentnaming the allowed set — the API teaches its own vocabulary. - Prefer the resolved stage facts. On
deals, filter and group ondeal_stage_status/deal_stage_label, not the rawdeal_stageid (tenant CRMs carry opaque stage ids). - A blank deal on
interactionshas two meanings —deal_match_methodtells you which.deal_stage_status/deal_stage_label/deal_amountare all null both when the company genuinely has no deal (deal_match_methodis null) and when the attach refused — the company has deals and none was attachable at the time of the conversation (deal_match_method: "no_open_deal"). That is ~61% of linked rows fleet-wide, so reading every blank as "no pipeline here" is the single easiest way to misread this surface. Roll the first kind up at the company; treat the second as unattributed. And excludecompany_post_closefrom win/loss analysis — that conversation happened after the deal closed, so keeping it leaks the outcome you are trying to predict. - Branch on
mode_ran, not on hope. Integrations that need similarity matches should forcemode: "semantic"and treatmode_ranas the confirmation. - Similarity scores are ordinal, not calibrated. Rank within one response; don't compare absolute values across queries.
- Don't reach for
speaker_typeto get customer voice on the semantic lane. The mirror is bounded to external speech at ingest, sospeaker_type: "external"is a no-op there and"internal"returns a structural zero. On the filter lane it does separate reps from customers — but "customer voice" usually means excluding investors, advisors and partners too, and that isaudience: "customer_voice". - Hydrate before you quote. A raw semantic match carries a truncated preview and no label columns — enough to rank, not enough to cite.
hydrate: truegets you the full text and the attribution, at call grain. - Read
corpus, not the shape of the rows. It names the store that answered, whether your filters actually ran, and — throughempty_reason: "read_failed"— whether an empty result is an answer or a broken read.
See also
- FAQ: why a search is empty, and what
setup_neededmeans - Endpoints overview — the whole surface, prerequisites, and how the verbs chain.
- Chat — when "find" becomes "explain", hand the thread to the agentic lane.