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

# Knowledge base

> Treat everything you've synced as one searchable corpus and answer questions from it.

Once a few plugins are connected, `corsair_entities` holds your Slack messages, GitHub issues, Notion pages, and Gmail threads in one table with one shape. That's a corpus you can search.

Ask "what's happening with the Peterson report?" and you can look across every synced service at once, then hand the matches to a model as grounding, instead of calling six search APIs and reconciling six response formats.

**Pattern:** search `.db` across entity types, rank in your code, then feed matches to the model as context.

## Searching one entity type

Use the typed client when you know where to look. String fields support `contains`, which compiles to a SQL `LIKE '%value%'`:

```ts theme={null}
const tenant = corsair.withTenant('acme');

const issues = await tenant.github.db.issues.search({
    data: { title: { contains: 'peterson' } },
    limit: 25,
});
```

Filters combine with AND, so you can narrow by more than text:

```ts theme={null}
const messages = await tenant.slack.db.messages.search({
    data: {
        text: { contains: 'peterson' },
        channel: 'C01234567',
    },
    limit: 50,
});
```

## Searching across services

The typed clients are scoped to one entity type at a time. To sweep the whole corpus, fan out and merge:

```ts src/server/search.ts theme={null}
export async function searchEverything(query: string) {
    const tenant = corsair.withTenant('acme');

    const [issues, comments, messages, pages] = await Promise.all([
        tenant.github.db.issues.search({ data: { title: { contains: query } }, limit: 25 }),
        tenant.github.db.comments.search({ data: { body: { contains: query } }, limit: 25 }),
        tenant.slack.db.messages.search({ data: { text: { contains: query } }, limit: 25 }),
        tenant.notion.db.pages.search({ data: { title: { contains: query } }, limit: 25 }),
    ]);

    return [
        ...issues.map((r) => ({ source: 'github-issue', text: r.data.title, url: r.data.htmlUrl, at: r.updatedAt })),
        ...comments.map((r) => ({ source: 'github-comment', text: r.data.body, url: r.data.htmlUrl, at: r.updatedAt })),
        ...messages.map((r) => ({ source: 'slack', text: r.data.text, url: undefined, at: r.updatedAt })),
        ...pages.map((r) => ({ source: 'notion', text: r.data.title, url: r.data.url, at: r.updatedAt })),
    ].sort((a, b) => (b.at?.getTime() ?? 0) - (a.at?.getTime() ?? 0));
}
```

For a genuinely open-ended sweep, query `corsair_entities` directly with your own ORM. It's one table, so a single statement covers every service and entity type at once, which is useful when you don't want to enumerate the plugins up front:

```sql theme={null}
SELECT entity_type, entity_id, data, updated_at
FROM corsair_entities
WHERE account_id = ANY($1) -- this tenant's account ids
  AND data::text ILIKE '%peterson report%'
ORDER BY updated_at DESC
LIMIT 50;
```

Scope that query to the tenant's `account_id` values. The typed clients do this for you; raw SQL does not.

## Grounding a model

Retrieval is the whole trick. Pass matches in as context and require citations back to the source URL:

```ts theme={null}
const matches = await searchEverything('peterson report');

const { text } = await generateText({
    model: anthropic('claude-sonnet-4-6'),
    prompt: [
        'Answer using only the context below. Cite the source URL for each claim.',
        '',
        ...matches.slice(0, 30).map((m) => `[${m.source}] ${m.url ?? ''}\n${m.text}`),
        '',
        'Question: what is happening with the Peterson report?',
    ].join('\n'),
});
```

Because rows are kept current by API calls and webhooks, answers reflect the present state rather than whenever you last built an index.

## Filling the corpus

Search only finds what's been synced. Two ways rows get there:

1. **Backfill.** Call `.api` list endpoints once per source. Every response is upserted, so a paginated crawl populates the table.
2. **Stay fresh.** Register webhooks so upstream changes update the same rows in place. See [Webhooks](/concepts/webhooks).

```ts theme={null}
// backfill, then let webhooks maintain it
for (let page = 1; ; page++) {
    const batch = await tenant.slack.api.messages.list({ channel, limit: 200, page });
    if (batch.length < 200) break;
}
```

## Know the limits

<Warning>
  `contains` is substring matching, not semantic search. It won't match synonyms, handle typos, or rank by relevance.
</Warning>

That's fine for names, IDs, and known phrases. For conceptual questions, keep embeddings in your own table keyed to `corsair_entities.id`, and use Corsair search to fetch the current text for whatever your vector query returns. The row your embedding points at keeps updating itself, so you re-embed on change rather than rebuilding from scratch.

One more constraint. `search` filters top-level `data` fields only, and has no sort option. Order results in your code, or use SQL.

## Checklist

* Reads go through `.db`, never `.api`, so questions don't burn rate limit.
* Raw `corsair_entities` queries are filtered to the tenant's accounts.
* Answers cite source URLs from the entity data.
* The corpus is backfilled once and maintained by webhooks.
* Semantic needs are handled with embeddings alongside, not by `contains`.

## What's next

<CardGroup cols={2}>
  <Card title="Database" href="/concepts/database">
    The entity table, and how to join it to your own.
  </Card>

  <Card title="Give it to an agent" href="/use-cases/agents">
    Let the model search and then act on what it finds.
  </Card>

  <Card title="Webhooks" href="/concepts/webhooks">
    Keep the corpus current without polling.
  </Card>

  <Card title="Multi-tenancy" href="/concepts/multi-tenancy">
    Keep each customer's corpus isolated.
  </Card>
</CardGroup>
