Skip to content

Commit 432a343

Browse files
committed
feat(search): hybrid Fuse + embedding ranking with better relevance
1 parent c1b968d commit 432a343

File tree

2 files changed

+46
-248
lines changed

2 files changed

+46
-248
lines changed
Lines changed: 44 additions & 242 deletions
Original file line numberDiff line numberDiff line change
@@ -1,251 +1,53 @@
11
'use client';
22

3-
import { Link } from '@components/Link/Link';
4-
import {
5-
Breadcrumb,
6-
type BreadcrumbLink,
7-
Input,
8-
Loader,
9-
} from '@intlayer/design-system';
10-
import { useSearchDoc } from '@intlayer/design-system/hooks';
11-
import type { BlogMetadata, DocMetadata } from '@intlayer/docs';
12-
import Fuse, { type IFuseOptions } from 'fuse.js';
13-
import { getIntlayer } from 'intlayer';
14-
import { ArrowRight, Search } from 'lucide-react';
15-
import { useSearchParams } from 'next/navigation';
16-
import { useIntlayer, useLocale } from 'next-intlayer';
17-
import { type FC, useEffect, useRef, useState } from 'react';
18-
19-
/* -------------------------------------------------------------------------- */
20-
/* Fuse.js Configuration */
21-
/* -------------------------------------------------------------------------- */
22-
23-
const fuseOptions: IFuseOptions<DocMetadata> = {
24-
includeScore: true,
25-
shouldSort: true,
26-
threshold: 0.25, // More flexible fuzzy matching
27-
ignoreLocation: true, // Word order insensitive
28-
distance: 100,
29-
minMatchCharLength: 2,
30-
findAllMatches: true,
31-
keys: [
32-
{ name: 'title', weight: 0.7 },
33-
{ name: 'description', weight: 0.15 },
34-
{ name: 'keywords', weight: 0.1 },
35-
{ name: 'excerpt', weight: 0.05 }, // Optional short snippet per doc
36-
],
37-
};
38-
39-
/* -------------------------------------------------------------------------- */
40-
/* Utilities */
41-
/* -------------------------------------------------------------------------- */
42-
43-
// Debounce utility
44-
const debounce = <T extends (...args: any[]) => void>(
45-
func: T,
46-
delay: number,
47-
onAbort: () => void
48-
): ((...args: Parameters<T>) => void) => {
49-
let timeoutId: ReturnType<typeof setTimeout> | null = null;
50-
return (...args: Parameters<T>) => {
51-
if (timeoutId) {
52-
onAbort();
53-
clearTimeout(timeoutId);
54-
}
55-
timeoutId = setTimeout(() => func(...args), delay);
56-
};
57-
};
58-
59-
/**
60-
* Hybrid rank merge between Fuse.js (local) and embedding backend results.
61-
*/
62-
function mergeHybridResults(
63-
fuseResults: Fuse.FuseResult<DocMetadata>[],
64-
backendResults: { docKey: string; score: number }[],
65-
allDocs: DocMetadata[]
66-
): DocMetadata[] {
67-
const normalizeFuse = (score?: number) => 1 - Math.min((score ?? 1) / 0.5, 1); // invert Fuse score
68-
const normalizeBackend = (score: number) => Math.min(score / 1.0, 1); // already cosine-like
69-
70-
const backendMap = new Map(
71-
backendResults.map((r) => [r.docKey, normalizeBackend(r.score)])
72-
);
73-
const combinedMap = new Map<string, { doc: DocMetadata; score: number }>();
74-
75-
for (const fuseItem of fuseResults) {
76-
const doc = fuseItem.item;
77-
const fuseScore = normalizeFuse(fuseItem.score);
78-
const backendScore = backendMap.get(doc.docKey);
79-
const combinedScore = backendScore
80-
? 0.7 * backendScore + 0.3 * fuseScore
81-
: fuseScore;
82-
combinedMap.set(doc.docKey, { doc, score: combinedScore });
83-
}
84-
85-
for (const [docKey, backendScore] of backendMap) {
86-
if (!combinedMap.has(docKey)) {
87-
const doc = allDocs.find((d) => d.docKey === docKey);
88-
if (doc) combinedMap.set(docKey, { doc, score: 0.7 * backendScore });
89-
}
90-
}
91-
92-
return Array.from(combinedMap.values())
93-
.sort((a, b) => b.score - a.score)
94-
.map((entry) => entry.doc);
95-
}
96-
97-
/* -------------------------------------------------------------------------- */
98-
/* UI Components */
99-
/* -------------------------------------------------------------------------- */
100-
101-
const SearchResultItem: FC<{ doc: DocMetadata; onClickLink: () => void }> = ({
102-
doc,
103-
onClickLink,
104-
}) => {
105-
const { searchResultItemButton } = useIntlayer('doc-search-view');
106-
const breadcrumbLinks: BreadcrumbLink[] = doc.url
107-
.split('/')
108-
.slice(2)
109-
.map((path) => ({ text: path }));
110-
111-
return (
112-
<Link
113-
label={searchResultItemButton.label.value}
114-
variant="hoverable"
115-
color="text"
116-
id={doc.url}
117-
href={doc.url}
118-
className="w-full max-w-full"
119-
onClick={onClickLink}
120-
>
121-
<div className="flex items-center justify-between gap-2 text-wrap p-3">
122-
<div className="flex flex-1 flex-col gap-2 text-left">
123-
<strong className="text-base">{doc.title}</strong>
124-
<p className="text-neutral text-sm">{doc.description}</p>
125-
<Breadcrumb links={breadcrumbLinks} className="text-xs opacity-30" />
126-
</div>
127-
<ArrowRight size={24} />
128-
</div>
129-
</Link>
130-
);
3+
import { Button, Modal } from '@intlayer/design-system';
4+
import { Search } from 'lucide-react';
5+
import { useIntlayer } from 'next-intlayer';
6+
import { type FC, useState } from 'react';
7+
import { SearchView } from './SearchView';
8+
9+
type SearchTriggerProps = {
10+
isMini?: boolean;
13111
};
13212

133-
/* -------------------------------------------------------------------------- */
134-
/* Main Search View */
135-
/* -------------------------------------------------------------------------- */
136-
137-
export const SearchView: FC<{
138-
onClickLink?: () => void;
139-
isOpen?: boolean;
140-
}> = ({ onClickLink = () => {}, isOpen = false }) => {
141-
const inputRef = useRef<HTMLInputElement>(null);
142-
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
143-
const searchQuery = useSearchParams().get('search');
144-
145-
const [results, setResults] = useState<DocMetadata[]>([]);
146-
const [currentSearchQuery, setCurrentSearchQuery] = useState<string | null>(
147-
searchQuery
148-
);
149-
const { data: searchDocData, isFetching } = useSearchDoc({
150-
input: currentSearchQuery ?? '',
151-
});
152-
153-
const { noContentText, searchInput } = useIntlayer('doc-search-view');
154-
const { locale } = useLocale();
155-
156-
const docMetadata = getIntlayer('doc-metadata', locale) as DocMetadata[];
157-
const blogMetadata = getIntlayer('blog-metadata', locale) as BlogMetadata[];
158-
const filesData = [...docMetadata, ...blogMetadata];
159-
const fuse = new Fuse(filesData, fuseOptions);
160-
161-
const handleSearch = async (query: string) => {
162-
if (!query) {
163-
setCurrentSearchQuery(null);
164-
setResults([]);
165-
return;
166-
}
167-
168-
if (query.length > 2) {
169-
setCurrentSearchQuery(query);
170-
} else {
171-
setCurrentSearchQuery(null);
172-
const fuseSearchResults = fuse.search(query).map((r) => r.item);
173-
setResults(fuseSearchResults);
174-
}
175-
};
176-
177-
const debouncedSearch = debounce(handleSearch, 200, () => null);
178-
179-
/* ---------------------- Handle backend + Fuse merge ---------------------- */
180-
useEffect(() => {
181-
if (searchDocData?.data && currentSearchQuery) {
182-
const backendDocsWithScore =
183-
(searchDocData?.data ?? []).map((d: any) => ({
184-
docKey: d.fileKey,
185-
score: d.similarityScore ?? 0.5,
186-
})) ?? [];
187-
188-
const fuseSearchResults = fuse.search(currentSearchQuery);
189-
const mergedResults = mergeHybridResults(
190-
fuseSearchResults,
191-
backendDocsWithScore,
192-
filesData
193-
);
194-
195-
setResults(mergedResults);
196-
}
197-
}, [searchDocData, currentSearchQuery, filesData, fuse]);
198-
199-
/* ---------------------- Handle initial URL-based search ---------------------- */
200-
useEffect(() => {
201-
if (searchQuery) handleSearch(searchQuery);
202-
}, [searchQuery]);
203-
204-
/* --------------------------- Autofocus on open --------------------------- */
205-
useEffect(() => {
206-
if (isOpen) {
207-
timeoutRef.current = setTimeout(() => inputRef.current?.focus(), 10);
208-
}
209-
return () => {
210-
if (timeoutRef.current) clearTimeout(timeoutRef.current);
211-
};
212-
}, [isOpen]);
213-
214-
const isNoResult =
215-
!isFetching && results.length === 0 && inputRef.current?.value !== '';
13+
export const SearchTrigger: FC<SearchTriggerProps> = ({ isMini = false }) => {
14+
const [isModalOpen, setIsModalOpen] = useState(false);
15+
const { searchButton } = useIntlayer('doc-search-trigger');
21616

21717
return (
218-
<div className="relative w-full p-4">
219-
<div className="flex items-center gap-1">
220-
<Search />
221-
<Input
222-
type="search"
223-
placeholder={searchInput.placeholder.value}
224-
aria-label={searchInput.label.value}
225-
onChange={(e) => debouncedSearch(e.target.value)}
226-
defaultValue={searchQuery ?? ''}
227-
className="m-3"
228-
ref={inputRef}
18+
<>
19+
{isMini ? (
20+
<Button
21+
label={searchButton.label.value}
22+
Icon={Search}
23+
variant="hoverable"
24+
size="icon-md"
25+
color="text"
26+
onClick={() => setIsModalOpen(true)}
22927
/>
230-
</div>
231-
232-
<div className="mt-4">
233-
{isNoResult && (
234-
<p className="text-center text-neutral text-sm">{noContentText}</p>
235-
)}
236-
237-
{results.length > 0 && (
238-
<ul className="flex flex-col gap-10">
239-
{results.map((result, i) => (
240-
<li key={result.url}>
241-
<SearchResultItem doc={result} onClickLink={onClickLink} />
242-
<p className="text-gray-400 text-xs">Rank #{i + 1}</p>
243-
</li>
244-
))}
245-
</ul>
246-
)}
247-
<Loader isLoading={isFetching} />
248-
</div>
249-
</div>
28+
) : (
29+
<Button
30+
label={searchButton.label.value}
31+
Icon={Search}
32+
variant="input"
33+
color="custom"
34+
onClick={() => setIsModalOpen(true)}
35+
isFullWidth={false}
36+
>
37+
{searchButton.text}
38+
</Button>
39+
)}
40+
<Modal
41+
isOpen={isModalOpen}
42+
onClose={() => setIsModalOpen(false)}
43+
title={searchButton.text.value}
44+
size="lg"
45+
>
46+
<SearchView
47+
onClickLink={() => setIsModalOpen(false)}
48+
isOpen={isModalOpen}
49+
/>
50+
</Modal>
51+
</>
25052
);
25153
};

apps/website/src/components/DocPage/Search/SearchView.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import { type FC, useEffect, useRef, useState } from 'react';
1919
const fuseOptions: IFuseOptions<DocMetadata> = {
2020
includeScore: true,
2121
shouldSort: true,
22-
threshold: 0.25,
23-
ignoreLocation: true,
22+
threshold: 0.25, // More flexible fuzzy matching
23+
ignoreLocation: true, // Word order insensitive
2424
distance: 100,
2525
minMatchCharLength: 2,
2626
findAllMatches: true,
@@ -48,9 +48,6 @@ const debounce = <T extends (...args: any[]) => void>(
4848
};
4949
};
5050

51-
/**
52-
* Hybrid rank merge between Fuse.js (local) and embedding backend results.
53-
*/
5451
function mergeHybridResults(
5552
fuseResults: Fuse.FuseResult<DocMetadata>[],
5653
backendResults: { docKey: string; score: number }[],
@@ -183,7 +180,6 @@ export const SearchView: FC<{
183180
if (searchQuery) handleSearch(searchQuery);
184181
}, [searchQuery]);
185182

186-
/* --------------------------- Autofocus on open --------------------------- */
187183
useEffect(() => {
188184
if (isOpen) {
189185
timeoutRef.current = setTimeout(() => inputRef.current?.focus(), 10);

0 commit comments

Comments
 (0)