---
title: "Search knowledge"
method: POST
path: "/v1/projects/{project_id}/knowledge/search"
tags: ["Knowledge"]
---

# Search knowledge

`POST /v1/projects/{project_id}/knowledge/search`

## Request

- Base URL: `https://api.naturali.ai`
- URL: `https://api.naturali.ai/v1/projects/{project_id}/knowledge/search`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.naturali.ai/authorize`, token URL `https://api.naturali.ai/token`, scopes: `mcp:access`)

Searches across documents and memories using hybrid retrieval — a vector query and a full-text query per source, fused by reciprocal rank — or by file paths, document IDs, memory store IDs, or tags. At least one of `query`, `tags`, `document_paths`, `document_ids`, or `memory_store_ids` must be provided.

## Request body

- object
  - `query` string: Search query text. Runs a vector query and a full-text query over each store and fuses the rankings; the full-text channel matches exact tokens and phrases, the vector channel everything else.
  - `min_similarity` number: Minimum raw cosine `similarity_score` a **vector** candidate must reach to take part in ranking, applied before fusion. Only applies when `query` is provided. Lexical candidates are deliberately exempt: a result that literally contains the searched token is the evidence, and dropping it for a low cosine is the failure hybrid search exists to prevent. Not a floor on `score`, whose fused value encodes rank position rather than similarity.
  - `rrf_k` integer: The `k` in the reciprocal rank fusion term `1 / (k + rank)`, which sets how steeply a result's contribution decays with its position in each ranked list. A smaller value weights the very top of each list more heavily. Defaults to the deployment's `KNOWLEDGE_RRF_K`, itself 60 by default. Only applies when `query` is provided.
  - `recency_half_life_days` number: Half-life, in days, of a recency decay applied to **memory store** results after fusion: a result's `score` is multiplied by `2 ^ (-age_in_days / recency_half_life_days)`, where age is measured from its `updated_at`. Document results are never decayed. `0` — the default, and the default of the deployment's `KNOWLEDGE_RECENCY_HALF_LIFE_DAYS` — disables the blend entirely; it is a switch, not a lower bound, and sending `0` turns off a deployment-wide decay for this one request. Accepts fractions (`0.5` is twelve hours). How many ranks a given half-life costs depends on `rrf_k`. Only applies when `query` is provided. **No value is known to be safe in general**: every half-life measured against the reference corpus that lifted recency-sensitive queries also cost relevance-sensitive ones, which is why this ships disabled. Measure against your own corpus before setting it — see the Retrieval Quality guide.
  - `limit` integer: Maximum number of results to return (default 10). A value above 100 is clamped to 100 — the ceiling bounds the vector scan this one request performs, so a larger `limit` returns everything there is up to that many rows rather than being refused.
  - `include_documents` boolean: Set `false` to leave the document store out of this search. A `query` names no store, so it reaches both; the store-specific filters narrow *within* a store rather than choosing between them. This is the switch, and a filter naming the other store never overrides it. `false` for both stores is `400`.
  - `include_memories` boolean: Set `false` to leave the memory store out of this search. The mirror of `include_documents`, for a caller that wants documents alone.
  - `memory_store_ids` string[]: Search memories within these specific memory stores
  - `document_paths` string[]: Filter results to documents whose file path starts with one of these prefixes
  - `document_ids` string[]: Filter results to specific document IDs
  - `tags` TagBag: Key-value labels on a resource. A flat object of string values — an array, a nested object or a number is rejected with `400 VALIDATION_FAILED`, never coerced. Keys are opaque and stored verbatim, so `cost_center` and `costCenter` are two different tags. Matched by JSONB containment wherever tags are read: the `?tags=` filter and knowledge search. Keys beginning `system.` are reserved: the platform writes them to record which conversation, actor, agent and role a row came from, and a write naming one is refused with `400 RESERVED_TAG_KEY`. They are read and filtered like any other tag. The bag is bounded, because every pair reaches the IAM context of every access check on the resource: at most 50 keys, each key at most 128 characters and each value at most 256. A write past a bound — including a merge that would grow the stored bag past the key count — is `400 VALIDATION_FAILED` with `meta.limit` naming the bound it crossed. `system.*` keys are the platform's and do not count against the 50.
  - `metadata` MetadataFilter: Narrows results by the `metadata` bag. Each key is a field; its value is either a value to match exactly, or an object naming one or more operators. Equality and `in` match the stored value exactly, so `3` and `"3"` are different filters. `gt`, `gte`, `lt` and `lte` order the field against the operand, and the operand's own JSON type says which comparison is meant: a number orders numerically, a string lexicographically. They work on any field, in any scope. A document whose field holds another type is excluded from the comparison rather than erroring on it, so a range never fails on a bag that happens to hold text where another holds a number. An operand that is neither a number nor a string — a boolean, `null`, a list, an object — has no ordering and is `400 VALIDATION_FAILED`, naming the field in `meta.field`.

