Skip to content

Commit 1ad8a38

Browse files
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>
1 parent 7872e27 commit 1ad8a38

File tree

7 files changed

+218
-18
lines changed

7 files changed

+218
-18
lines changed

.changeset/five-hotels-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'fumadocs-core': patch
3+
---
4+
5+
Support server-side Mixedbread search API, deprecate client-side adapter

apps/docs/content/docs/(framework)/search/mixedbread.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: Using Mixedbread with Fumadocs UI.
77

88
1. Integrate [Mixedbread Search](/docs/headless/search/mixedbread).
99

10-
2. Create a search dialog, update `apiKey` and `storeIdentifier` with your desired values.
10+
2. Create a search dialog component.
1111

1212
<include meta='title="components/search.tsx"'>./mixedbread.tsx</include>
1313

apps/docs/content/docs/(framework)/search/mixedbread.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,12 @@ import {
1414
} from 'fumadocs-ui/components/dialog/search';
1515
import { useDocsSearch } from 'fumadocs-core/search/client';
1616
import { useI18n } from 'fumadocs-ui/contexts/i18n';
17-
import Mixedbread from '@mixedbread/sdk';
18-
19-
const client = new Mixedbread({
20-
apiKey: 'mxb_xxxx',
21-
baseURL: 'https://api.example.com', // Optional, defaults to https://api.mixedbread.com
22-
});
2317

