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

# Build a RAG knowledge base

> Create a knowledge base (managed modulexdb or BYOK), ingest documents through the parse-chunk-embed pipeline, and query it with vector, hybrid, or RAG-context search over the REST API and the JavaScript and Python SDKs.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

This guide takes you end to end: create a [knowledge base](/concepts/knowledge-rag),
ingest your documents through the parse-chunk-embed pipeline, and query the result with
vector, hybrid, or RAG-context search — from the REST API and both SDKs. It documents
every request field, response shape, error, and credit cost so you can wire retrieval into
a workflow or an agent with no surprises.

For the conceptual model behind retrieval (knowledge bases, managed vs BYOK, ingest and
retrieval), read [Knowledge & RAG](/concepts/knowledge-rag). To manage knowledge bases in
the app instead of by API, see the [Knowledge overview](/platform/knowledge/overview).

<Note>
  A **knowledge base** has its own embedding and chunking
  configuration — the unit that retrieval searches over. A **document** is one uploaded
  file; ingest splits it into **chunks**, each carrying a vector **embedding**. The vocabulary
  on this page matches the [glossary](/reference/glossary).
</Note>

## Before you start

Every endpoint on this page lives under the `/knowledge-bases` router and is **org-scoped
and admin-gated**. You need:

* A ModuleX API key (`mx_live_…`). Get one from the app or see [Authentication](/api-reference/authentication).
* The **owner** or **admin** role in the organization. Every knowledge route depends on
  `organization_admin_required`, so a non-admin caller is rejected with `403`. The legacy
  `member` role is retired — see [Roles & permissions](/security/roles-permissions).
* The base URL `https://api.modulex.dev`. There is **no `/v1` path segment** — paths are
  exactly `/knowledge-bases/...`.

Authenticate every request with two headers (the backend also accepts `X-API-KEY` as an
alternative to the bearer token):

```http theme={null}
Authorization: Bearer mx_live_xxx
X-Organization-ID: org_123
```

