From cbfda751f76fce61391b7a10d023dab9777fd67b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:50:26 -0400 Subject: [PATCH 1/3] refactor(@angular/cli): enhance example search with structured data This commit refactors the `find_examples` MCP tool and its associated database generator to leverage the structured YAML front matter present in the example markdown files. Key changes: - The SQLite database schema is now relational, with dedicated columns for `title`, `summary`, and `keywords`. - An FTS5 virtual table indexes these structured fields, improving search relevance by allowing queries to target specific metadata. - The build-time database generator (`tools/example_db_generator.js`) now parses and validates the front matter of each example file using Zod. The build will fail if an example is missing a required field (`title`, `summary`), ensuring data integrity. - The runtime tool (`packages/.../examples.ts`) uses the same parsing logic but will warn and skip invalid files to be more resilient. This change provides a more robust and accurate foundation for the example search feature, enabling more precise results and paving the way for future enhancements like semantic search. --- package.json | 1 + .../cli/src/commands/mcp/tools/examples.ts | 118 +++++++++++++++- pnpm-lock.yaml | 8 ++ tools/BUILD.bazel | 1 + tools/example_db_generator.js | 126 ++++++++++++++++-- 5 files changed, 235 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index a45ea340d64e..1de0091a0ef7 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "verdaccio": "6.1.6", "verdaccio-auth-memory": "^10.0.0", "yargs-parser": "22.0.0", + "zod": "4.1.5", "zone.js": "^0.15.0" }, "dependenciesMeta": { diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index 21cacd5454c5..8f8d8e220bc8 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -114,7 +114,9 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) db = new DatabaseSync(exampleDatabasePath, { readOnly: true }); } if (!queryStatement) { - queryStatement = db.prepare('SELECT * from examples WHERE examples MATCH ? ORDER BY rank;'); + queryStatement = db.prepare( + 'SELECT content from examples_fts WHERE examples_fts MATCH ? ORDER BY rank;', + ); } const sanitizedQuery = escapeSearchQuery(query); @@ -218,24 +220,128 @@ function suppressSqliteWarning() { }; } +/** + * A simple YAML front matter parser. + * + * This function extracts the YAML block enclosed by `---` at the beginning of a string + * and parses it into a JavaScript object. It is not a full YAML parser and only + * supports simple key-value pairs and string arrays. + * + * @param content The string content to parse. + * @returns A record containing the parsed front matter data. + */ +function parseFrontmatter(content: string): Record { + const match = content.match(/^---\r?\n(.*?)\r?\n---/s); + if (!match) { + return {}; + } + + const frontmatter = match[1]; + const data: Record = {}; + const lines = frontmatter.split(/\r?\n/); + + let currentKey = ''; + let isArray = false; + const arrayValues: string[] = []; + + for (const line of lines) { + const keyValueMatch = line.match(/^([^:]+):\s*(.*)/); + if (keyValueMatch) { + if (currentKey && isArray) { + data[currentKey] = arrayValues.slice(); + arrayValues.length = 0; + } + + const [, key, value] = keyValueMatch; + currentKey = key.trim(); + isArray = value.trim() === ''; + + if (!isArray) { + data[currentKey] = value.trim(); + } + } else { + const arrayItemMatch = line.match(/^\s*-\s*(.*)/); + if (arrayItemMatch && currentKey && isArray) { + arrayValues.push(arrayItemMatch[1].trim()); + } + } + } + + if (currentKey && isArray) { + data[currentKey] = arrayValues; + } + + return data; +} + async function setupRuntimeExamples( examplesPath: string, ): Promise { const { DatabaseSync } = await import('node:sqlite'); const db = new DatabaseSync(':memory:'); - db.exec(`CREATE VIRTUAL TABLE examples USING fts5(content, tokenize = 'porter ascii');`); + // Create a relational table to store the structured example data. + db.exec(` + CREATE TABLE examples ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + keywords TEXT, + content TEXT NOT NULL + ); + `); + + // Create an FTS5 virtual table to provide full-text search capabilities. + // It indexes the title, summary, keywords, and the full content. + db.exec(` + CREATE VIRTUAL TABLE examples_fts USING fts5( + title, + summary, + keywords, + content, + content='examples', + content_rowid='id', + tokenize = 'porter ascii' + ); + `); - const insertStatement = db.prepare('INSERT INTO examples(content) VALUES(?);'); + // Create triggers to keep the FTS table synchronized with the examples table. + db.exec(` + CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN + INSERT INTO examples_fts(rowid, title, summary, keywords, content) + VALUES (new.id, new.title, new.summary, new.keywords, new.content); + END; + `); + + const insertStatement = db.prepare( + 'INSERT INTO examples(title, summary, keywords, content) VALUES(?, ?, ?, ?);', + ); + + const frontmatterSchema = z.object({ + title: z.string(), + summary: z.string(), + keywords: z.array(z.string()).optional(), + }); db.exec('BEGIN TRANSACTION'); - for await (const entry of glob('*.md', { cwd: examplesPath, withFileTypes: true })) { + for await (const entry of glob('**/*.md', { cwd: examplesPath, withFileTypes: true })) { if (!entry.isFile()) { continue; } - const example = await readFile(path.join(entry.parentPath, entry.name), 'utf-8'); - insertStatement.run(example); + const content = await readFile(path.join(entry.parentPath, entry.name), 'utf-8'); + const frontmatter = parseFrontmatter(content); + + const validation = frontmatterSchema.safeParse(frontmatter); + if (!validation.success) { + // eslint-disable-next-line no-console + console.warn(`Skipping invalid example file ${entry.name}:`, validation.error.issues); + continue; + } + + const { title, summary, keywords } = validation.data; + + insertStatement.run(title, summary, JSON.stringify(keywords ?? []), content); } db.exec('END TRANSACTION'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c9f0e02113d..a0061ea24039 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -316,6 +316,9 @@ importers: yargs-parser: specifier: 22.0.0 version: 22.0.0 + zod: + specifier: 4.1.5 + version: 4.1.5 zone.js: specifier: ^0.15.0 version: 0.15.1 @@ -8950,6 +8953,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.5: + resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} + zone.js@0.15.1: resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} @@ -18355,4 +18361,6 @@ snapshots: zod@3.25.76: {} + zod@4.1.5: {} + zone.js@0.15.1: {} diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel index 65954dc5e6bc..59c817e3aa06 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -35,6 +35,7 @@ js_binary( name = "ng_example_db", data = [ "example_db_generator.js", + "//:node_modules/zod", ], entry_point = "example_db_generator.js", ) diff --git a/tools/example_db_generator.js b/tools/example_db_generator.js index f55303ce6d46..2259f964dd71 100644 --- a/tools/example_db_generator.js +++ b/tools/example_db_generator.js @@ -6,22 +6,66 @@ * found in the LICENSE file at https://angular.dev/license */ -const { readdirSync, readFileSync, mkdirSync, existsSync, rmSync } = require('node:fs'); -const { resolve, dirname } = require('node:path'); +const { globSync, readdirSync, readFileSync, mkdirSync, existsSync, rmSync } = require('node:fs'); +const { resolve, dirname, join } = require('node:path'); const { DatabaseSync } = require('node:sqlite'); +const { z } = require('zod'); -function generate(inPath, outPath) { - const examples = []; +/** + * A simple YAML front matter parser. + * + * This function extracts the YAML block enclosed by `---` at the beginning of a string + * and parses it into a JavaScript object. It is not a full YAML parser and only + * supports simple key-value pairs and string arrays. + * + * @param content The string content to parse. + * @returns A record containing the parsed front matter data. + */ +function parseFrontmatter(content) { + const match = content.match(/^---\r?\n(.*?)\r?\n---/s); + if (!match) { + return {}; + } - const entries = readdirSync(resolve(inPath), { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile()) { - continue; + const frontmatter = match[1]; + const data = {}; + const lines = frontmatter.split(/\r?\n/); + + let currentKey = ''; + let isArray = false; + const arrayValues = []; + + for (const line of lines) { + const keyValueMatch = line.match(/^([^:]+):\s*(.*)/); + if (keyValueMatch) { + if (currentKey && isArray) { + data[currentKey] = arrayValues.slice(); + arrayValues.length = 0; + } + + const [, key, value] = keyValueMatch; + currentKey = key.trim(); + isArray = value.trim() === ''; + + if (!isArray) { + data[currentKey] = value.trim(); + } + } else { + const arrayItemMatch = line.match(/^\s*-\s*(.*)/); + if (arrayItemMatch && currentKey && isArray) { + arrayValues.push(arrayItemMatch[1].trim()); + } } + } - examples.push(readFileSync(resolve(inPath, entry.name), 'utf-8')); + if (currentKey && isArray) { + data[currentKey] = arrayValues; } + return data; +} + +function generate(inPath, outPath) { const dbPath = outPath; mkdirSync(dirname(outPath), { recursive: true }); @@ -30,13 +74,69 @@ function generate(inPath, outPath) { } const db = new DatabaseSync(dbPath); - db.exec(`CREATE VIRTUAL TABLE examples USING fts5(content, tokenize = 'porter ascii');`); + // Create a relational table to store the structured example data. + db.exec(` + CREATE TABLE examples ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + keywords TEXT, + content TEXT NOT NULL + ); + `); + + // Create an FTS5 virtual table to provide full-text search capabilities. + db.exec(` + CREATE VIRTUAL TABLE examples_fts USING fts5( + title, + summary, + keywords, + content, + content='examples', + content_rowid='id', + tokenize = 'porter ascii' + ); + `); - const insertStatement = db.prepare('INSERT INTO examples(content) VALUES(?);'); + // Create triggers to keep the FTS table synchronized with the examples table. + db.exec(` + CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN + INSERT INTO examples_fts(rowid, title, summary, keywords, content) + VALUES (new.id, new.title, new.summary, new.keywords, new.content); + END; + `); + + const insertStatement = db.prepare( + 'INSERT INTO examples(title, summary, keywords, content) VALUES(?, ?, ?, ?);', + ); + + const frontmatterSchema = z.object({ + title: z.string(), + summary: z.string(), + keywords: z.array(z.string()).optional(), + }); db.exec('BEGIN TRANSACTION'); - for (const example of examples) { - insertStatement.run(example); + const entries = globSync + ? globSync('**/*.md', { cwd: resolve(inPath), withFileTypes: true }) + : readdirSync(resolve(inPath), { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.md')) { + continue; + } + + const content = readFileSync(join(entry.parentPath, entry.name), 'utf-8'); + const frontmatter = parseFrontmatter(content); + + const validation = frontmatterSchema.safeParse(frontmatter); + if (!validation.success) { + console.error(`Validation failed for example file: ${entry.name}`); + console.error('Issues:', validation.error.issues); + throw new Error(`Invalid front matter in ${entry.name}`); + } + + const { title, summary, keywords } = validation.data; + insertStatement.run(title, summary, JSON.stringify(keywords ?? []), content); } db.exec('END TRANSACTION'); From 49cdaa178f85e8b6e1f7b4a4f00c2ef69ea95f30 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:33:27 -0400 Subject: [PATCH 2/3] feat(@angular/cli): add advanced filtering to MCP example search This commit enhances the `find_examples` MCP tool by introducing advanced filtering capabilities, allowing for more precise and powerful queries. The tool's input schema now accepts optional array-based filters for `keywords`, `required_packages`, and `related_concepts`. These filters are combined with the main full-text search query to narrow down results. To support this, the underlying SQLite database schema has been extended with dedicated columns for this metadata. The build-time database generator and the runtime tool have both been updated to parse, validate, and store this structured data from the example file's front matter. The query logic is now fully dynamic, constructing parameterized SQL queries to safely and efficiently filter the examples based on the provided criteria. --- .../cli/src/commands/mcp/tools/examples.ts | 91 ++++++++++++++++--- tools/example_db_generator.js | 36 +++++++- 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index 8f8d8e220bc8..56d5bcc29568 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -8,6 +8,7 @@ import { glob, readFile } from 'node:fs/promises'; import path from 'node:path'; +import type { SQLInputValue } from 'node:sqlite'; import { z } from 'zod'; import { McpToolContext, declareTool } from './tool-registry'; @@ -36,6 +37,15 @@ Examples of queries: - Find lazy loading a route: 'lazy load route' - Find forms with validation: 'form AND (validation OR validator)'`, ), + keywords: z.array(z.string()).optional().describe('Filter examples by specific keywords.'), + required_packages: z + .array(z.string()) + .optional() + .describe('Filter examples by required NPM packages (e.g., "@angular/forms").'), + related_concepts: z + .array(z.string()) + .optional() + .describe('Filter examples by related high-level concepts.'), }); type FindExampleInput = z.infer; @@ -55,7 +65,9 @@ new or evolving features. * **Modern Implementation:** Finding the correct modern syntax for features (e.g., query: 'functional route guard' or 'http client with fetch'). * **Refactoring to Modern Patterns:** Upgrading older code by finding examples of new syntax - (e.g., query: 'built-in control flow' to replace "*ngIf'). + (e.g., query: 'built-in control flow' to replace "*ngIf"). +* **Advanced Filtering:** Combining a full-text search with filters to narrow results. + (e.g., query: 'forms', required_packages: ['@angular/forms'], keywords: ['validation']) * **Tool Selection:** This database primarily contains examples for new and recently updated Angular @@ -64,6 +76,8 @@ new or evolving features. * The examples in this database are the single source of truth for modern Angular coding patterns. * The search query uses a powerful full-text search syntax (FTS5). Refer to the 'query' parameter description for detailed syntax rules and examples. +* You can combine the main 'query' with optional filters like 'keywords', 'required_packages', + and 'related_concepts' to create highly specific searches. `, inputSchema: findExampleInputSchema.shape, outputSchema: { @@ -104,7 +118,7 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) suppressSqliteWarning(); - return async ({ query }: FindExampleInput) => { + return async (input: FindExampleInput) => { if (!db) { if (!exampleDatabasePath) { // This should be prevented by the registration logic in mcp-server.ts @@ -113,18 +127,45 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) const { DatabaseSync } = await import('node:sqlite'); db = new DatabaseSync(exampleDatabasePath, { readOnly: true }); } - if (!queryStatement) { - queryStatement = db.prepare( - 'SELECT content from examples_fts WHERE examples_fts MATCH ? ORDER BY rank;', - ); + + const { query, keywords, required_packages, related_concepts } = input; + + // Build the query dynamically + const params: SQLInputValue[] = []; + let sql = 'SELECT content FROM examples_fts'; + const whereClauses = []; + + // FTS query + if (query) { + whereClauses.push('examples_fts MATCH ?'); + params.push(escapeSearchQuery(query)); } - const sanitizedQuery = escapeSearchQuery(query); + // JSON array filters + const addJsonFilter = (column: string, values: string[] | undefined) => { + if (values?.length) { + for (const value of values) { + whereClauses.push(`${column} LIKE ?`); + params.push(`%"${value}"%`); + } + } + }; + + addJsonFilter('keywords', keywords); + addJsonFilter('required_packages', required_packages); + addJsonFilter('related_concepts', related_concepts); + + if (whereClauses.length > 0) { + sql += ` WHERE ${whereClauses.join(' AND ')}`; + } + sql += ' ORDER BY rank;'; + + const queryStatement = db.prepare(sql); // Query database and return results const examples = []; const textContent = []; - for (const exampleRecord of queryStatement.all(sanitizedQuery)) { + for (const exampleRecord of queryStatement.all(...params)) { const exampleContent = exampleRecord['content'] as string; examples.push({ content: exampleContent }); textContent.push({ type: 'text' as const, text: exampleContent }); @@ -287,17 +328,22 @@ async function setupRuntimeExamples( title TEXT NOT NULL, summary TEXT NOT NULL, keywords TEXT, + required_packages TEXT, + related_concepts TEXT, + related_tools TEXT, content TEXT NOT NULL ); `); // Create an FTS5 virtual table to provide full-text search capabilities. - // It indexes the title, summary, keywords, and the full content. db.exec(` CREATE VIRTUAL TABLE examples_fts USING fts5( title, summary, keywords, + required_packages, + related_concepts, + related_tools, content, content='examples', content_rowid='id', @@ -308,19 +354,27 @@ async function setupRuntimeExamples( // Create triggers to keep the FTS table synchronized with the examples table. db.exec(` CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN - INSERT INTO examples_fts(rowid, title, summary, keywords, content) - VALUES (new.id, new.title, new.summary, new.keywords, new.content); + INSERT INTO examples_fts(rowid, title, summary, keywords, required_packages, related_concepts, related_tools, content) + VALUES ( + new.id, new.title, new.summary, new.keywords, new.required_packages, new.related_concepts, + new.related_tools, new.content + ); END; `); const insertStatement = db.prepare( - 'INSERT INTO examples(title, summary, keywords, content) VALUES(?, ?, ?, ?);', + 'INSERT INTO examples(' + + 'title, summary, keywords, required_packages, related_concepts, related_tools, content' + + ') VALUES(?, ?, ?, ?, ?, ?, ?);', ); const frontmatterSchema = z.object({ title: z.string(), summary: z.string(), keywords: z.array(z.string()).optional(), + required_packages: z.array(z.string()).optional(), + related_concepts: z.array(z.string()).optional(), + related_tools: z.array(z.string()).optional(), }); db.exec('BEGIN TRANSACTION'); @@ -339,9 +393,18 @@ async function setupRuntimeExamples( continue; } - const { title, summary, keywords } = validation.data; + const { title, summary, keywords, required_packages, related_concepts, related_tools } = + validation.data; - insertStatement.run(title, summary, JSON.stringify(keywords ?? []), content); + insertStatement.run( + title, + summary, + JSON.stringify(keywords ?? []), + JSON.stringify(required_packages ?? []), + JSON.stringify(related_concepts ?? []), + JSON.stringify(related_tools ?? []), + content, + ); } db.exec('END TRANSACTION'); diff --git a/tools/example_db_generator.js b/tools/example_db_generator.js index 2259f964dd71..c85b63e497cf 100644 --- a/tools/example_db_generator.js +++ b/tools/example_db_generator.js @@ -81,6 +81,9 @@ function generate(inPath, outPath) { title TEXT NOT NULL, summary TEXT NOT NULL, keywords TEXT, + required_packages TEXT, + related_concepts TEXT, + related_tools TEXT, content TEXT NOT NULL ); `); @@ -91,6 +94,9 @@ function generate(inPath, outPath) { title, summary, keywords, + required_packages, + related_concepts, + related_tools, content, content='examples', content_rowid='id', @@ -101,19 +107,30 @@ function generate(inPath, outPath) { // Create triggers to keep the FTS table synchronized with the examples table. db.exec(` CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN - INSERT INTO examples_fts(rowid, title, summary, keywords, content) - VALUES (new.id, new.title, new.summary, new.keywords, new.content); + INSERT INTO examples_fts( + rowid, title, summary, keywords, required_packages, related_concepts, related_tools, + content + ) + VALUES ( + new.id, new.title, new.summary, new.keywords, new.required_packages, + new.related_concepts, new.related_tools, new.content + ); END; `); const insertStatement = db.prepare( - 'INSERT INTO examples(title, summary, keywords, content) VALUES(?, ?, ?, ?);', + 'INSERT INTO examples(' + + 'title, summary, keywords, required_packages, related_concepts, related_tools, content' + + ') VALUES(?, ?, ?, ?, ?, ?, ?);', ); const frontmatterSchema = z.object({ title: z.string(), summary: z.string(), keywords: z.array(z.string()).optional(), + required_packages: z.array(z.string()).optional(), + related_concepts: z.array(z.string()).optional(), + related_tools: z.array(z.string()).optional(), }); db.exec('BEGIN TRANSACTION'); @@ -135,8 +152,17 @@ function generate(inPath, outPath) { throw new Error(`Invalid front matter in ${entry.name}`); } - const { title, summary, keywords } = validation.data; - insertStatement.run(title, summary, JSON.stringify(keywords ?? []), content); + const { title, summary, keywords, required_packages, related_concepts, related_tools } = + validation.data; + insertStatement.run( + title, + summary, + JSON.stringify(keywords ?? []), + JSON.stringify(required_packages ?? []), + JSON.stringify(related_concepts ?? []), + JSON.stringify(related_tools ?? []), + content, + ); } db.exec('END TRANSACTION'); From 1e23332761d8c5c7fd3457d3d67ec9fafcc5814f Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:08:45 -0400 Subject: [PATCH 3/3] refactor(@angular/cli): implement weighted search for MCP examples This commit improves the relevance of the `find_examples` MCP server tool by implementing weighted search ranking. The FTS5 query now uses the `bm25()` ranking function to assign a higher weight to matches found in more important fields. Specifically, matches in the `title`, `summary`, and `keywords` are now weighted more heavily than matches in the main content. This results in more accurate and intuitive search results, as examples where the query terms are central to the topic are ranked higher. --- packages/angular/cli/src/commands/mcp/tools/examples.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index 56d5bcc29568..2a066535d302 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -158,7 +158,12 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) if (whereClauses.length > 0) { sql += ` WHERE ${whereClauses.join(' AND ')}`; } - sql += ' ORDER BY rank;'; + + // Order the results by relevance using the BM25 algorithm. + // The weights assigned to each column boost the ranking of documents where the + // search term appears in a more important field. + // Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content + sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);'; const queryStatement = db.prepare(sql);