mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-07-28 04:34:58 +00:00
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:
co-authored by
Fuma Nama
parent
7872e27faa
commit
1ad8a389bb
@@ -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;
|
||||
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user