<Warning>
  The header is `X-Organization-ID` with a capital `ID`. It is **required** on every
  knowledge endpoint except the single unauthenticated info endpoint
  [`GET /knowledge-bases/info/supported-file-types`](#supported-file-types). A missing or
  wrong org context resolves to a `403` (or `404` for cross-org resources). All request
  bodies and responses are snake\_case JSON.
</Warning>

## Step 1 — choose managed or BYOK

The single decision that drives cost is whether the knowledge base is **managed** or
**BYOK** (bring your own key). It is set by `embedding_config.integration_name`:

<CardGroup cols={2}>
  <Card title="Managed (modulexdb)" icon="server">
    Set `integration_name` to `modulexai`. ModuleX provisions the embedding provider and
    stores vectors in its managed store. **Ingest and retrieval are billed in credits.**
    See [modulexdb (managed)](/integrations/knowledge-providers/modulexdb).
  </Card>

  <Card title="BYOK" icon="key">
    Point `embedding_config` at one of your own LLM-provider credentials (for example your
    own OpenAI or Cohere key). **Ingest and retrieval are uncosted** by ModuleX — you pay
    the provider directly. See [Knowledge providers](/integrations/knowledge-providers/overview).
  </Card>
</CardGroup>

<Info>
  Only **managed** knowledge bases (those whose `embedding_config.integration_name` is
  `modulexai`) are metered. BYOK retrieval and ingest consume **no** ModuleX credits — they
  are analytics-only. The credit columns throughout this page apply to managed knowledge
  bases only.
</Info>

If you omit `embedding_config.credential_id` entirely, ModuleX **auto-discovers** an
org credential that exposes an embedding-capable model (it looks for an `llm_provider`
credential whose integration has a model flagged `is_embedding`). If none exists, create
fails — see the errors below.

<Note>
  **Embedding-config key drift.** Two key conventions coexist in the backend and both are
  read defensively. The model-level default uses `provider` + `model_id`; the service
  default uses `integration_name` + `provider_id` + `model_id` + `credential_id`. When you
  write `embedding_config`, the embed code accepts either `provider` or `provider_id`,
  either `model` or `model_id`, and either `provider_credential_id` or `credential_id`.
  Pick one convention and stay consistent. Managed is detected specifically on
  `integration_name == "modulexai"`.
</Note>

## Step 2 — create the knowledge base

`POST /knowledge-bases` creates the base and returns `201 Created`. There is **no credit
gate** on create. On success ModuleX also auto-creates a linked internal credential (the
"native KB = credential" link) so a [knowledge node](/workflow-builder/nodes/knowledge)
can reference the base, and sets `status` to `active`.

### Request fields

<ParamField body="name" type="string" required>
  Display name, 1–255 characters.
</ParamField>

<ParamField body="description" type="string">
  Optional free-text description.
</ParamField>

<ParamField body="embedding_config" type="object">
  Per-knowledge-base embedding settings. Omit it to auto-discover an embedding credential
  and use the defaults below.

  <Expandable title="embedding_config fields">
    <ParamField body="integration_name" type="string">
      Set to `modulexai` for a **managed** knowledge base (billed in credits). Any other
      value, or a `credential_id` pointing at your own provider, makes it **BYOK**.
    </ParamField>

    <ParamField body="provider" type="string" default="openai">
      Embedding provider. Accepted values are `openai`, `cohere`, `azure`, and
      `huggingface`, but only `openai` and `cohere` have a working embed implementation —
      anything else fails ingest with an embedding error. Read as `provider` or `provider_id`.
    </ParamField>

    <ParamField body="model_id" type="string" default="text-embedding-3-small">
      Embedding model. Read as `model` or `model_id`.
    </ParamField>

    <ParamField body="dimension" type="integer" default="1536">
      Vector dimension, 64–4096. The chunk embedding vector defaults to `1536`; keep
      ingest-time and query-time dimensions identical.
    </ParamField>

    <ParamField body="credential_id" type="string">
      The org credential to embed with. Read as `credential_id` or `provider_credential_id`.
      If omitted, ModuleX auto-discovers an embedding-capable credential.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="chunking_config" type="object">
  Per-knowledge-base chunking settings used by the ingest pipeline.

  <Expandable title="chunking_config fields">
    <ParamField body="strategy" type="string" default="recursive">
      One of `recursive`, `token`, or `simple`. `recursive` splits on `separators`;
      `token` uses a `cl100k_base` token window; `simple` is a fixed character window.
    </ParamField>

    <ParamField body="chunk_size" type="integer" default="1000">
      Target chunk size, 100–4000.
    </ParamField>

    <ParamField body="chunk_overlap" type="integer" default="200">
      Overlap between adjacent chunks, 0–500. Must be **at most 50%** of `chunk_size`,
      or validation fails.
    </ParamField>

    <ParamField body="separators" type="array">
      Split points for `recursive`. Default `["\n\n", "\n", " ", ""]`.
    </ParamField>
  </Expandable>
</ParamField>

### Create a knowledge base

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/knowledge-bases \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: org_123" \
    -H "Content-Type: application/json" \
    -d '{
          "name": "Product Docs",
          "description": "Technical documentation",
          "embedding_config": { "integration_name": "modulexai", "model_id": "text-embedding-3-small", "dimension": 1536 },
          "chunking_config": { "strategy": "recursive", "chunk_size": 1000, "chunk_overlap": 200 }
        }'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import AsyncModulex

  client = AsyncModulex(
      api_key="mx_live_xxx",
      organization_id="org_123",
  )

  async def main():
      kb = await client.knowledge.create(
          name="Product Docs",
          description="Technical documentation",
          embedding_config={
              "integration_name": "modulexai",
              "model_id": "text-embedding-3-small",
              "dimension": 1536,
          },
          chunking_config={"strategy": "recursive", "chunk_size": 1000, "chunk_overlap": 200},
      )
      print(kb["id"])

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex-js";

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "org_123",
  });

  const kb = await client.knowledge.create({
    name: "Product Docs",
    description: "Technical documentation",
    embeddingConfig: { integration_name: "modulexai", model_id: "text-embedding-3-small", dimension: 1536 },
    chunkingConfig: { strategy: "recursive", chunk_size: 1000, chunk_overlap: 200 },
  });

  console.log(kb.id);
  ```
</CodeGroup>

<Note>
  The JS SDK takes camelCase method arguments but returns snake\_case fields (so the response
  `id`/`created_at` stay snake\_case); the Python SDK is snake\_case both ways. Nested config
  keys like `integration_name` and `model_id` are already snake\_case on both sides.
</Note>

### Response (`201 Created`)

<ResponseField name="id" type="string">The knowledge-base UUID.</ResponseField>
<ResponseField name="organization_id" type="string">Owning organization.</ResponseField>
<ResponseField name="created_by_user_id" type="string">Creator user id.</ResponseField>
<ResponseField name="credential_id" type="string">The auto-created internal credential that links this base to workflow nodes.</ResponseField>
<ResponseField name="name" type="string">The display name.</ResponseField>
<ResponseField name="description" type="string">The description, or `null`.</ResponseField>
<ResponseField name="embedding_config" type="object">The resolved embedding settings.</ResponseField>
<ResponseField name="chunking_config" type="object">The resolved chunking settings.</ResponseField>
<ResponseField name="status" type="string">One of `active`, `processing`, `error`, `archived`. New bases are `active`.</ResponseField>
<ResponseField name="document_count" type="integer">Document count (rolled up).</ResponseField>
<ResponseField name="total_chunks" type="integer">Chunk count across documents.</ResponseField>
<ResponseField name="total_tokens" type="integer">Token count across documents.</ResponseField>
<ResponseField name="created_at" type="string">Creation timestamp.</ResponseField>
<ResponseField name="updated_at" type="string">Last-update timestamp.</ResponseField>

```json Example response theme={null}
{
  "id": "7c1e...",
  "organization_id": "org_123",
  "created_by_user_id": "user_456",
  "credential_id": "a1b2...",
  "name": "Product Docs",
  "description": "Technical documentation",
  "embedding_config": { "integration_name": "modulexai", "provider_id": "openai", "model_id": "text-embedding-3-small", "credential_id": "a1b2...", "dimension": 1536 },
  "chunking_config": { "strategy": "recursive", "chunk_size": 1000, "chunk_overlap": 200, "separators": ["\n\n", "\n", " ", ""] },
  "document_count": 0,
  "total_chunks": 0,
  "total_tokens": 0,
  "status": "active",
  "created_at": "2026-06-20T10:00:00",
  "updated_at": "2026-06-20T10:00:00"
}
```

### Create errors

| Status | Condition                                                                                                                                      |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | No embedding credential could be found or auto-discovered; or config validation failed (for example `chunk_overlap` over 50% of `chunk_size`). |
| `403`  | Caller is not owner/admin, **or** the plan's knowledge-base quota is exhausted (`KnowledgeBaseQuotaExceededError`).                            |
| `422`  | Pydantic validation (for example `name` outside 1–255 characters).                                                                             |
| `500`  | Unexpected service error.                                                                                                                      |

<Note>
  The knowledge-base count quota is a **plan entitlement** (`max_knowledge_bases`):
  unlimited for admin and Enterprise, Max 50, Pro 3, Free uses the config default. Quota
  counts **non-archived** bases — archiving a base frees a slot. Quota and storage denials
  use the plain `403` `{"detail": ...}` shape, **not** the billing
  [`DenialEnvelope`](/api-reference/errors).
</Note>

<MediaEmbed id="MX-MEDIA-4460" type="image" caption={"A diagram of the knowledge-base lifecycle: create, ingest, query."} />

## Step 3 — ingest documents

Upload a file with `POST /knowledge-bases/{knowledge_base_id}/documents` as
`multipart/form-data`. The upload returns `201` with the document in `pending` status; a
background worker then runs the parse-chunk-embed pipeline asynchronously. You poll the
[document status](#poll-document-status) until it reaches `completed` or `failed`.

### Form parts

<ParamField body="file" type="file" required>
  The document to ingest. Supported types: `pdf`, `docx`, `doc`, `txt`, `md`, `html`,
  `csv`, `json`, `xlsx`, `pptx`. The type is resolved from the extension first, then the
  MIME type.
</ParamField>

<ParamField body="metadata" type="string">
  Optional JSON **string** of arbitrary metadata. Invalid JSON returns `400` with
  `{"detail": "Invalid metadata JSON"}`.
</ParamField>

<Warning>
  **Per-file size cap is a plan entitlement, not a fixed 50 MB.** The static info endpoint
  advertises 50 MB, but the actually enforced per-file cap is your plan's storage
  entitlement: Free 10 MB, Pro 50 MB, Max 100 MB, Enterprise unlimited. Plan-storage and
  per-base document-count breaches return `403` `{"detail": ...}` (not the billing
  envelope). The hard 50 MB constant is only a fallback when the plan does not meter file
  size. A base also accepts at most 500 documents.
</Warning>

<Info>
  **Deduplication.** ModuleX computes a content hash of the file. Uploading the same content to
  the same base again returns `400` (`Duplicate file...`). To re-ingest changed content,
  delete the existing document first.
</Info>

### Upload a document

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/knowledge-bases/7c1e.../documents \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: org_123" \
    -F "file=@manual.pdf" \
    -F 'metadata={"tags":["product"]}'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import AsyncModulex

  client = AsyncModulex(api_key="mx_live_xxx", organization_id="org_123")

  async def main():
      with open("manual.pdf", "rb") as f:
          doc = await client.knowledge.upload_document(
              knowledge_base_id="7c1e...",
              file=f,
              metadata={"tags": ["product"]},
          )
      print(doc["id"], doc["status"])  # -> "pending"

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { readFileSync } from "node:fs";
  import { Modulex } from "modulex-js";

  const client = new Modulex({ apiKey: "mx_live_xxx", organizationId: "org_123" });

  const doc = await client.knowledge.uploadDocument("7c1e...", {
    file: new Blob([readFileSync("manual.pdf")]),
    metadata: { tags: ["product"] },
  });

  console.log(doc.id, doc.status); // -> "pending"
  ```
