Repository navigation
fix(web): restore docs search ranking after the fumadocs 16.17 bump #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
2fdfb98
test(web): add a rank check for the docs search
PunGrumpy 5312573
fix(web): index docs table cells one by one again
PunGrumpy 74b86ca
fix(web): drop the table pipes from search results
PunGrumpy 844f457
test(web): rank the docs search by page, not by result index
PunGrumpy be3d253
test(web): probe provider names and queries that never regressed
PunGrumpy 8eb58e5
fix(web): rank a page title above equal-scoring table cells
PunGrumpy 7de21ac
Merge branch 'main' into fix/docs-search-ranking
PunGrumpy 34c336e
fix(web): strip table pipes without a backtracking regex
PunGrumpy e4f90a1
style(scripts): add the blank lines ultracite 8 expects
PunGrumpy c8b924b
Merge branch 'main' into fix/docs-search-ranking
PunGrumpy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }, | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SearchResult[]> => { | ||
| 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 <label>=<base url>, got "${arg}"`); | ||
| } | ||
|
|
||
| return { base, label }; | ||
| }; | ||
|
|
||
| const { positionals, values } = parseArgs({ | ||
| allowPositionals: true, | ||
| options: { top: { default: "0", type: "string" } }, | ||
| }); | ||
|
|
||
| if (positionals.length === 0) { | ||
| throw new Error( | ||
| "Usage: bun scripts/search-ranks.ts [--top N] <label>=<base url> ..." | ||
| ); | ||
| } | ||
|
|
||
| const targets = positionals.map(parseTarget); | ||
| const top = Number(values.top); | ||
|
|
||
| const rows = await Promise.all( | ||
| PROBES.map(async ({ page, query }) => ({ | ||
| found: await Promise.all( | ||
| targets.map(async ({ base, label }) => ({ | ||
| label, | ||
| results: await search(base, query), | ||
| })) | ||
| ), | ||
| page, | ||
| query, | ||
| })) | ||
| ); | ||
|
|
||
| console.log( | ||
| `| query | page | ${targets.map((target) => target.label).join(" | ")} |` | ||
| ); | ||
|
|
||
| console.log(`| --- | --- | ${targets.map(() => "---").join(" | ")} |`); | ||
|
|
||
| for (const { found, page, query } of rows) { | ||
| const cells = found.map(({ results }) => { | ||
| const pages = pagesOf(results); | ||
|
|
||
| return `${rankOf(pages, page)} of ${pages.length}`; | ||
| }); | ||
|
|
||
| console.log(`| \`${query}\` | \`${page}\` | ${cells.join(" | ")} |`); | ||
| } | ||
|
|
||
| if (top > 0) { | ||
| for (const { found, query } of rows) { | ||
| for (const { label, results } of found) { | ||
| console.log(`\n${label} · ${query}`); | ||
|
|
||
| for (const [index, page] of pagesOf(results).slice(0, top).entries()) { | ||
| console.log(` ${index + 1}. ${page}`); | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Normalize the suffix before checking the closing pipe.
opened.endsWith("|")fails when whitespace follows the closing pipe, such as| value |. The pipe remains in the search result. The escape check also treats two consecutive backslashes before|as escaped, although that pipe is unescaped. Trim trailing whitespace first and determine escaping from the parity of consecutive backslashes.Proposed fix
Committable suggestion