2418
export default function CustomSearchDialog(props: SharedProps) {
2519
const { locale } = useI18n(); // (optional) for i18n
2620
const { search, setSearch, query } = useDocsSearch({
27-
type: 'mixedbread',
28-
client,
29-
storeIdentifier: 'your_store_identifier',
21+
type: 'fetch',
22+
api: '/api/search',
3023
locale,
3124
});
3225

apps/docs/content/docs/headless/search/mixedbread.mdx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ You can automatically sync your documentation by adding a sync script to your `p
5757
}
5858
```
5959

60+
### Search API
61+
62+
Create an API route to handle search requests server-side:
63+
64+
```ts title="app/api/search/route.ts"
65+
import { createMixedbreadSearchAPI } from 'fumadocs-core/search/mixedbread';
66+
import Mixedbread from '@mixedbread/sdk';
67+
68+
const client = new Mixedbread({
69+
apiKey: 'YOUR_API_KEY',
70+
});
71+
72+
export const { GET } = createMixedbreadSearchAPI({
73+
client,
74+
storeIdentifier: 'YOUR_STORE_ID',
75+
});
76+
```
77+
6078
### Search Client
6179

6280
- **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
6583
```ts
6684
import { useDocsSearch } from 'fumadocs-core/search/client';
6785

68-
const mxbai = new Mixedbread({
69-
apiKey: 'YOUR_API_KEY',
70-
});
71-
7286
const client = useDocsSearch({
73-
type: 'mixedbread',
74-
client: mxbai,
87+
type: 'fetch',
88+
api: '/api/search',
7589
});
7690
```
7791

@@ -93,13 +107,15 @@ tag: docs
93107

94108
And update your search client:
95109

96-
- **Fumadocs UI**: Enable [Tag Filter](/docs/search/orama#tag-filter) on Search UI.
110+
- **Fumadocs UI**: Enable [Tag Filter](/docs/search/mixedbread#tag-filter) on Search UI.
97111
- **Search Client**: You can add the tag filter like:
98112

99113
```ts
100114
import { useDocsSearch } from 'fumadocs-core/search/client';
101115

102116
const { search, setSearch, query } = useDocsSearch({
117+
type: 'fetch',
118+
api: '/api/search',
103119
tag: '<your tag value>',
104120
// ...
105121
});

packages/core/src/search/client/mixedbread.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import removeMd from 'remove-markdown';
44
import Slugger from 'github-slugger';
55
import type { StoreSearchResponse } from '@mixedbread/sdk/resources/stores';
66

7+
/**
8+
* @deprecated Use `createMixedbreadSearchAPI` from `fumadocs-core/search/mixedbread` instead.
9+
* This client-side approach exposes your API key in the browser.
10+
* The server-side approach keeps the key secure and uses `type: 'fetch'` on the client.
11+
*/
712
export interface MixedbreadOptions {
813
/**
914
* The identifier of the store to search in
@@ -59,6 +64,11 @@ function extractHeadingTitle(text: string): string {
5964
return '';
6065
}
6166

67+
/**
68+
* @deprecated Use `createMixedbreadSearchAPI` from `fumadocs-core/search/mixedbread` instead.
69+
* This client-side approach exposes your API key in the browser.
70+
* The server-side approach keeps the key secure and uses `type: 'fetch'` on the client.
71+
*/
6272
export async function search(query: string, options: MixedbreadOptions): Promise<SortedResult[]> {
6373
const { client, storeIdentifier, tag } = options;
6474

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { SortedResult } from '@/search';
2+
import type Mixedbread from '@mixedbread/sdk';
3+
import type { StoreSearchParams, StoreSearchResponse } from '@mixedbread/sdk/resources/stores';
4+
import removeMd from 'remove-markdown';
5+
import Slugger from 'github-slugger';
6+
import { createEndpoint } from '@/search/orama/create-endpoint';
7+
import type { SearchAPI } from '@/search/server';
8+
9+
export interface SearchMetadata {
10+
title?: string;
11+
description?: string;
12+
url?: string;
13+
tag?: string;
14+
}
15+
16+
type StoreSearchResult = StoreSearchResponse['data'][number] & {
17+
generated_metadata: SearchMetadata;
18+
};
19+
20+
export interface MixedbreadSearchOptions {
21+
/**
22+
* The Mixedbread SDK client instance
23+
*/
24+
client: Mixedbread;
25+
26+
/**
27+
* The identifier of the store to search in
28+
*/
29+
storeIdentifier: string;
30+
31+
/**
32+
* Maximum number of results to return
33+
*
34+
* @defaultValue 10
35+
*/
36+
topK?: number;
37+
38+
/**
39+
* Re-rank search results for improved relevance
40+
*
41+
* @defaultValue true
42+
*/
43+
rerank?: boolean;
44+
45+
/**
46+
* Rewrite the query for better search results
47+
*/
48+
rewriteQuery?: boolean;
49+
50+
/**
51+
* Minimum score threshold for results
52+
*/
53+
scoreThreshold?: number;
54+
55+
/**
56+
* Custom transform function for search results
57+
*/
58+
transform?: (results: StoreSearchResult[], query: string) => SortedResult[];
59+
}
60+
61+
const slugger = new Slugger();
62+
63+
function extractHeadingTitle(text: string): string {
64+
const trimmedText = text.trim();
65+
66+
if (!trimmedText.startsWith('#')) {
67+
return '';
68+
}
69+
70+
const lines = trimmedText.split('\n');
71+
const firstLine = lines[0]?.trim();
72+
73+
if (firstLine) {
74+
return removeMd(firstLine, {
75+
useImgAltText: false,
76+
});
77+
}
78+
79+
return '';
80+
}
81+
82+
function defaultTransform(results: StoreSearchResult[]): SortedResult[] {
83+
return results.flatMap((item) => {
84+
const metadata = item.generated_metadata;
85+
86+
const url = metadata.url || '#';
87+
const title = metadata.title || 'Untitled';
88+
89+
const chunkResults: SortedResult[] = [
90+
{
91+
id: `${item.file_id}-${item.chunk_index}-page`,
92+
type: 'page',
93+
content: title,
94+
url,
95+
},
96+
];
97+
98+
const headingTitle = item.type === 'text' ? extractHeadingTitle(item.text) : '';
99+
100+
if (headingTitle) {
101+
slugger.reset();
102+
103+
chunkResults.push({
104+
id: `${item.file_id}-${item.chunk_index}-heading`,
105+
type: 'heading',
106+
content: headingTitle,
107+
url: `${url}#${slugger.slug(headingTitle)}`,
108+
});
109+
}
110+
111+
return chunkResults;
112+
});
113+
}
114+
115+
export function createMixedbreadSearchAPI(options: MixedbreadSearchOptions): SearchAPI {
116+
const {
117+
client,
118+
storeIdentifier,
119+
topK = 10,
120+
rerank = true,
121+
rewriteQuery,
122+
scoreThreshold,
123+
transform,
124+
} = options;
125+
126+
return createEndpoint({
127+
async search(query, searchOptions) {
128+
if (!query.trim()) {
129+
return [];
130+
}
131+
132+
const tag = searchOptions?.tag;
133+
134+
let filters: StoreSearchParams['filters'] | undefined;
135+
if (Array.isArray(tag) && tag.length > 0) {
136+
filters = {
137+
key: 'generated_metadata.tag',
138+
operator: 'in',
139+
value: tag,
140+
};
141+
} else if (typeof tag === 'string') {
142+
filters = {
143+
key: 'generated_metadata.tag',
144+
operator: 'eq',
145+
value: tag,
146+
};
147+
}
148+
149+
const res = await client.stores.search({
150+
query,
151+
store_identifiers: [storeIdentifier],
152+
top_k: topK,
153+
filters,
154+
search_options: {
155+
return_metadata: true,
156+
rerank,
157+
rewrite_query: rewriteQuery,
158+
score_threshold: scoreThreshold,
159+
},
160+
});
161+
162+
const results = res.data as StoreSearchResult[];
163+
164+
if (transform) {
165+
return transform(results, query);
166+
}
167+
168+
return defaultTransform(results);
169+
},
170+
async export() {
171+
throw new Error(
172+
'Mixedbread search does not support exporting indexes. Use the Mixedbread dashboard to manage your store.',
173+
);
174+
},
175+
});
176+
}

packages/core/tsdown.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default defineConfig({
1515
'src/source/{index,schema}.ts',
1616
'src/source/client/*.{ts,tsx}',
1717
'src/source/plugins/{lucide-icons,slugs,status-badges}.{ts,tsx}',
18-
'src/search/{index,client,server,algolia,orama-cloud,orama-cloud-legacy}.ts',
18+
'src/search/{index,client,server,algolia,orama-cloud,orama-cloud-legacy,mixedbread}.ts',
1919
'src/utils/use-on-change.ts',
2020
'src/utils/use-media-query.ts',
2121
'src/i18n/*.ts',

0 commit comments

Comments
 (0)