Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions components/search-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import {
*
* The per-locale Orama indexes are built at build time by
* app/api/search/[lang]/route.ts; this component downloads only the current
* reader's language (via `from`) and runs every query client-side. It mirrors
* fumadocs' DefaultSearchDialog layout (so the UX is unchanged) but swaps the
* fetch client for the static Orama client with a locale-aware `initOrama`.
* reader's language and runs every query client-side. It mirrors fumadocs'
* DefaultSearchDialog layout (so the UX is unchanged) but swaps the fetch client
* for a static one with a locale-aware `initOrama` and page-level ranking
* (lib/search-client.ts, lib/search-ranking.ts).
*
* `initOrama` must reproduce the SAME tokenizer the route used to build each
* locale's index, otherwise the query is tokenized differently from the stored
Expand All @@ -36,10 +37,9 @@ import {
export default function StaticSearchDialog(props: SharedProps) {
const { locale } = useI18n()
const lang = locale ?? i18n.defaultLanguage
// Created inline (like fumadocs' DefaultSearchDialog); useDocsSearch tracks
// re-queries via the client's `deps` ([tag, locale]), and the downloaded index
// is cached module-side keyed by URL, so this stays cheap across renders.
// `from` is the per-locale endpoint so we only download this reader's language.
// Created inline (like fumadocs' DefaultSearchDialog); useDocsSearch re-queries
// when the client's `deps` ([locale]) change, and the downloaded index is
// cached module-side per locale, so this stays cheap across renders.
const client = createDocsSearchClient(lang)
const { search, setSearch, query } = useDocsSearch({ client })

Expand Down
46 changes: 38 additions & 8 deletions lib/search-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { create, type AnyOrama } from '@orama/orama'
import { oramaStaticClient } from 'fumadocs-core/search/client/orama-static'
import { create, load, type AnyOrama } from '@orama/orama'
import type { SearchClient } from 'fumadocs-core/search/client'
import { SEARCH_LANGUAGE_BY_LOCALE } from '@/lib/search-languages'
import { searchRanked } from '@/lib/search-ranking'

/**
* Build the same locale-specific tokenizer used by the static search route.
Expand Down Expand Up @@ -37,10 +38,39 @@ export async function initOrama(locale?: string): Promise<AnyOrama> {
})
}

export function createDocsSearchClient(locale: string) {
return oramaStaticClient({
from: `/api/search/${encodeURIComponent(locale)}`,
initOrama,
locale,
})
// One download per locale per page load, shared by every client. A failed
// download is evicted so the next query can retry.
const dbCache = new Map<string, Promise<AnyOrama | undefined>>()

function getLocaleDB(locale: string): Promise<AnyOrama | undefined> {
let cached = dbCache.get(locale)
if (!cached) {
cached = (async () => {
const res = await fetch(`/api/search/${encodeURIComponent(locale)}`)
if (!res.ok) throw new Error(`failed to fetch search index for "${locale}": ${res.status}`)
const exported = (await res.json()) as { data: Record<string, unknown> }
const data = exported.data[locale]
if (!data) return undefined
const db = await initOrama(locale)
load(db, data as Parameters<typeof load>[1])
return db
})()
cached.catch(() => dbCache.delete(locale))
dbCache.set(locale, cached)
}
return cached
}

/**
* Static search over the per-locale index, ranked by page (see
* lib/search-ranking.ts) instead of fumadocs' per-fragment ordering.
*/
export function createDocsSearchClient(locale: string): SearchClient {
return {
deps: [locale],
async search(query) {
const db = await getLocaleDB(locale)
return db ? searchRanked(db, query) : []
},
}
}
118 changes: 118 additions & 0 deletions lib/search-ranking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { create, insertMultiple } from '@orama/orama'
import { describe, expect, it } from 'vitest'
import { rankPages, scoreTitle, searchRanked } from './search-ranking'

const SCHEMA = {
id: 'string',
page_id: 'string',
type: 'string',
content: 'string',
url: 'string',
breadcrumbs: 'string[]',
} as const

// Mirrors the shape fumadocs exports for the advanced index: one `page` doc per
// page (content = title, id = page_id) plus heading/text fragments.
function page(url: string, title: string, fragments: string[]) {
return [
{ id: url, page_id: url, type: 'page', content: title, url, breadcrumbs: [] },
...fragments.map((content, i) => ({
id: `${url}-${i}`,
page_id: url,
type: 'text',
content,
url: `${url}#${i}`,
breadcrumbs: [],
})),
]
}

async function createDB() {
const db = create({ schema: SCHEMA })
await insertMultiple(db, [
...page('/docs/remote', 'Overview', [
'Development oriented docker compose',
'For IaaS we recommend Docker Compose',
'Deployment oriented docker compose',
'There are two docker compose files',
'Why Docker Compose? Docker guide',
'deploy-compose includes Nginx, internal to Docker',
]),
...page('/docs/local/docker', 'Docker', ['Install LibreChat with docker compose']),
...page('/docs/remote/docker_linux', 'Docker (Remote Linux)', ['docker.io and docker-ce']),
...page('/docs/configuration/cdn', 'File Storage & CDN', ['Store uploaded files']),
...page('/docs/features/rag_api', 'RAG API', ['Chat with files using the RAG API']),
])
return db
}

const pageUrls = (results: { type: string; url: string }[]) =>
results.filter((r) => r.type === 'page').map((r) => r.url)

describe('scoreTitle', () => {
it('ranks exact, prefix, and all-terms title matches in that order', () => {
expect(scoreTitle('Docker', 'docker')).toBeGreaterThan(scoreTitle('Docker Override', 'docker'))
expect(scoreTitle('Docker Override', 'docker')).toBeGreaterThan(
scoreTitle('Remote Docker Guide', 'docker'),
)
expect(scoreTitle('Remote Docker Guide', 'docker')).toBeGreaterThan(
scoreTitle('Overview', 'docker'),
)
})

it('matches query terms on word prefixes only', () => {
expect(scoreTitle('File Storage & CDN', 'rag')).toBe(0)
expect(scoreTitle('RAG API', 'rag')).toBeGreaterThan(0)
})

it('falls back to substring matching for CJK titles', () => {
expect(scoreTitle('Docker 部署指南', '部署')).toBeGreaterThan(0)
})
})

describe('rankPages', () => {
it('never lets body hits lift a weaker title match above a stronger one', () => {
const doc = (page_id: string, type: 'page' | 'text', content: string) => ({
id: `${page_id}-${content}`,
page_id,
type,
content,
url: page_id,
})
const pages = {
'/override': doc('/override', 'page', 'Docker Override'),
'/guide': doc('/guide', 'page', 'Remote Docker Guide'),
}
const hits = [
{ score: 1, document: doc('/override', 'text', 'docker') },
...Array.from({ length: 10 }, (_, i) => ({
score: 100,
document: doc('/guide', 'text', `docker ${i}`),
})),
]
const ranked = rankPages(hits, 'docker', (id) => pages[id as keyof typeof pages])
expect(ranked.map((r) => r.page.url)).toEqual(['/override', '/guide'])
})
})

describe('searchRanked', () => {
it('puts the page titled with the query above pages that only repeat it in the body', async () => {
const results = await searchRanked(await createDB(), 'docker')
expect(pageUrls(results).slice(0, 2)).toEqual([
'/docs/local/docker',
'/docs/remote/docker_linux',
])
})

it('caps fragments per page', async () => {
const results = await searchRanked(await createDB(), 'docker', { maxFragmentsPerPage: 2 })
const overviewFragments = results.filter(
(r) => r.type !== 'page' && r.url.startsWith('/docs/remote#'),
)
expect(overviewFragments).toHaveLength(2)
})

it('returns nothing for a blank query', async () => {
expect(await searchRanked(await createDB(), ' ')).toEqual([])
})
})
174 changes: 174 additions & 0 deletions lib/search-ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { getByID, search, type AnyOrama } from '@orama/orama'
import { createContentHighlighter, type SortedResult } from 'fumadocs-core/search'

/**
* Page-level ranking for the static (advanced) Orama index.
*
* fumadocs' built-in `searchAdvanced` scores every heading and paragraph as an
* independent document with BM25 and then orders pages by their first hit. Short
* paragraphs that repeat a term therefore outrank the page that is actually about
* it: searching "docker" put the "Overview" page (seven short paragraphs that
* mention docker) above the page titled "Docker". It also shows up to eight
* fragments per page, so one page fills the whole dialog.
*
* Here Orama only finds the candidates; each page is then scored by how well its
* title matches the query, with body relevance as the tie-breaker, and keeps a
* few fragments only.
*/

type IndexedDoc = {
id: string
page_id: string
type: SortedResult['type']
content: string
url: string
breadcrumbs?: string[]
}

export type RankOptions = {
/** Pages to return. */
maxPages?: number
/** Heading/text fragments shown under each page. */
maxFragmentsPerPage?: number
}

const CANDIDATE_LIMIT = 500
const TYPE_WEIGHT: Record<SortedResult['type'], number> = { page: 1, heading: 1.5, text: 1 }
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u

function normalize(value: string): string {
return value.toLowerCase().replaceAll(/\s+/g, ' ').trim()
}

function splitWords(value: string): string[] {
return normalize(value)
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean)
}

/**
* Title relevance: exact title, then a title that starts with the query, then a
* title containing every query term as a word prefix ("rag" matches "RAG API",
* not "Storage"). CJK titles have no word breaks, so they fall back to substring.
*/
export function scoreTitle(title: string, query: string): number {
const t = normalize(title)
const q = normalize(query)
if (!q) return 0
if (t === q) return 100
if (t.startsWith(q)) return 60

const terms = splitWords(q)
if (terms.length === 0) return 0
const words = splitWords(t)
const matched = terms.filter((term) =>
CJK.test(term) ? t.includes(term) : words.some((word) => word.startsWith(term)),
).length
if (matched === terms.length) return 40
return 20 * (matched / terms.length)
}

type Fragment = { doc: IndexedDoc; score: number }
type PageGroup = { pageId: string; best: number; count: number; fragments: Fragment[] }

export type RankedPage = {
page: IndexedDoc
titleScore: number
bodyScore: number
fragments: IndexedDoc[]
}

/**
* Groups Orama hits by page and orders pages by title match first, then by
* their best weighted body hit, then by how many fragments matched. The title
* tier is compared on its own so body hits can never lift a weaker title match
* above a stronger one.
*/
export function rankPages(
hits: { score: number; document: IndexedDoc }[],
query: string,
getPage: (pageId: string) => IndexedDoc | undefined,
{ maxPages = 10, maxFragmentsPerPage = 3 }: RankOptions = {},
): RankedPage[] {
const groups = new Map<string, PageGroup>()
let maxScore = 0

for (const hit of hits) {
const doc = hit.document
const weighted = hit.score * TYPE_WEIGHT[doc.type]
maxScore = Math.max(maxScore, weighted)

let group = groups.get(doc.page_id)
if (!group) {
group = { pageId: doc.page_id, best: 0, count: 0, fragments: [] }
groups.set(doc.page_id, group)
}
group.best = Math.max(group.best, weighted)
group.count++
if (doc.type !== 'page') group.fragments.push({ doc, score: weighted })
}

const ranked: RankedPage[] = []
for (const group of groups.values()) {
const page = getPage(group.pageId)
if (!page) continue
const body = maxScore > 0 ? (group.best / maxScore) * 30 : 0
const coverage = (Math.min(group.count, 10) / 10) * 10
ranked.push({
page,
titleScore: scoreTitle(page.content, query),
bodyScore: body + coverage,
fragments: group.fragments
.sort((a, b) => b.score - a.score)
.slice(0, maxFragmentsPerPage)
.map((f) => f.doc),
})
}

return ranked
.sort((a, b) => b.titleScore - a.titleScore || b.bodyScore - a.bodyScore)
.slice(0, maxPages)
}

/** Runs a query against an exported fumadocs advanced index. */
export async function searchRanked(
db: AnyOrama,
query: string,
options?: RankOptions,
): Promise<SortedResult[]> {
if (!query.trim()) return []

const result = await search(db, {
term: query,
limit: CANDIDATE_LIMIT,
properties: ['content'],
})
const pages = rankPages(
result.hits as unknown as { score: number; document: IndexedDoc }[],
query,
(pageId) => getByID(db, pageId) as IndexedDoc | undefined,
options,
)

const highlighter = createContentHighlighter(query)
const list: SortedResult[] = []
for (const { page, fragments } of pages) {
list.push({
id: page.id,
type: 'page',
content: highlighter.highlightMarkdown(page.content),
breadcrumbs: page.breadcrumbs,
url: page.url,
})
for (const doc of fragments) {
list.push({
id: doc.id.toString(),
type: doc.type,
content: highlighter.highlightMarkdown(doc.content),
breadcrumbs: doc.breadcrumbs,
url: doc.url,
})
}
}
return list
}
Loading
Loading