feat: move mixedbread search to server-side (#2980)

* feat: move mixedbread search to server-side

* style: format code

* chore: add changeset

* style: format code

* fix: support multiple tags in Mixedbread search filter

* fix: handle empty array tags in Mixedbread search filter

* update changeset

---------

Co-authored-by: Fuma Nama <76240755+fuma-nama@users.noreply.github.com>
This commit is contained in:
Naing Linn Khant
2026-02-10 22:47:18 +08:00
committed by GitHub
co-authored by Fuma Nama
parent 7872e27faa
commit 1ad8a389bb
7 changed files with 218 additions and 18 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support server-side Mixedbread search API, deprecate client-side adapter
@@ -7,7 +7,7 @@ description: Using Mixedbread with Fumadocs UI.
1. Integrate [Mixedbread Search](/docs/headless/search/mixedbread).
2. Create a search dialog, update `apiKey` and `storeIdentifier` with your desired values.
2. Create a search dialog component.
<include meta='title="components/search.tsx"'>./mixedbread.tsx</include>
@@ -14,19 +14,12 @@ import {
} from 'fumadocs-ui/components/dialog/search';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { useI18n } from 'fumadocs-ui/contexts/i18n';
import Mixedbread from '@mixedbread/sdk';
const client = new Mixedbread({
apiKey: 'mxb_xxxx',
baseURL: 'https://api.example.com', // Optional, defaults to https://api.mixedbread.com
});
export default function CustomSearchDialog(props: SharedProps) {
const { locale } = useI18n(); // (optional) for i18n
const { search, setSearch, query } = useDocsSearch({
type: 'mixedbread',
client,
storeIdentifier: 'your_store_identifier',
type: 'fetch',
api: '/api/search',
locale,
});
@@ -57,6 +57,24 @@ You can automatically sync your documentation by adding a sync script to your `p
}
```
### Search API
Create an API route to handle search requests server-side:
```ts title="app/api/search/route.ts"
import { createMixedbreadSearchAPI } from 'fumadocs-core/search/mixedbread';
import Mixedbread from '@mixedbread/sdk';
const client = new Mixedbread({
apiKey: 'YOUR_API_KEY',
});
export const { GET } = createMixedbreadSearchAPI({
client,
storeIdentifier: 'YOUR_STORE_ID',
});
```
### Search Client
- **Fumadocs UI**: see [Search UI](/docs/search/mixedbread) for details.
@@ -65,13 +83,9 @@ You can automatically sync your documentation by adding a sync script to your `p
```ts
import { useDocsSearch } from 'fumadocs-core/search/client';
const mxbai = new Mixedbread({
apiKey: 'YOUR_API_KEY',
});
const client = useDocsSearch({
type: 'mixedbread',
client: mxbai,
type: 'fetch',
api: '/api/search',
});
```
@@ -93,13 +107,15 @@ tag: docs
And update your search client:
- **Fumadocs UI**: Enable [Tag Filter](/docs/search/orama#tag-filter) on Search UI.
- **Fumadocs UI**: Enable [Tag Filter](/docs/search/mixedbread#tag-filter) on Search UI.
- **Search Client**: You can add the tag filter like:
```ts
import { useDocsSearch } from 'fumadocs-core/search/client';
const { search, setSearch, query } = useDocsSearch({
type: 'fetch',
api: '/api/search',
tag: '<your tag value>',
// ...
});
@@ -4,6 +4,11 @@ import removeMd from 'remove-markdown';
import Slugger from 'github-slugger';
import type { StoreSearchResponse } from '@mixedbread/sdk/resources/stores';
/**
* @deprecated Use `createMixedbreadSearchAPI` from `fumadocs-core/search/mixedbread` instead.
* This client-side approach exposes your API key in the browser.
* The server-side approach keeps the key secure and uses `type: 'fetch'` on the client.
*/
export interface MixedbreadOptions {
/**
* The identifier of the store to search in
@@ -59,6 +64,11 @@ function extractHeadingTitle(text: string): string {
return '';
}
/**
* @deprecated Use `createMixedbreadSearchAPI` from `fumadocs-core/search/mixedbread` instead.
* This client-side approach exposes your API key in the browser.
* The server-side approach keeps the key secure and uses `type: 'fetch'` on the client.
*/
export async function search(query: string, options: MixedbreadOptions): Promise<SortedResult[]> {
const { client, storeIdentifier, tag } = options;
+176
View File
@@ -0,0 +1,176 @@
import type { SortedResult } from '@/search';
import type Mixedbread from '@mixedbread/sdk';
import type { StoreSearchParams, StoreSearchResponse } from '@mixedbread/sdk/resources/stores';
import removeMd from 'remove-markdown';
import Slugger from 'github-slugger';
import { createEndpoint } from '@/search/orama/create-endpoint';
import type { SearchAPI } from '@/search/server';
export interface SearchMetadata {
title?: string;
description?: string;
url?: string;
tag?: string;
}
type StoreSearchResult = StoreSearchResponse['data'][number] & {
generated_metadata: SearchMetadata;
};
export interface MixedbreadSearchOptions {
/**
* The Mixedbread SDK client instance
*/
client: Mixedbread;
/**
* The identifier of the store to search in
*/
storeIdentifier: string;
/**
* Maximum number of results to return
*
* @defaultValue 10
*/
topK?: number;
/**
* Re-rank search results for improved relevance
*
* @defaultValue true
*/
rerank?: boolean;
/**
* Rewrite the query for better search results
*/
rewriteQuery?: boolean;
/**
* Minimum score threshold for results
*/
scoreThreshold?: number;
/**
* Custom transform function for search results
*/
transform?: (results: StoreSearchResult[], query: string) => SortedResult[];
}
const slugger = new Slugger();
function extractHeadingTitle(text: string): string {
const trimmedText = text.trim();
if (!trimmedText.startsWith('#')) {
return '';
}
const lines = trimmedText.split('\n');
const firstLine = lines[0]?.trim();
if (firstLine) {
return removeMd(firstLine, {
useImgAltText: false,
});
}
return '';
}
function defaultTransform(results: StoreSearchResult[]): SortedResult[] {
return results.flatMap((item) => {
const metadata = item.generated_metadata;
const url = metadata.url || '#';
const title = metadata.title || 'Untitled';
const chunkResults: SortedResult[] = [
{
id: `${item.file_id}-${item.chunk_index}-page`,
type: 'page',
content: title,
url,
},
];
const headingTitle = item.type === 'text' ? extractHeadingTitle(item.text) : '';
if (headingTitle) {
slugger.reset();
chunkResults.push({
id: `${item.file_id}-${item.chunk_index}-heading`,
type: 'heading',
content: headingTitle,
url: `${url}#${slugger.slug(headingTitle)}`,
});
}
return chunkResults;
});
}
export function createMixedbreadSearchAPI(options: MixedbreadSearchOptions): SearchAPI {
const {
client,
storeIdentifier,
topK = 10,
rerank = true,
rewriteQuery,
scoreThreshold,
transform,
} = options;
return createEndpoint({
async search(query, searchOptions) {
if (!query.trim()) {
return [];
}
const tag = searchOptions?.tag;
let filters: StoreSearchParams['filters'] | undefined;
if (Array.isArray(tag) && tag.length > 0) {
filters = {
key: 'generated_metadata.tag',
operator: 'in',
value: tag,
};
} else if (typeof tag === 'string') {
filters = {
key: 'generated_metadata.tag',
operator: 'eq',
value: tag,
};
}
const res = await client.stores.search({
query,
store_identifiers: [storeIdentifier],
top_k: topK,
filters,
search_options: {
return_metadata: true,
rerank,
rewrite_query: rewriteQuery,
score_threshold: scoreThreshold,
},
});
const results = res.data as StoreSearchResult[];
if (transform) {
return transform(results, query);
}
return defaultTransform(results);
},
async export() {
throw new Error(
'Mixedbread search does not support exporting indexes. Use the Mixedbread dashboard to manage your store.',
);
},
});
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineConfig({
'src/source/{index,schema}.ts',
'src/source/client/*.{ts,tsx}',
'src/source/plugins/{lucide-icons,slugs,status-badges}.{ts,tsx}',
'src/search/{index,client,server,algolia,orama-cloud,orama-cloud-legacy}.ts',
'src/search/{index,client,server,algolia,orama-cloud,orama-cloud-legacy,mixedbread}.ts',
'src/utils/use-on-change.ts',
'src/utils/use-media-query.ts',
'src/i18n/*.ts',