</CodeGroup>

### Upload response (`201 Created`)

The upload endpoint returns the router `DocumentResponse` shape:

<ResponseField name="id" type="string">Document UUID.</ResponseField>
<ResponseField name="knowledge_base_id" type="string">Parent base.</ResponseField>
<ResponseField name="filename" type="string">Original filename.</ResponseField>
<ResponseField name="file_type" type="string">Resolved type (for example `pdf`).</ResponseField>
<ResponseField name="file_size_bytes" type="integer">File size in bytes.</ResponseField>
<ResponseField name="status" type="string">Lifecycle status; `pending` immediately after upload.</ResponseField>
<ResponseField name="chunk_count" type="integer">Chunks produced; `0` until ingest completes.</ResponseField>
<ResponseField name="token_count" type="integer">Tokens counted; `0` until ingest completes.</ResponseField>
<ResponseField name="error_message" type="string">Failure reason, or `null`.</ResponseField>
<ResponseField name="created_at" type="string">Upload timestamp.</ResponseField>

```json Example response theme={null}
{
  "id": "d1...",
  "knowledge_base_id": "7c1e...",
  "filename": "manual.pdf",
  "file_type": "pdf",
  "file_size_bytes": 820341,
  "status": "pending",
  "chunk_count": 0,
  "token_count": 0,
  "error_message": null,
  "created_at": "2026-06-20T12:00:00"
}
```

