Enable Normalization on Postgres Query (#28573)

* Enable Normalization on Postgres Query

Signed-off-by: patrick.pulfer <patrick.pulfer@digitecgalaxus.ch>
This commit is contained in:
Patrick Pulfer
2025-02-20 15:26:48 +01:00
committed by GitHub
parent ca48e7ba7a
commit 8155b0493f
9 changed files with 92 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-pg': patch
---
Enable normalization in postgres query to change the behavior of the search.
+15 -1
View File
@@ -17,7 +17,7 @@ for details on how to setup Postgres based search for your Backstage instance.
## Optional Configuration
The following is an example of the optional configuration that can be applied when using Postgres as the search backend. Currently this is mostly for just the highlight feature:
The following is an example of the optional configuration that can be applied when using Postgres as the search backend.
```yaml
search:
@@ -30,6 +30,20 @@ search:
highlightAll: false # If true the whole document will be used as the headline, ignoring the preceding three parameters. The default is false.
maxFragments: 0 # Maximum number of text fragments to display. The default value of zero selects a non-fragment-based headline generation method. A value greater than zero selects fragment-based headline generation (see the linked documentation above for more details).
fragmentDelimiter: ' ... ' # Delimiter string used to concatenate fragments. Defaults to " ... ".
normalization: 0 # Ranking functions use an integer bit mask to control document length impact on rank. The default value is 0
```
The Normalization option controls several behaviors. It is a bit mask. [Ranking Search Results](https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-RANKING) has more details.
Example with bit mask specifying more behaviours:
- 2 divides the rank by the document length
- 4 divides the rank by the mean harmonic distance between extents
```yaml
search:
pg:
normalization: 2 | 4
```
**Note:** the highlight search term feature uses `ts_headline` which has been known to potentially impact performance. You only need this minimal config to disable it should you have issues:
+4
View File
@@ -57,6 +57,10 @@ export interface Config {
*/
fragmentDelimiter?: string;
};
/**
* Ranking functions use an integer bit mask to control document length impact on rank. The default value is 0
*/
normalization?: number | string;
/**
* Batch size to use when indexing
*/
@@ -164,6 +164,8 @@ export interface PgSearchQuery {
// (undocumented)
limit: number;
// (undocumented)
normalization?: number;
// (undocumented)
offset: number;
// (undocumented)
options: PgSearchHighlightOptions;
@@ -182,6 +184,7 @@ export type PgSearchQueryTranslator = (
// @public
export type PgSearchQueryTranslatorOptions = {
highlightOptions: PgSearchHighlightOptions;
normalization?: number;
};
// @public (undocumented)
@@ -86,7 +86,10 @@ describe('PgSearchEngine', () => {
term: 'testTerm',
filters: {},
},
{ highlightOptions },
{
highlightOptions,
normalization: 0,
},
);
});
@@ -237,6 +240,7 @@ describe('PgSearchEngine', () => {
pgTerm: '("Hello" | "Hello":*)&("World" | "World":*)',
offset: 0,
limit: 26,
normalization: 0,
options: highlightOptions,
});
});
@@ -294,6 +298,7 @@ describe('PgSearchEngine', () => {
pgTerm: '("Hello" | "Hello":*)&("World" | "World":*)',
offset: 0,
limit: 26,
normalization: 0,
options: highlightOptions,
});
});
@@ -354,6 +359,7 @@ describe('PgSearchEngine', () => {
pgTerm: '("Hello" | "Hello":*)&("World" | "World":*)',
offset: 25,
limit: 26,
normalization: 0,
options: highlightOptions,
});
});
@@ -45,6 +45,7 @@ export type ConcretePgSearchQuery = {
*/
export type PgSearchQueryTranslatorOptions = {
highlightOptions: PgSearchHighlightOptions;
normalization?: number;
};
/**
@@ -86,6 +87,7 @@ export class PgSearchEngine implements SearchEngine {
private readonly logger?: LoggerService;
private readonly highlightOptions: PgSearchHighlightOptions;
private readonly indexerBatchSize: number;
private readonly normalization: number;
/**
* @deprecated This will be marked as private in a future release, please us fromConfig instead
@@ -116,9 +118,42 @@ export class PgSearchEngine implements SearchEngine {
this.highlightOptions = highlightOptions;
this.indexerBatchSize =
config.getOptionalNumber('search.pg.indexerBatchSize') ?? 1000;
this.normalization = this.getNormalizationValue(config);
this.logger = logger;
}
private getNormalizationValue(config: Config) {
const normalizationConfig =
config.getOptional('search.pg.normalization') ?? 0;
if (typeof normalizationConfig === 'number') {
return normalizationConfig;
} else if (typeof normalizationConfig === 'string') {
return this.evaluateBitwiseOrExpression(normalizationConfig);
}
this.logger?.error(
`Unknown normalization configuration: ${normalizationConfig}`,
);
return 0;
}
private evaluateBitwiseOrExpression(expression: string) {
const tokens = expression.split('|').map(token => token.trim());
const numbers = tokens.map(token => {
const num = parseInt(token, 10);
if (isNaN(num)) {
this.logger?.error(
`Unknown expression for normalization: ${expression}`,
);
return 0;
}
return num;
});
return numbers.reduce((acc, num) => acc | num, 0);
}
/**
* @deprecated This will be removed in a future release, please use fromConfig instead
*/
@@ -155,6 +190,7 @@ export class PgSearchEngine implements SearchEngine {
const offset = page * pageSize;
// We request more result to know whether there is another page
const limit = pageSize + 1;
const normalization = options.normalization || 0;
return {
pgQuery: {
@@ -168,6 +204,7 @@ export class PgSearchEngine implements SearchEngine {
types: query.types,
offset,
limit,
normalization,
options: options.highlightOptions,
},
pageSize,
@@ -190,6 +227,7 @@ export class PgSearchEngine implements SearchEngine {
async query(query: SearchQuery): Promise<IndexableResultSet> {
const { pgQuery, pageSize } = this.translator(query, {
highlightOptions: this.highlightOptions,
normalization: this.normalization,
});
const rows = await this.databaseStore.transaction(async tx =>
@@ -240,6 +240,7 @@ describe('DatabaseDocumentStore', () => {
pgTerm: 'Hello & World',
offset: 1,
limit: 1,
normalization: 0,
options: highlightOptions,
}),
);
@@ -285,6 +286,7 @@ describe('DatabaseDocumentStore', () => {
pgTerm: 'Hello & World',
offset: 0,
limit: 25,
normalization: 0,
options: highlightOptions,
}),
);
@@ -346,6 +348,7 @@ describe('DatabaseDocumentStore', () => {
types: ['my-type'],
offset: 0,
limit: 25,
normalization: 0,
options: highlightOptions,
}),
);
@@ -399,6 +402,7 @@ describe('DatabaseDocumentStore', () => {
fields: { myField: 'this' },
offset: 0,
limit: 25,
normalization: 0,
options: highlightOptions,
}),
);
@@ -465,6 +469,7 @@ describe('DatabaseDocumentStore', () => {
fields: { myField: ['this', 'that'] },
offset: 0,
limit: 25,
normalization: 0,
options: highlightOptions,
}),
);
@@ -546,6 +551,7 @@ describe('DatabaseDocumentStore', () => {
fields: { myField: 'this', otherField: 'another' },
offset: 0,
limit: 25,
normalization: 0,
options: highlightOptions,
}),
);
@@ -595,6 +601,7 @@ describe('DatabaseDocumentStore', () => {
fields: { myField: 'this' },
offset: 0,
limit: 25,
normalization: 0,
options: highlightOptions,
}),
);
@@ -148,10 +148,18 @@ export class DatabaseDocumentStore implements DatabaseStore {
tx: Knex.Transaction,
searchQuery: PgSearchQuery,
): Promise<DocumentResultRow[]> {
const { types, pgTerm, fields, offset, limit, options } = searchQuery;
const {
types,
pgTerm,
fields,
offset,
limit,
normalization = 0,
options,
} = searchQuery;
// TODO(awanlin): We should make the language a parameter so that we can support more then just english
// Builds a query like:
// SELECT ts_rank_cd(body, query) AS rank, type, document,
// SELECT ts_rank_cd(body, query, 0) AS rank, type, document,
// ts_headline('english', document, query) AS highlight
// FROM documents, to_tsquery('english', 'consent') query
// WHERE query @@ body AND (document @> '{"kind": "API"}')
@@ -194,7 +202,7 @@ export class DatabaseDocumentStore implements DatabaseStore {
if (pgTerm && options.useHighlight) {
const headlineOptions = `MaxWords=${options.maxWords}, MinWords=${options.minWords}, ShortWord=${options.shortWord}, HighlightAll=${options.highlightAll}, MaxFragments=${options.maxFragments}, FragmentDelimiter=${options.fragmentDelimiter}, StartSel=${options.preTag}, StopSel=${options.postTag}`;
query
.select(tx.raw('ts_rank_cd(body, query) AS "rank"'))
.select(tx.raw(`ts_rank_cd(body, query, ${normalization}) AS "rank"`))
.select(
tx.raw(
`ts_headline(\'english\', document, query, '${headlineOptions}') as "highlight"`,
@@ -203,7 +211,7 @@ export class DatabaseDocumentStore implements DatabaseStore {
.orderBy('rank', 'desc');
} else if (pgTerm && !options.useHighlight) {
query
.select(tx.raw('ts_rank_cd(body, query) AS "rank"'))
.select(tx.raw(`ts_rank_cd(body, query, ${normalization}) AS "rank"`))
.orderBy('rank', 'desc');
} else {
query.select(tx.raw('1 as rank'));
@@ -24,6 +24,7 @@ export interface PgSearchQuery {
pgTerm?: string;
offset: number;
limit: number;
normalization?: number;
options: PgSearchHighlightOptions;
}