## Response `200`

Search results

- object
  - `results` KnowledgeResult[], required
    - union
      - DocumentKnowledgeResult
        - `source_type` 'document', required: The type of knowledge source this result comes from
        - `document_id` string, required: Public ID of the document
        - `document_version` integer: The document version the chunk belongs to. Cite it to record which text was read; the document's versions keep that text after it changes.
        - `chunk_id` string: Public ID of the document chunk that matched the query
        - `page` integer, nullable: Page number within the source PDF (1-indexed). Null for plain-text documents.
        - `file_id` string: Public ID of the underlying file
        - `project_id` string: Public ID of the project the document belongs to
        - `path` string: Logical path of the file within the project
        - `filename` string: Filename of the underlying file
        - `size` integer: File size in bytes
        - `title` string: Document title
        - `metadata` MetadataBag: Caller-owned annotations on a resource, stored as the object they were written as: the types a value was written with are the types a read returns, so a filter can ask an ordering question about a number. Unlike other body fields, keys are stored and returned verbatim in the casing supplied — they are not converted between snake_case and camelCase. No key is reserved, and that is the point: every piece of state the platform owns lives in its own typed column, so nothing written here reaches platform state. The platform never reads the bag — it is not an IAM context, not a policy input and not part of a prompt — which is what separates it from a tag bag.
        - `tags` TagBag: Key-value labels on a resource. A flat object of string values — an array, a nested object or a number is rejected with `400 VALIDATION_FAILED`, never coerced. Keys are opaque and stored verbatim, so `cost_center` and `costCenter` are two different tags. Matched by JSONB containment wherever tags are read: the `?tags=` filter and knowledge search. Keys beginning `system.` are reserved: the platform writes them to record which conversation, actor, agent and role a row came from, and a write naming one is refused with `400 RESERVED_TAG_KEY`. They are read and filtered like any other tag. The bag is bounded, because every pair reaches the IAM context of every access check on the resource: at most 50 keys, each key at most 128 characters and each value at most 256. A write past a bound — including a merge that would grow the stored bag past the key count — is `400 VALIDATION_FAILED` with `meta.limit` naming the bound it crossed. `system.*` keys are the platform's and do not count against the 50.
        - `content` string, nullable, required: Full text content of the document
        - `score` number: Reciprocal-rank-fusion relevance ranking — higher is better. The **ordering** it produces is the contract; the absolute value is not, is deliberately not rescaled into 0–1, and the formula behind it may change. Results are sorted by it. Nothing filters on it: `min_similarity` filters `similarity_score`. Only present when `query` was provided. Use `similarity_score` when you need the raw cosine value.
        - `signals` object: Which retrieval channels ranked this result before fusion, and its 1-based position in that channel's own ordering. A channel that did not return the result is absent, so presence reads as "this channel found it". `score` says where the result landed; this says how it got there — `{ "lexical": 1 }` is a pure token hit, `{ "vector": 2, "lexical": 1 }` a result both channels found and fusion promoted. Only present when `query` was provided; a channel that degraded is absent from every result. Diagnostic: nothing filters or sorts on it.
          - `vector` integer: Rank in the vector (cosine) channel, best first.
          - `lexical` integer: Rank in the lexical (full-text) channel, best first.
        - `similarity_score` number: Raw cosine similarity (0–1) between the query and this result. Pinned to that meaning — unlike `score`, it is never redefined — and populated on every result of a `query` search, a lexical-only hit included. Absent only when the embedding provider was unreachable and the search answered from the lexical channel alone, where there is no query vector to measure against.
        - `created_at` string, date-time, required: Creation timestamp
        - `updated_at` string, date-time, required: Last updated timestamp
      - MemoryKnowledgeResult
        - `source_type` 'memory', required: The type of knowledge source this result comes from
        - `memory_id` string, required: Public ID of the memory
        - `memory_store_id` string, required: Public ID of the parent memory store
        - `memory_store_name` string, required: Human-readable name of the parent memory store
        - `content` string, required: Text content of the memory
        - `tags` TagBag: Key-value labels on a resource. A flat object of string values — an array, a nested object or a number is rejected with `400 VALIDATION_FAILED`, never coerced. Keys are opaque and stored verbatim, so `cost_center` and `costCenter` are two different tags. Matched by JSONB containment wherever tags are read: the `?tags=` filter and knowledge search. Keys beginning `system.` are reserved: the platform writes them to record which conversation, actor, agent and role a row came from, and a write naming one is refused with `400 RESERVED_TAG_KEY`. They are read and filtered like any other tag. The bag is bounded, because every pair reaches the IAM context of every access check on the resource: at most 50 keys, each key at most 128 characters and each value at most 256. A write past a bound — including a merge that would grow the stored bag past the key count — is `400 VALIDATION_FAILED` with `meta.limit` naming the bound it crossed. `system.*` keys are the platform's and do not count against the 50.
        - `score` number: Reciprocal-rank-fusion relevance ranking — higher is better. The **ordering** it produces is the contract; the absolute value is not, is deliberately not rescaled into 0–1, and the formula behind it may change. Results are sorted by it. Nothing filters on it: `min_similarity` filters `similarity_score`. Only present when `query` was provided. Use `similarity_score` when you need the raw cosine value.
        - `signals` object: Which retrieval channels ranked this result before fusion, and its 1-based position in that channel's own ordering. A channel that did not return the result is absent, so presence reads as "this channel found it". `score` says where the result landed; this says how it got there — `{ "lexical": 1 }` is a pure token hit, `{ "vector": 2, "lexical": 1 }` a result both channels found and fusion promoted. Only present when `query` was provided; a channel that degraded is absent from every result. Diagnostic: nothing filters or sorts on it.
          - `vector` integer: Rank in the vector (cosine) channel, best first.
          - `lexical` integer: Rank in the lexical (full-text) channel, best first.
        - `similarity_score` number: Raw cosine similarity (0–1) between the query and this result. Pinned to that meaning — unlike `score`, it is never redefined — and populated on every result of a `query` search, a lexical-only hit included. Absent only when the embedding provider was unreachable and the search answered from the lexical channel alone, where there is no query vector to measure against.
        - `created_at` string, date-time, required: Creation timestamp
        - `updated_at` string, date-time, required: Last updated timestamp

## Other responses

- `400`: Bad request — at least one search parameter is required
- `401`: Unauthorized
- `403`: Forbidden, or a `query` and no project in scope the caller holds `embeddings:CreateEmbeddings` on to bill its embedding to

---

[API](https://skmtc.dev/naturali/apis/naturali-ai-api.md) · [All operations](https://skmtc.dev/naturali/apis/naturali-ai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/naturali/apis/naturali-ai-api/revisions/e4905b78f422?raw)