### What the worker does

The background `ingest_document` task (retried up to 3 times) drives the document from
`pending` to a terminal state:

<Steps>
  <Step title="Parse">
    Decodes text formats; uses `pypdf` for PDF, `python-docx` for Word, and the
    `unstructured` library for HTML and unknown types.
  </Step>

  <Step title="Chunk">
    Splits text per `chunking_config`, recording `token_count`, `start_char`, and
    `end_char` for each chunk.
  </Step>

  <Step title="Embed">
    Generates embeddings with OpenAI or Cohere. A **managed** base routes through the same
    underlying model used at query time, so ingest-time and search-time vectors match.
  </Step>

  <Step title="Persist">
    Writes chunk rows with their vectors, sets the document `completed`, and updates
    `chunk_count` and `token_count`. On any exception the document is set `failed` with an
    `error_message` and the task retries.
  </Step>
</Steps>

The document status lifecycle is `pending → processing → completed | failed`, and a
`failed` document can be returned to `pending` with [retry](#retry-a-failed-document).

### Poll document status

`GET /knowledge-bases/{kb_id}/documents/{document_id}/status` returns a status dict whose
extra fields vary by state. Poll it until `status` is `completed` or `failed`.

```json Example response (completed) theme={null}
{
  "document_id": "d1...",
  "status": "completed",
  "filename": "manual.pdf",
  "file_type": "pdf",
  "message": "Processing completed successfully",
  "processing_completed_at": "2026-06-20T12:01:30",
  "chunk_count": 42,
  "token_count": 18000
}
```

<Accordion title="Status-by-status fields">
  * `pending` — `message: "Waiting to be processed"`.
  * `processing` — adds `processing_started_at`.
  * `completed` — adds `processing_completed_at`, `chunk_count`, `token_count`.
  * `failed` — adds `error` with the failure reason.
</Accordion>

### Retry a failed document

`POST /knowledge-bases/{kb_id}/documents/{document_id}/retry` re-enqueues ingest. It is
valid **only** when the document status is `failed` (otherwise `400`); it resets the
document to `pending` and clears the error and timestamps. There is **no credit gate** on
retry — the per-chunk embedding cost is keyed on `{doc_id}:embedding:{token_count}`, so an
unchanged reprocess does not re-charge.

### Other document operations

| Operation         | Endpoint                                                      | Notes                                                          |
| ----------------- | ------------------------------------------------------------- | -------------------------------------------------------------- |
| List documents    | `GET /knowledge-bases/{kb_id}/documents`                      | `status`, `limit` (1–500, default 100), `offset` query params. |
| Get a document    | `GET /knowledge-bases/{kb_id}/documents/{document_id}`        | Wrong-org access returns `404`, not `403`.                     |
| List chunks       | `GET /knowledge-bases/{kb_id}/documents/{document_id}/chunks` | `limit`/`offset`; returns `{"chunks": [...], "count": N}`.     |
| Delete a document | `DELETE /knowledge-bases/{kb_id}/documents/{document_id}`     | `delete_file` query (default `true`). Returns `204`.           |

### Ingest errors

| Status        | Condition                                                                                                                                       |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`         | Empty file, duplicate (same hash in same base), unsupported type, or bad `metadata` JSON.                                                       |
| `403`         | Caller not owner/admin, plan storage cap exceeded, or per-base document cap (500) reached.                                                      |
| `402` / `429` | **Managed only** — the ingest credit gate denied the upload (see [Costs](#step-5-costs)). Returns the `DenialEnvelope`; no document is created. |
| `422`         | Missing the `file` part.                                                                                                                        |
| `500`         | Unexpected service error.                                                                                                                       |

## Step 4 — query the knowledge base

ModuleX offers four retrieval modes. All reserve **1 retrieval credit on a managed base**
(see [Costs](#step-5-costs)) and accept the same auth headers. Pick the mode that fits:

<CardGroup cols={2}>
  <Card title="Vector search" icon="magnifying-glass">
    `POST /knowledge-bases/{kb_id}/search` — semantic (cosine) similarity over one base.
  </Card>

  <Card title="Multi-base search" icon="layer-group">
    `POST /knowledge-bases/search` — the same query across several bases, merged by score.
  </Card>

  <Card title="Hybrid search" icon="scale-balanced">
    `POST /knowledge-bases/{kb_id}/hybrid-search` — vector plus keyword full-text ranking.
  </Card>

  <Card title="Retrieve context" icon="file-lines">
    `POST /knowledge-bases/{kb_id}/retrieve-context` — a single token-budgeted RAG context string.
  </Card>
</CardGroup>

### Vector search

<ParamField body="query" type="string" required>The search text (minimum length 1).</ParamField>
<ParamField body="top_k" type="integer" default="5">Number of results, 1–50.</ParamField>
<ParamField body="min_score" type="number" default="0.0">Cosine-similarity floor, 0.0–1.0. Computed as `1 - distance`.</ParamField>
<ParamField body="filters" type="object">Optional filter; supports `document_id` or `document_ids` to scope to specific documents.</ParamField>
<ParamField body="include_content" type="boolean" default="true">Return chunk text in each match.</ParamField>
<ParamField body="include_metadata" type="boolean" default="true">Return chunk metadata in each match.</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/knowledge-bases/7c1e.../search \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: org_123" \
    -H "Content-Type: application/json" \
    -d '{ "query": "how to install", "top_k": 5, "min_score": 0.2 }'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import AsyncModulex

  client = AsyncModulex(api_key="mx_live_xxx", organization_id="org_123")

  async def main():
      result = await client.knowledge.search(
          knowledge_base_id="7c1e...",
          query="how to install",
          top_k=5,
          min_score=0.2,
      )
      for match in result["matches"]:
          print(match["score"], match["document_filename"])

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex-js";

  const client = new Modulex({ apiKey: "mx_live_xxx", organizationId: "org_123" });

  const result = await client.knowledge.search("7c1e...", {
    query: "how to install",
    top_k: 5,
    min_score: 0.2,
  });

  for (const match of result.matches) {
    console.log(match.score, match.document_filename);
  }
  ```
</CodeGroup>

<ResponseField name="query" type="string">The query you sent.</ResponseField>
<ResponseField name="knowledge_base_id" type="string">The base searched.</ResponseField>
<ResponseField name="top_k" type="integer">The requested result count.</ResponseField>
<ResponseField name="total_matches" type="integer">Number of matches returned.</ResponseField>

<ResponseField name="matches" type="array">
  Ranked matches, highest score first.

  <Expandable title="match object">
    <ResponseField name="chunk_id" type="string">Chunk UUID.</ResponseField>
    <ResponseField name="document_id" type="string">Source document UUID.</ResponseField>
    <ResponseField name="document_filename" type="string">Source filename.</ResponseField>
    <ResponseField name="chunk_index" type="integer">Position of the chunk in its document.</ResponseField>
    <ResponseField name="score" type="number">Cosine similarity, 0.0–1.0.</ResponseField>
    <ResponseField name="content" type="string">Chunk text (when `include_content` is true).</ResponseField>
    <ResponseField name="metadata" type="object">Chunk metadata (when `include_metadata` is true).</ResponseField>
  </Expandable>
</ResponseField>

```json Example response theme={null}
{
  "query": "how to install",
  "knowledge_base_id": "7c1e...",
  "top_k": 5,
  "total_matches": 3,
  "matches": [
    {
      "chunk_id": "c1...",
      "document_id": "d1...",
      "document_filename": "manual.pdf",
      "chunk_index": 4,
      "score": 0.83,
      "content": "To install, run...",
      "metadata": {}
    }
  ]
}
```

### Multi-base search

`POST /knowledge-bases/search` runs the query across several bases and merges results by
score. It takes `knowledge_base_ids` (array of UUIDs, required), `query` (required),
`top_k` (1–50, default 5), and `min_score` (0.0–1.0, default 0.0). Bases your org cannot
access are silently skipped. The response is
`{ "query", "knowledge_bases_searched", "top_k", "total_matches", "matches" }`.

<Note>
  Billing reserves **one** retrieval credit if **any** queried base is managed (regardless
  of ordering). One known in-code limitation: the per-base query-embedding token cost is
  recorded for the **last** searched base only.
</Note>

### Hybrid search

`POST /knowledge-bases/{kb_id}/hybrid-search` blends semantic similarity with keyword
full-text ranking.

<ParamField body="query" type="string" required>The search text.</ParamField>
<ParamField body="top_k" type="integer" default="5">Number of results, 1–50.</ParamField>
<ParamField body="keyword_weight" type="number" default="0.3">Weight of the keyword score, 0.0–1.0.</ParamField>
<ParamField body="semantic_weight" type="number" default="0.7">Weight of the semantic score, 0.0–1.0.</ParamField>
<ParamField body="min_score" type="number" default="0.0">Combined weighted-score floor, 0.0–1.0.</ParamField>
<ParamField body="filters" type="object">Optional document filter, as in vector search.</ParamField>

Each match adds `semantic_score` and `keyword_score` alongside the combined `score`, and
the response echoes `search_type: "hybrid"` and the `weights` you used.

<Warning>
  `min_score` is **not the same scale** across modes. In vector search it is a cosine-
  similarity floor; in hybrid search it is a floor on the **combined weighted** score. Tune
  it per mode.
</Warning>

### Retrieve context (for RAG)

`POST /knowledge-bases/{kb_id}/retrieve-context` returns a single ready-to-prompt string
instead of a match array — ideal for feeding an LLM.

<ParamField body="query" type="string" required>The search text.</ParamField>
<ParamField body="max_tokens" type="integer" default="2000">Token budget for the assembled context, 100–10000.</ParamField>
<ParamField body="top_k" type="integer" default="10">Chunks to consider, 1–50.</ParamField>
<ParamField body="min_score" type="number" default="0.3">Cosine-similarity floor, 0.0–1.0.</ParamField>

It runs the same vector search, then concatenates chunks within the token budget, each
prefixed with a `[Source: <filename>, Chunk <n>]` header and joined by a separator. The
response is `{ "context": "<string>", "query": "<query>" }`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/knowledge-bases/7c1e.../retrieve-context \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: org_123" \
    -H "Content-Type: application/json" \
    -d '{ "query": "how to install", "max_tokens": 2000, "top_k": 10, "min_score": 0.3 }'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import AsyncModulex

  client = AsyncModulex(api_key="mx_live_xxx", organization_id="org_123")

  async def main():
      result = await client.knowledge.retrieve_context(
          knowledge_base_id="7c1e...",
          query="how to install",
          max_tokens=2000,
          top_k=10,
          min_score=0.3,
      )
      print(result["context"])

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex-js";

  const client = new Modulex({ apiKey: "mx_live_xxx", organizationId: "org_123" });

  const result = await client.knowledge.retrieveContext("7c1e...", {
    query: "how to install",
    max_tokens: 2000,
    top_k: 10,
    min_score: 0.3,
  });

  console.log(result.context);
  ```
</CodeGroup>

### Query from a workflow or agent

Inside a workflow, a [knowledge node](/workflow-builder/nodes/knowledge) retrieves from a
base by its linked credential and returns `chunks`, `context`, or `both` (set by the
node's `output_format`). The Assistant retrieves through its own `search_knowledge` tool.
Both reuse the same managed-billing path as the REST endpoints, so the credit costs below
apply identically.

### Search errors

| Status        | Condition                                                                                   |
| ------------- | ------------------------------------------------------------------------------------------- |
| `403`         | Caller not owner/admin, or the base belongs to another org (`SearchAccessDeniedError`).     |
| `402` / `429` | **Managed only** — the retrieval credit gate denied the call. Returns the `DenialEnvelope`. |
| `422`         | Invalid body (for example empty `query`, or `top_k` out of range).                          |
| `500`         | Embedding error (for example an unsupported provider) or search error.                      |

## Step 5 — costs

Cost depends entirely on the managed-vs-BYOK choice from
[Step 1](#step-1-choose-managed-or-byok). For the full credit model, see
[Credits & metering](/billing/credits); for how the gate denies calls, see
[Usage gating & limits](/billing/usage-gating).

| Operation                                     | Managed (`modulexai`) base                                                                                             | BYOK base |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------- |
| Create knowledge base                         | Free (no credit gate)                                                                                                  | Free      |
| Document ingest (per document)                | **1 credit** base, reserved before the file is written, plus the per-chunk embedding token cost recorded by the worker | Uncosted  |
| Vector / hybrid / retrieve-context (per call) | **1 credit** base, reserved before the embed, plus the query-embedding token cost                                      | Uncosted  |
| Multi-base search (per call)                  | **1 credit** if any queried base is managed                                                                            | Uncosted  |
| Retry a failed document                       | No base charge; embedding cost not re-charged for unchanged content                                                    | Uncosted  |

A credit is the managed-usage unit: **100 credits = \$1.00** (1 credit = \$0.01). The
gate works **reserve → record → release**: it reserves the credit before doing the work
and records it on success, so an exhausted balance is rejected *before* any document is
written or any embedding is generated.

<Warning>
  On a **managed** base, ingest and retrieval flow through the live billing gate. When the
  balance is exhausted (or rate-limited, or the plan quota is hit), the call is denied with
  the flat `DenialEnvelope` — `{code, layer, key, current, limit, reason}` — at **402**
  (credit/wallet), **403** (quota), or **429** (rate). This is **not** the
  `{"detail": ...}` shape used by CRUD and access errors. A `429` also carries `Retry-After`
  and `X-RateLimit-*` headers. See [Errors & status codes](/api-reference/errors) for all
  three error-envelope shapes.
</Warning>

<Info>
  **Knowledge-base count quota vs storage cap** are plan **entitlements**, enforced
  separately from credits and returned as `403` `{"detail": ...}` — not as the billing
  envelope. Counts and caps are described under [Step 2](#step-2-create-the-knowledge-base)
  and [Step 3](#step-3-ingest-documents).
</Info>

## Manage and clean up

| Operation             | Endpoint                                | Notes                                                                                                                      |
| --------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| List bases            | `GET /knowledge-bases`                  | `status`, `limit`, `offset` query params. The default unfiltered listing is cached.                                        |
| Get one base          | `GET /knowledge-bases/{kb_id}`          | Includes a `stats` object with a `documents_by_status` breakdown.                                                          |
| Org-wide stats        | `GET /knowledge-bases/stats`            | Returns base, document, chunk, token, and total-size counts.                                                               |
| Update a base         | `PUT /knowledge-bases/{kb_id}`          | All fields optional; configs are merged then re-validated.                                                                 |
| Archive (soft delete) | `POST /knowledge-bases/{kb_id}/archive` | Sets `status` to `archived` and frees a count-quota slot.                                                                  |
| Delete (hard)         | `DELETE /knowledge-bases/{kb_id}`       | `delete_files` query (default `true`). Also deletes chunks, documents, the base, and its linked credential. Returns `204`. |

<Note id="supported-file-types">
  **Supported file types — no auth.** `GET /knowledge-bases/info/supported-file-types` is the
  one knowledge endpoint with no authentication. It returns the 10 supported types and the
  fallback `max_file_size_bytes` (52428800) / `max_file_size_mb` (50.0). Remember that the
  **enforced** per-file cap is your plan entitlement, not this advertised 50 MB.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Knowledge & RAG" icon="book-open" href="/concepts/knowledge-rag">
    The conceptual model: knowledge bases, managed vs BYOK, ingest and retrieval.
  </Card>

  <Card title="Knowledge overview" icon="folder" href="/platform/knowledge/overview">
    Manage knowledge bases, documents, and processing in the app.
  </Card>

  <Card title="Knowledge node" icon="diagram-project" href="/workflow-builder/nodes/knowledge">
    Retrieve from a base inside a workflow.
  </Card>

  <Card title="Credits & metering" icon="coins" href="/billing/credits">
    Exactly what consumes credits, and the reserve-record-release lifecycle.
  </Card>
</CardGroup>
