Mixedbread integration (#2089)

* feat: add mixedbread integration

* chore(release): add changeset

* docs: add mixedbread integration ui docs

* feat: update tag filtering and extract title from chunked text

* feat: improve extractHeadingTitle

* fix: fix slug generation

* feat: add a minor adjustment

* feat: add adjustments

* chore: make mixedbread sdk optional

* feat: add metadata filtering

* inline deps

* Update sour-teeth-deny.md

---------

Co-authored-by: Fuma Nama <[email protected]>
Co-authored-by: Fuma Nama <[email protected]>
This commit is contained in:
Naing Linn Khant
2025-07-24 16:22:20 +08:00
committed by Fuma Nama
co-authored by Fuma Nama Fuma Nama
parent 38c099ae46
commit 1b0e9d5cda
31 changed files with 1210 additions and 959 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Add mixedbread integration
@@ -0,0 +1,92 @@
---
title: Mixedbread
description: Integrate Mixedbread Search with Fumadocs
---
## Introduction
The Mixedbread Integration uses vector search to provide semantic search capabilities for your documentation. It indexes your documentation content into a vector store, enabling users to search using natural language queries and find relevant content based on meaning rather than just keyword matching.
## Setup
### Get your API Key
1. Sign up at [Mixedbread](https://platform.mixedbread.com)
2. Navigate to [API Keys](https://platform.mixedbread.com/platform?next=api-keys)
3. Create a new API key and store it in your environment variables
### Create a Vector Store
To sync your documentation, you'll need to create a vector store:
1. Go to the [Vector Stores](https://platform.mixedbread.com/platform?next=vector-stores) in your Mixedbread dashboard
2. Create a new vector store for your documentation
3. Copy the vector store ID
### Sync Documentation
Use the [Mixedbread CLI](https://www.mixedbread.com/cli) to sync your documentation:
Install the CLI:
```package-install
@mixedbread/cli -D
```
Configure authentication and sync your documentation:
```bash
# Configure authentication
mxbai config keys add YOUR_API_KEY
# Sync your documentation
mxbai vs sync YOUR_VECTOR_STORE_ID "./content/docs"
```
The CLI will automatically detect changes in your documentation and update the vector store accordingly.
### Workflow
You can automatically sync your documentation by adding a sync script to your `package.json`:
```json
{
"scripts": {
"build": "next build && npm run sync-content",
"sync-content": "mxbai vs sync YOUR_VECTOR_STORE_ID './content/docs' --ci"
}
}
```
## Options
### Tag Filter
To filter search results by tags, add a tag field to your document metadata:
```typescript
---
title: Mixedbread
description: Integrate Mixedbread Search with Fumadocs
url: /docs/headless/search/mixedbread
// [!code ++]
tag: docs
---
...
```
And update your search client:
- **Fumadocs UI**: Enable [Tag Filter](/docs/ui/search/orama#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({
tag: '<your tag value>',
// ...
});
```
This allows you to scope searches to specific sections of your documentation.
@@ -0,0 +1,22 @@
---
title: Mixedbread
description: Using Mixedbread with Fumadocs UI.
---
## Overview
For the setup guide, see [Integrate Mixedbread Search](/docs/headless/search/mixedbread).
## Setup
Create a search dialog, replace `apiKey` and `vectorStoreId` with your desired values.
<include meta='title="components/search.tsx"'>./mixedbread.tsx</include>
<include>.shared.mdx</include>
### Tag Filter
Optionally, you can add UI for filtering results by tags. Configure [Tag Filter](/docs/headless/search/mixedbread#tag-filter) on search server and add the following:
<include>.tag-filter.mdx</include>
@@ -0,0 +1,60 @@
'use client';
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogFooter,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SharedProps,
} 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,
vectorStoreId: 'vectorStoreId',
locale,
});
return (
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
<SearchDialogFooter>
<a
href="https://mixedbread.com"
rel="noreferrer noopener"
className="ms-auto text-xs text-fd-muted-foreground"
>
Search powered by Mixedbread
</a>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}
+10 -9
View File
@@ -15,6 +15,7 @@
"@ai-sdk/openai": "^1.3.23",
"@ai-sdk/react": "^1.2.12",
"@fumadocs/mdx-remote": "workspace:*",
"@mixedbread/sdk": "^0.19.0",
"@orama/orama": "^3.1.11",
"@oramacloud/client": "^2.1.4",
"@radix-ui/react-dialog": "^1.1.14",
@@ -36,10 +37,10 @@
"katex": "^0.16.22",
"lucide-react": "^0.525.0",
"mermaid": "^11.9.0",
"next": "15.4.2",
"next": "15.4.3",
"next-themes": "^0.4.6",
"octokit": "^5.0.3",
"oxc-transform": "^0.77.3",
"oxc-transform": "^0.78.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"rehype-katex": "^7.0.1",
@@ -51,22 +52,22 @@
"scroll-into-view-if-needed": "^3.1.0",
"shiki": "^3.8.1",
"tailwind-merge": "^3.3.1",
"twoslash": "^0.3.2",
"twoslash": "^0.3.3",
"unist-util-visit": "^5.0.0",
"zod": "^4.0.5"
"zod": "^4.0.8"
},
"devDependencies": {
"@fumadocs/cli": "workspace:*",
"@next/bundle-analyzer": "15.4.2",
"@next/env": "15.4.2",
"@next/eslint-plugin-next": "15.4.2",
"@next/bundle-analyzer": "15.4.3",
"@next/env": "15.4.3",
"@next/eslint-plugin-next": "15.4.3",
"@tailwindcss/postcss": "^4.1.11",
"@types/hast": "^3.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"algoliasearch": "5.34.0",
"algoliasearch": "5.34.1",
"eslint-config-custom": "workspace:*",
"gray-matter": "^4.0.3",
"next-validate-link": "^1.5.2",
+1 -1
View File
@@ -11,7 +11,7 @@
"@fumadocs/content-collections": "workspace:*",
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "^15.4.2",
"next": "^15.4.3",
"react": "19.1.0",
"react-dom": "19.1.0"
},
+1 -1
View File
@@ -12,7 +12,7 @@
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "15.4.2",
"next": "15.4.3",
"react": "19.1.0",
"react-dom": "19.1.0"
},
+1 -1
View File
@@ -11,7 +11,7 @@
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "15.4.2",
"next": "15.4.3",
"react": "19.1.0",
"react-dom": "19.1.0"
},
+1 -1
View File
@@ -14,7 +14,7 @@
"fumadocs-mdx": "workspace:*",
"fumadocs-openapi": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "15.4.2",
"next": "15.4.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"shiki": "^3.8.1"
+2 -2
View File
@@ -14,14 +14,14 @@
"fumadocs-mdx": "workspace:*",
"fumadocs-python": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "15.4.2",
"next": "15.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@types/mdx": "^2.0.13",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"postcss": "^8.5.6",
+1 -1
View File
@@ -24,7 +24,7 @@
"devDependencies": {
"@react-router/dev": "^7.7.0",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.0.15",
"@types/node": "^24.1.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.11",
+1 -1
View File
@@ -22,7 +22,7 @@
"devDependencies": {
"@react-router/dev": "^7.7.0",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.0.15",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"react-router-devtools": "^5.0.6",
+1 -1
View File
@@ -11,7 +11,7 @@
"@fumadocs/mdx-remote": "workspace:*",
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "15.4.2",
"next": "15.4.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tinyglobby": "^0.2.14"
+1 -1
View File
@@ -23,7 +23,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.0.15",
"@types/node": "^24.1.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.7.0",
+1 -1
View File
@@ -30,7 +30,7 @@
"tsup": "8.5.0",
"turbo": "2.5.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.37.0",
"typescript-eslint": "^8.38.0",
"vitest": "^3.2.4"
},
"packageManager": "[email protected]",
+1 -1
View File
@@ -42,7 +42,7 @@
"ts-morph": "^26.0.0"
},
"devDependencies": {
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*",
"tsx": "^4.20.3"
+2 -2
View File
@@ -37,12 +37,12 @@
"devDependencies": {
"@content-collections/core": "^0.10.0",
"@content-collections/mdx": "^0.2.2",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
"tsconfig": "workspace:*",
"unified": "^11.0.5",
"zod": "^4.0.5"
"zod": "^4.0.8"
},
"peerDependencies": {
"@content-collections/core": "0.x.x",
+9 -3
View File
@@ -112,28 +112,31 @@
},
"devDependencies": {
"@mdx-js/mdx": "^3.1.0",
"@mixedbread/sdk": "^0.19.0",
"@oramacloud/client": "^2.1.4",
"@tanstack/react-router": "^1.129.0",
"@types/estree-jsx": "^1.0.5",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.3",
"@types/negotiator": "^0.6.4",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"algoliasearch": "5.34.0",
"algoliasearch": "5.34.1",
"eslint-config-custom": "workspace:*",
"mdast-util-mdx-jsx": "^3.2.0",
"mdast-util-mdxjs-esm": "^2.0.1",
"next": "^15.4.2",
"next": "^15.4.3",
"react-router": "^7.7.0",
"remark-mdx": "^3.1.0",
"remove-markdown": "^0.6.2",
"tsconfig": "workspace:*",
"typescript": "^5.8.3",
"unified": "^11.0.5",
"vfile": "^6.0.3"
},
"peerDependencies": {
"@mixedbread/sdk": "^0.19.0",
"@oramacloud/client": "1.x.x || 2.x.x",
"@types/react": "*",
"algoliasearch": "5.x.x",
@@ -142,6 +145,9 @@
"react-dom": "18.x.x || 19.x.x"
},
"peerDependenciesMeta": {
"@mixedbread/sdk": {
"optional": true
},
"@types/react": {
"optional": true
},
+10 -1
View File
@@ -6,6 +6,7 @@ import { useOnChange } from '@/utils/use-on-change';
import { type StaticOptions } from '@/search/client/static';
import { type AlgoliaOptions } from '@/search/client/algolia';
import { type OramaCloudOptions } from '@/search/client/orama-cloud';
import { type MixedbreadOptions } from '@/search/client/mixedbread';
interface UseDocsSearch {
search: string;
@@ -29,7 +30,10 @@ export type Client =
} & AlgoliaOptions)
| ({
type: 'orama-cloud';
} & OramaCloudOptions);
} & OramaCloudOptions)
| ({
type: 'mixedbread';
} & MixedbreadOptions);
function isDifferentDeep(a: unknown, b: unknown): boolean {
if (Array.isArray(a) && Array.isArray(b)) {
@@ -146,6 +150,11 @@ export function useDocsSearch(
return search(debouncedValue, client);
}
if (client.type === 'mixedbread') {
const { search } = await import('./client/mixedbread');
return search(debouncedValue, client);
}
throw new Error('unknown search client');
}
@@ -0,0 +1,121 @@
import { SortedResult } from '@/server';
import Mixedbread from '@mixedbread/sdk';
import { VectorStoreSearchResponse } from '@mixedbread/sdk/resources/vector-stores';
import removeMd from 'remove-markdown';
import Slugger from 'github-slugger';
export interface MixedbreadOptions {
/**
* The ID of the vector store to search in
*/
vectorStoreId: string;
/**
* The Mixedbread SDK client instance
*/
client: Mixedbread;
/**
* Filter results with specific tag.
*/
tag?: string;
/**
* Filter by locale (unsupported at the moment)
*/
locale?: string;
}
export interface SearchMetadata {
title?: string;
description?: string;
url?: string;
tag?: string;
}
type VectorStoreSearchResult = VectorStoreSearchResponse['data'][number] & {
generated_metadata: SearchMetadata;
};
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) {
// Use remove-markdown to convert to plain text and remove colons
const plainText = removeMd(firstLine, {
useImgAltText: false,
});
return plainText;
}
return '';
}
export async function search(
query: string,
options: MixedbreadOptions,
): Promise<SortedResult[]> {
const { client, vectorStoreId, tag } = options;
if (!query.trim()) {
return [];
}
const res = await client.vectorStores.search({
query,
vector_store_identifiers: [vectorStoreId],
top_k: 10,
filters: {
key: 'generated_metadata.tag',
operator: 'eq',
value: tag,
},
search_options: {
return_metadata: true,
},
});
const results = (res.data as VectorStoreSearchResult[]).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;
});
return results;
}
+2 -2
View File
@@ -16,13 +16,13 @@
"@tanstack/react-router": "^1.129.0",
"@tanstack/react-start": "^1.129.0",
"@types/mdx": "^2.0.13",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.7.0",
"gray-matter": "^4.0.3",
"isbot": "^5.1.28",
"next": "15.4.2",
"next": "15.4.3",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+1 -1
View File
@@ -36,7 +36,7 @@
},
"devDependencies": {
"@types/cross-spawn": "^6.0.6",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"eslint-config-custom": "workspace:*",
"tinyglobby": "^0.2.14",
"tsconfig": "workspace:*"
+3 -3
View File
@@ -40,16 +40,16 @@
"estree-util-to-js": "^2.0.0",
"estree-util-value-to-estree": "^3.4.0",
"npm-to-yarn": "^3.0.1",
"oxc-transform": "^0.77.3",
"oxc-transform": "^0.78.0",
"unist-util-visit": "^5.0.0",
"zod": "^4.0.5"
"zod": "^4.0.8"
},
"devDependencies": {
"@mdx-js/mdx": "^3.1.0",
"@types/estree": "^1.0.8",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.3",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"eslint-config-custom": "workspace:*",
"mdast-util-mdx-jsx": "^3.2.0",
"remark": "^15.0.0",
+2 -2
View File
@@ -38,11 +38,11 @@
"dependencies": {
"@mdx-js/mdx": "^3.1.0",
"gray-matter": "^4.0.3",
"zod": "^4.0.5"
"zod": "^4.0.8"
},
"devDependencies": {
"@types/mdx": "^2.0.13",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"@types/react": "^19.1.8",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
+3 -3
View File
@@ -69,19 +69,19 @@
"tinyexec": "^1.0.1",
"tinyglobby": "^0.2.14",
"unist-util-visit": "^5.0.0",
"zod": "^4.0.5"
"zod": "^4.0.8"
},
"devDependencies": {
"@fumadocs/mdx-remote": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/mdast": "^4.0.3",
"@types/mdx": "^2.0.13",
"@types/node": "^24.0.15",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
"mdast-util-mdx-jsx": "^3.2.0",
"next": "^15.4.2",
"next": "^15.4.3",
"react": "^19.1.0",
"tsconfig": "workspace:*",
"unified": "^11.0.5",
+3 -3
View File
@@ -67,7 +67,7 @@
"js-yaml": "^4.1.0",
"next-themes": "^0.4.6",
"openapi-sampler": "^1.6.1",
"react-hook-form": "^7.60.0",
"react-hook-form": "^7.61.0",
"remark": "^15.0.1",
"remark-rehype": "^11.1.2",
"shiki": "^3.8.1",
@@ -77,12 +77,12 @@
"devDependencies": {
"@scalar/api-client-react": "^1.3.24",
"@types/js-yaml": "^4.0.9",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"@types/openapi-sampler": "^1.0.3",
"@types/react": "^19.1.8",
"eslint-config-custom": "workspace:*",
"json-schema-typed": "^8.0.1",
"next": "15.4.2",
"next": "15.4.3",
"openapi-types": "^12.1.3",
"tailwindcss": "^4.1.11",
"tsc-alias": "^1.8.16",
+1 -1
View File
@@ -40,7 +40,7 @@
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.0.15",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
+2 -2
View File
@@ -48,12 +48,12 @@
"mdast-util-to-hast": "^13.2.0",
"shiki": "^3.8.1",
"tailwind-merge": "^3.3.1",
"twoslash": "^0.3.2"
"twoslash": "^0.3.3"
},
"devDependencies": {
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.3",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"@types/react": "^19.1.8",
"eslint-config-custom": "workspace:*",
"fumadocs-ui": "workspace:*",
+1 -1
View File
@@ -50,7 +50,7 @@
"@types/estree": "^1.0.8",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.3",
"@types/node": "24.0.15",
"@types/node": "24.1.0",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"eslint-config-custom": "workspace:*",
+2 -2
View File
@@ -103,14 +103,14 @@
},
"devDependencies": {
"@fumadocs/cli": "workspace:*",
"@next/eslint-plugin-next": "^15.4.2",
"@next/eslint-plugin-next": "^15.4.3",
"@tailwindcss/cli": "^4.1.11",
"@types/lodash.merge": "^4.6.9",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
"next": "15.4.2",
"next": "15.4.3",
"tailwindcss": "^4.1.11",
"tsc-alias": "^1.8.16",
"tsconfig": "workspace:*"
+847 -912
View File
File diff suppressed because it is too large Load Diff