diff --git a/apps/web/app/api/search/route.ts b/apps/web/app/api/search/route.ts index 84ede84..62e1dce 100644 --- a/apps/web/app/api/search/route.ts +++ b/apps/web/app/api/search/route.ts @@ -1,5 +1,54 @@ +import type { StructuredData } from "fumadocs-core/mdx-plugins"; import { createFromSource } from "fumadocs-core/search/server"; import { source } from "@/lib/source"; -export const { GET } = createFromSource(source); +// A table cell is sliced from the Markdown together with the pipe that opens +// it, and the last one with the pipe that closes the row. Search results render +// that Markdown, so the pipes show up as stray characters. +const stripTablePipes = (content: string): string => { + const opened = content.startsWith("|") + ? content.slice(1).trimStart() + : content; + + return opened.endsWith("|") && !opened.endsWith("\\|") + ? opened.slice(0, -1).trimEnd() + : opened; +}; + +// The index has one `content` field, so a page title weighs the same as any +// other record and BM25 favours the shorter one. A table header cell such as +// `Pinecone` ties with the title of the Pinecone page, and scores above a +// longer title such as `Upstash Vector`, so the provider page drops below the +// comparison tables. Fumadocs' simple search boosts the title field for this +// reason. The advanced search takes no per-field boost, so scale the score of +// a page record before sorting. Any value from 1.1 to 4 ranks the same here. +const PAGE_TITLE_BOOST = 1.5; + +const withoutTablePipes = ({ + contents, + headings, +}: StructuredData): StructuredData => ({ + contents: contents.map((item) => ({ + ...item, + content: stripTablePipes(item.content), + })), + headings, +}); + +const boosted = (score: number, type: string): number => + type === "page" ? score * PAGE_TITLE_BOOST : score; + +export const { GET } = createFromSource(source, { + buildIndex: (page) => ({ + description: page.data.description, + id: page.url, + structuredData: withoutTablePipes(page.data.structuredData), + title: page.data.title, + url: page.url, + }), + search: { + sortBy: ([idA, scoreA, docA], [idB, scoreB, docB]) => + boosted(scoreB, docB.type) - boosted(scoreA, docA.type) || idA - idB, + }, +}); diff --git a/apps/web/source.config.ts b/apps/web/source.config.ts index c2fdf23..5e26755 100644 --- a/apps/web/source.config.ts +++ b/apps/web/source.config.ts @@ -35,5 +35,18 @@ export default defineConfig({ rehypeCodeOptions: { themes: { dark: geistShikiTheme, light: geistShikiTheme }, }, + // fumadocs 16.17 indexes a table as one record per row, header row included. + // Those records are long, so a query for an API name that sits in a table + // cell scores below short headings elsewhere and the page that documents + // it drops down the results. Index each cell on its own, as 16.15 did. + remarkStructureOptions: { + types: [ + "heading", + "paragraph", + "blockquote", + "tableCell", + "mdxJsxFlowElement", + ], + }, }, }); diff --git a/scripts/search-ranks.ts b/scripts/search-ranks.ts new file mode 100644 index 0000000..3783698 --- /dev/null +++ b/scripts/search-ranks.ts @@ -0,0 +1,140 @@ +// Rank check for the docs search. Fetches /api/search from one or more running +// builds of apps/web and reports where the page that documents each query lands. +// The search lists a page followed by its hits, so a page ranks by where its +// first result appears among the distinct pages, not by its index in the list. +// +// bun scripts/search-ranks.ts main=http://localhost:3000 +// bun scripts/search-ranks.ts before=http://localhost:4061 after=http://localhost:4062 +// bun scripts/search-ranks.ts --top 8 after=http://localhost:3000 +// +// Start a build with `bun run build && bun run start` inside apps/web. +import { parseArgs } from "node:util"; + +interface Probe { + readonly query: string; + readonly page: string; +} + +interface Target { + readonly label: string; + readonly base: string; +} + +interface SearchResult { + readonly url: string; +} + +const STORE = "/docs/core/store"; + +const PROBES: readonly Probe[] = [ + // A concept or an API name, documented on one page. + { page: "/docs/core/filters", query: "filter" }, + { page: STORE, query: "upsert" }, + { page: "/docs/core/filters", query: "eq" }, + { page: "/docs/guides/migration", query: "namespace" }, + { page: "/docs/providers/upstash", query: "namespace" }, + // A provider name, which is also a column header in the comparison tables. + { page: "/docs/providers/pinecone", query: "pinecone" }, + { page: "/docs/providers/redis", query: "redis" }, + { page: "/docs/providers/vectorize", query: "vectorize" }, + { page: "/docs/providers/upstash", query: "upstash" }, + { page: "/docs/providers/supabase", query: "supabase" }, + { page: "/docs/providers/pgvector", query: "pgvector" }, + { page: "/docs/providers/qdrant", query: "qdrant" }, + // Queries that never regressed, to catch a change that makes them worse. + { page: STORE, query: "createIndex" }, + { page: STORE, query: "topK" }, + { page: "/docs/getting-started", query: "install" }, + { page: STORE, query: "delete" }, +]; + +const pagePath = (url: string): string => url.split("#")[0] ?? url; + +const search = async (base: string, query: string): Promise => { + const url = `${base}/api/search?query=${encodeURIComponent(query)}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`${url} returned ${response.status}`); + } + + // SAFETY: /api/search returns a JSON array of results that each carry a `url`. + return (await response.json()) as SearchResult[]; +}; + +const pagesOf = (results: readonly SearchResult[]): string[] => [ + ...new Set(results.map((result) => pagePath(result.url))), +]; + +const rankOf = (pages: readonly string[], page: string): string => { + const index = pages.indexOf(page); + + return index === -1 ? "-" : `#${index + 1}`; +}; + +const parseTarget = (arg: string): Target => { + const separator = arg.indexOf("="); + const label = arg.slice(0, separator); + const base = arg.slice(separator + 1).replace(/\/$/u, ""); + + if (separator < 1 || !base) { + throw new Error(`Expected