@byline/search-postgres 4.7.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -28
- package/dist/build-index-row.d.ts +4 -3
- package/dist/build-index-row.js +4 -3
- package/dist/build-index-row.test.node.js +0 -18
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +22 -0
- package/dist/index.d.ts +11 -14
- package/dist/index.js +4 -4
- package/dist/migrations-data.js +17 -6
- package/dist/portable-index-vector.d.ts +20 -0
- package/dist/portable-index-vector.js +108 -0
- package/dist/portable-index-vector.test.node.d.ts +8 -0
- package/dist/portable-index-vector.test.node.js +61 -0
- package/dist/portable-query.d.ts +17 -0
- package/dist/portable-query.js +110 -0
- package/dist/portable-query.test.node.d.ts +8 -0
- package/dist/portable-query.test.node.js +48 -0
- package/dist/postgres-search-provider.d.ts +6 -20
- package/dist/postgres-search-provider.js +195 -98
- package/migrations/0001_init.sql +17 -6
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
# @byline/search-postgres
|
|
2
2
|
|
|
3
|
-
The built-in
|
|
4
|
-
|
|
5
|
-
ranked search with **zero new
|
|
6
|
-
connection.
|
|
3
|
+
The built-in PostgreSQL full-text `SearchProvider` for Byline CMS. It combines
|
|
4
|
+
the portable multilingual analyzer from `@byline/search-analysis` with a
|
|
5
|
+
weighted, GIN-indexed `tsvector`, providing ranked search with **zero new
|
|
6
|
+
infrastructure** and reusing the existing PostgreSQL connection.
|
|
7
7
|
|
|
8
8
|
It consumes the type-enriched `SearchDocument` that core assembles
|
|
9
9
|
(`buildSearchDocument`) and stores one weighted row per
|
|
10
|
-
`(collection_path, document_id, locale)`:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
`(collection_path, document_id, locale)`: body fields → `A`–`D` by their
|
|
11
|
+
declared `boost`, facet **terms** → `C` (folded into the searchable vector),
|
|
12
|
+
with facet **ids** and filterable scalars kept as `jsonb` for future
|
|
13
|
+
aggregation and filtering. A collection's title remains display-only unless
|
|
14
|
+
its identity field is also declared in `search.body`.
|
|
14
15
|
|
|
15
|
-
See
|
|
16
|
-
|
|
16
|
+
See
|
|
17
|
+
[`docs/06-search/06-postgres-and-mysql.md`](../../docs/06-search/06-postgres-and-mysql.md)
|
|
18
|
+
for the built-in provider comparison and operational reference.
|
|
17
19
|
|
|
18
20
|
## Install
|
|
19
21
|
|
|
@@ -54,6 +56,25 @@ migration stream — it ships its own numbered SQL files in
|
|
|
54
56
|
`byline_search_migrations` table. There are three ways to apply them; pick per
|
|
55
57
|
environment.
|
|
56
58
|
|
|
59
|
+
### Portable-analysis cutover
|
|
60
|
+
|
|
61
|
+
The portable-analysis release replaces the original native PostgreSQL search
|
|
62
|
+
schema directly. There is no in-place compatibility migration: search data is
|
|
63
|
+
a disposable projection of published documents. Before deploying this version
|
|
64
|
+
over an older `@byline/search-postgres` installation, drop only the three
|
|
65
|
+
driver-owned tables, reapply `0001_init.sql`, and rebuild each searchable
|
|
66
|
+
collection:
|
|
67
|
+
|
|
68
|
+
```sql
|
|
69
|
+
DROP TABLE IF EXISTS byline_search_index_metadata;
|
|
70
|
+
DROP TABLE IF EXISTS byline_search_documents;
|
|
71
|
+
DROP TABLE IF EXISTS byline_search_migrations;
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This does not remove CMS documents. After applying the new schema, run the
|
|
75
|
+
normal `client.reindex()` workflow so the portable index is reconstructed from
|
|
76
|
+
published versions.
|
|
77
|
+
|
|
57
78
|
### 1. Run the SQL by hand (locked-down / managed Postgres)
|
|
58
79
|
|
|
59
80
|
The numbered files are the source of truth and are DBA-reviewable:
|
|
@@ -88,29 +109,36 @@ option 2 so startup is deterministic and DDL permissions are explicit.
|
|
|
88
109
|
```ts
|
|
89
110
|
provider.capabilities
|
|
90
111
|
// { facets: false, typoTolerance: false, semantic: false,
|
|
91
|
-
// bm25: false, weighting: true, highlights: true
|
|
112
|
+
// bm25: false, weighting: true, highlights: true,
|
|
113
|
+
// fullText: {
|
|
114
|
+
// nativeAnalysis: false, portableAnalysis: true,
|
|
115
|
+
// allTerms: true, anyTerms: true,
|
|
116
|
+
// minimumShouldMatch: true, phrase: true
|
|
117
|
+
// } }
|
|
92
118
|
```
|
|
93
119
|
|
|
94
|
-
The `tsvector` + `ts_rank` floor
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
The `tsvector` + `ts_rank` floor supports per-field **weighting** and all shared
|
|
121
|
+
full-text matching policies. Ranked rows are highlighted from stored original
|
|
122
|
+
body text through the shared portable token offsets, so snippets preserve the
|
|
123
|
+
source spelling while matching normalized and expanded terms. Facet *data* is
|
|
124
|
+
indexed, but facet *aggregation* queries, structured `where` filtering, fuzzy
|
|
125
|
+
matching, BM25 ranking, and semantic/vector retrieval are follow-ups. The
|
|
126
|
+
capability flags let consumers enable only supported behavior.
|
|
99
127
|
|
|
100
128
|
## Language / locale
|
|
101
129
|
|
|
102
|
-
Search is per
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
to `
|
|
106
|
-
|
|
107
|
-
`defaultLocale` so locale-less queries use your default content locale:
|
|
130
|
+
Search is stored per locale. The portable analyzer applies Unicode
|
|
131
|
+
normalization, ICU word segmentation, identifier preservation, optional
|
|
132
|
+
language expanders, and Han bigrams before the adapter writes parser-safe
|
|
133
|
+
physical terms to PostgreSQL. `defaultLocale` supplies the analyzer fallback
|
|
134
|
+
when content or a query does not declare a usable locale:
|
|
108
135
|
|
|
109
136
|
```ts
|
|
110
|
-
postgresSearch({
|
|
111
|
-
pool: db.pool,
|
|
112
|
-
defaultLocale: 'en', // regconfig for searches that omit `locale`
|
|
113
|
-
localeRegconfig: { th: 'thai' }, // a custom dictionary you've installed
|
|
114
|
-
fallbackRegconfig: 'simple',
|
|
115
|
-
})
|
|
137
|
+
postgresSearch({ pool: db.pool, defaultLocale: 'en' })
|
|
116
138
|
```
|
|
139
|
+
|
|
140
|
+
For language-specific stemming or lemmatization, construct a portable analyzer
|
|
141
|
+
with versioned expanders and pass it as `analyzer`. The provider persists its
|
|
142
|
+
fingerprint per collection. If that fingerprint changes, clear and rebuild the
|
|
143
|
+
affected collection before it accepts new writes or searches; this prevents
|
|
144
|
+
mixed token pipelines from producing incomplete results.
|
|
@@ -39,8 +39,9 @@ export interface IndexRow {
|
|
|
39
39
|
export declare function weightClass(boost: number | undefined, defaultClass: WeightClass): WeightClass;
|
|
40
40
|
/**
|
|
41
41
|
* Transform a `SearchDocument` into the flat `IndexRow` the provider writes.
|
|
42
|
-
* Pure — no SQL, no DB.
|
|
43
|
-
*
|
|
44
|
-
* ids are projected into `facets`; filters are
|
|
42
|
+
* Pure — no SQL, no DB. `title` is display-only and never weighted (see the
|
|
43
|
+
* note below); body text weights by boost (default `B`); facet terms weight by
|
|
44
|
+
* boost (default `C`) and their ids are projected into `facets`; filters are
|
|
45
|
+
* projected as-is.
|
|
45
46
|
*/
|
|
46
47
|
export declare function buildIndexRow(doc: SearchDocument): IndexRow;
|
package/dist/build-index-row.js
CHANGED
|
@@ -22,9 +22,10 @@ export function weightClass(boost, defaultClass) {
|
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
24
|
* Transform a `SearchDocument` into the flat `IndexRow` the provider writes.
|
|
25
|
-
* Pure — no SQL, no DB.
|
|
26
|
-
*
|
|
27
|
-
* ids are projected into `facets`; filters are
|
|
25
|
+
* Pure — no SQL, no DB. `title` is display-only and never weighted (see the
|
|
26
|
+
* note below); body text weights by boost (default `B`); facet terms weight by
|
|
27
|
+
* boost (default `C`) and their ids are projected into `facets`; filters are
|
|
28
|
+
* projected as-is.
|
|
28
29
|
*/
|
|
29
30
|
export function buildIndexRow(doc) {
|
|
30
31
|
const weighted = { A: [], B: [], C: [], D: [] };
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
9
9
|
import { buildIndexRow, weightClass } from './build-index-row.js';
|
|
10
|
-
import { createRegconfigResolver } from './locale-regconfig.js';
|
|
11
10
|
function doc(overrides = {}) {
|
|
12
11
|
return {
|
|
13
12
|
collectionPath: 'publications',
|
|
@@ -103,20 +102,3 @@ describe('buildIndexRow', () => {
|
|
|
103
102
|
expect(row.body).toBe('Body text.\nEcology');
|
|
104
103
|
});
|
|
105
104
|
});
|
|
106
|
-
describe('createRegconfigResolver', () => {
|
|
107
|
-
const resolve = createRegconfigResolver();
|
|
108
|
-
it('maps known locales (and their base) to a Postgres regconfig', () => {
|
|
109
|
-
expect(resolve('en')).toBe('english');
|
|
110
|
-
expect(resolve('fr')).toBe('french');
|
|
111
|
-
expect(resolve('fr-CA')).toBe('french');
|
|
112
|
-
});
|
|
113
|
-
it('falls back to simple for unknown locales / undefined', () => {
|
|
114
|
-
expect(resolve('th')).toBe('simple');
|
|
115
|
-
expect(resolve(undefined)).toBe('simple');
|
|
116
|
-
});
|
|
117
|
-
it('honours overrides and a custom fallback', () => {
|
|
118
|
-
const custom = createRegconfigResolver({ th: 'thai' }, 'english');
|
|
119
|
-
expect(custom('th')).toBe('thai');
|
|
120
|
-
expect(custom('xx')).toBe('english');
|
|
121
|
-
});
|
|
122
|
-
});
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export declare class SearchAnalyzerMismatchError extends Error {
|
|
9
|
+
readonly collectionPath: string;
|
|
10
|
+
readonly expectedFingerprint: string;
|
|
11
|
+
readonly actualFingerprint: string | null;
|
|
12
|
+
readonly code = "SEARCH_INDEX_REINDEX_REQUIRED";
|
|
13
|
+
constructor(collectionPath: string, expectedFingerprint: string, actualFingerprint: string | null);
|
|
14
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export class SearchAnalyzerMismatchError extends Error {
|
|
9
|
+
collectionPath;
|
|
10
|
+
expectedFingerprint;
|
|
11
|
+
actualFingerprint;
|
|
12
|
+
code = 'SEARCH_INDEX_REINDEX_REQUIRED';
|
|
13
|
+
constructor(collectionPath, expectedFingerprint, actualFingerprint) {
|
|
14
|
+
const actual = actualFingerprint ?? 'an unversioned analyzer';
|
|
15
|
+
super(`Search index for collection "${collectionPath}" uses "${actual}"; ` +
|
|
16
|
+
`expected analyzer "${expectedFingerprint}". Rebuild this collection's search index.`);
|
|
17
|
+
this.collectionPath = collectionPath;
|
|
18
|
+
this.expectedFingerprint = expectedFingerprint;
|
|
19
|
+
this.actualFingerprint = actualFingerprint;
|
|
20
|
+
this.name = 'SearchAnalyzerMismatchError';
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
import type { SearchProvider } from '@byline/core';
|
|
30
|
+
import { type PortableSearchAnalyzer } from '@byline/search-analysis';
|
|
30
31
|
import type { Pool } from 'pg';
|
|
31
32
|
export { buildIndexRow, type IndexRow, type WeightClass, weightClass } from './build-index-row.js';
|
|
32
|
-
export {
|
|
33
|
+
export { SearchAnalyzerMismatchError } from './errors.js';
|
|
33
34
|
export { type MigrateOptions, type MigrateResult, migrate } from './migrate.js';
|
|
34
35
|
export { PostgresSearchProvider } from './postgres-search-provider.js';
|
|
35
36
|
export interface PostgresSearchOptions {
|
|
@@ -47,22 +48,18 @@ export interface PostgresSearchOptions {
|
|
|
47
48
|
*/
|
|
48
49
|
autoMigrate?: boolean;
|
|
49
50
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
51
|
+
* Locale used when neither indexed content nor a query supplies one.
|
|
52
|
+
* Defaults to `en`.
|
|
52
53
|
*/
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Fallback `regconfig` for locales not in the map. Defaults to `'simple'`
|
|
56
|
-
* (no stemming / stop-words — unstemmed but correct).
|
|
57
|
-
*/
|
|
58
|
-
fallbackRegconfig?: string;
|
|
54
|
+
defaultLocale?: string;
|
|
59
55
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
56
|
+
* Custom portable analyzer, typically carrying versioned language
|
|
57
|
+
* expanders. Defaults to `createPortableSearchAnalyzer({ defaultLocale })`.
|
|
58
|
+
*
|
|
59
|
+
* Changing the analyzer fingerprint requires rebuilding the affected
|
|
60
|
+
* collections before they can be searched or indexed.
|
|
64
61
|
*/
|
|
65
|
-
|
|
62
|
+
analyzer?: PortableSearchAnalyzer;
|
|
66
63
|
/** Optional sink for migration progress lines (e.g. the host logger). */
|
|
67
64
|
log?: (message: string) => void;
|
|
68
65
|
}
|
package/dist/index.js
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { createPortableSearchAnalyzer } from '@byline/search-analysis';
|
|
9
9
|
import { migrate } from './migrate.js';
|
|
10
10
|
import { PostgresSearchProvider } from './postgres-search-provider.js';
|
|
11
11
|
export { buildIndexRow, weightClass } from './build-index-row.js';
|
|
12
|
-
export {
|
|
12
|
+
export { SearchAnalyzerMismatchError } from './errors.js';
|
|
13
13
|
export { migrate } from './migrate.js';
|
|
14
14
|
export { PostgresSearchProvider } from './postgres-search-provider.js';
|
|
15
15
|
/**
|
|
@@ -22,7 +22,7 @@ export { PostgresSearchProvider } from './postgres-search-provider.js';
|
|
|
22
22
|
* `migrate(pool)` explicitly during boot instead.
|
|
23
23
|
*/
|
|
24
24
|
export function postgresSearch(options) {
|
|
25
|
-
const
|
|
25
|
+
const analyzer = options.analyzer ?? createPortableSearchAnalyzer({ defaultLocale: options.defaultLocale });
|
|
26
26
|
if (options.autoMigrate === true) {
|
|
27
27
|
void migrate(options.pool, { log: options.log }).catch((error) => {
|
|
28
28
|
const message = `[search-postgres] autoMigrate failed: ${error.message}`;
|
|
@@ -32,5 +32,5 @@ export function postgresSearch(options) {
|
|
|
32
32
|
console.error(message);
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
|
-
return new PostgresSearchProvider(options.pool,
|
|
35
|
+
return new PostgresSearchProvider(options.pool, analyzer);
|
|
36
36
|
}
|
package/dist/migrations-data.js
CHANGED
|
@@ -11,11 +11,11 @@ export const MIGRATIONS = [
|
|
|
11
11
|
name: '0001_init.sql',
|
|
12
12
|
sql: `-- @byline/search-postgres — 0001_init
|
|
13
13
|
--
|
|
14
|
-
-- The full-text search index, owned entirely by this driver. One
|
|
15
|
-
-- (collection_path, document_id, locale).
|
|
16
|
-
--
|
|
17
|
-
--
|
|
18
|
-
--
|
|
14
|
+
-- The disposable full-text search index, owned entirely by this driver. One
|
|
15
|
+
-- row per (collection_path, document_id, locale). \`search_vector\` stores
|
|
16
|
+
-- parser-safe physical tokens produced by @byline/search-analysis and
|
|
17
|
+
-- \`analyzer_fingerprint\` identifies the exact portable analysis pipeline.
|
|
18
|
+
-- Search indexes created with an older schema must be dropped and rebuilt.
|
|
19
19
|
--
|
|
20
20
|
-- Idempotent (IF NOT EXISTS throughout) so re-applying is safe. The driver's
|
|
21
21
|
-- migration runner records applied versions in byline_search_migrations.
|
|
@@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS byline_search_documents (
|
|
|
29
29
|
title text NOT NULL DEFAULT '',
|
|
30
30
|
path text,
|
|
31
31
|
body text NOT NULL DEFAULT '',
|
|
32
|
-
search_vector tsvector,
|
|
32
|
+
search_vector tsvector NOT NULL,
|
|
33
|
+
analyzer_fingerprint text NOT NULL,
|
|
33
34
|
facets jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
34
35
|
filters jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
35
36
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
@@ -51,6 +52,16 @@ CREATE INDEX IF NOT EXISTS byline_search_documents_facets_idx
|
|
|
51
52
|
-- Single-collection scoping + status filtering.
|
|
52
53
|
CREATE INDEX IF NOT EXISTS byline_search_documents_collection_idx
|
|
53
54
|
ON byline_search_documents (collection_path, status);
|
|
55
|
+
|
|
56
|
+
-- One locked row per collection prevents concurrent processes with different
|
|
57
|
+
-- analyzer fingerprints from mixing incompatible projections. Collection
|
|
58
|
+
-- zones let fingerprint guards retain zone scope without scanning documents.
|
|
59
|
+
CREATE TABLE IF NOT EXISTS byline_search_index_metadata (
|
|
60
|
+
collection_path text PRIMARY KEY,
|
|
61
|
+
analyzer_fingerprint text NOT NULL,
|
|
62
|
+
zones text[] NOT NULL DEFAULT '{}',
|
|
63
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
64
|
+
);
|
|
54
65
|
`,
|
|
55
66
|
},
|
|
56
67
|
];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { type PortableSearchAnalyzer } from '@byline/search-analysis';
|
|
9
|
+
import type { IndexRow } from './build-index-row.js';
|
|
10
|
+
export interface PortableIndexVector {
|
|
11
|
+
/** PostgreSQL text representation accepted by `$n::tsvector`. */
|
|
12
|
+
value: string;
|
|
13
|
+
analyzerFingerprint: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build a weighted tsvector literal from portable logical terms. Expansions
|
|
17
|
+
* share their source position, derived variants lose one weight class, and
|
|
18
|
+
* Han grams always use the lowest class.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildPortableIndexVector(row: Pick<IndexRow, 'locale' | 'weighted'>, analyzer: PortableSearchAnalyzer): PortableIndexVector;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { encodeSqlToken, } from '@byline/search-analysis';
|
|
9
|
+
const WEIGHT_CLASSES = ['A', 'B', 'C', 'D'];
|
|
10
|
+
const MAX_TSVECTOR_POSITION = 16_383;
|
|
11
|
+
/**
|
|
12
|
+
* Build a weighted tsvector literal from portable logical terms. Expansions
|
|
13
|
+
* share their source position, derived variants lose one weight class, and
|
|
14
|
+
* Han grams always use the lowest class.
|
|
15
|
+
*/
|
|
16
|
+
export function buildPortableIndexVector(row, analyzer) {
|
|
17
|
+
const lexemes = new Map();
|
|
18
|
+
let positionBase = 0;
|
|
19
|
+
for (const weight of WEIGHT_CLASSES) {
|
|
20
|
+
const analyzed = analyzer.analyzeText({ text: row.weighted[weight], locale: row.locale });
|
|
21
|
+
const sourcePositionCount = Math.max(-1, ...analyzed.exactTokens.map(positionOf), ...analyzed.identifierTokens.map(positionOf)) + 1;
|
|
22
|
+
const gramPositions = positionGrams(analyzed.gramTokens, positionBase + sourcePositionCount + 1);
|
|
23
|
+
for (const token of analyzed.tokens) {
|
|
24
|
+
const physical = encodeSqlToken(token);
|
|
25
|
+
const position = gramPositions.get(token) ?? positionBase + token.position + 1;
|
|
26
|
+
const effectiveWeight = tokenWeight(token, weight);
|
|
27
|
+
addLexeme(lexemes, physical, position, effectiveWeight);
|
|
28
|
+
}
|
|
29
|
+
// Keep a one-position boundary between weight buckets so phrases cannot
|
|
30
|
+
// bridge independently configured fields solely because they were joined.
|
|
31
|
+
positionBase = Math.max(positionBase + sourcePositionCount, ...gramPositions.values()) + 1;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
value: renderTsvector(lexemes),
|
|
35
|
+
analyzerFingerprint: analyzer.fingerprint,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function positionOf(token) {
|
|
39
|
+
return token.position;
|
|
40
|
+
}
|
|
41
|
+
function positionGrams(grams, firstPosition) {
|
|
42
|
+
const positioned = new Map();
|
|
43
|
+
const ordered = grams.toSorted((left, right) => left.normalizedStart - right.normalizedStart || left.normalizedEnd - right.normalizedEnd);
|
|
44
|
+
let position = firstPosition;
|
|
45
|
+
let previous;
|
|
46
|
+
for (const gram of ordered) {
|
|
47
|
+
// Overlapping bigrams in one Han run occupy consecutive positions.
|
|
48
|
+
// Leave a gap between distinct runs so a phrase cannot bridge intervening
|
|
49
|
+
// non-Han content.
|
|
50
|
+
if (previous != null && gram.normalizedStart >= previous.normalizedEnd)
|
|
51
|
+
position += 1;
|
|
52
|
+
positioned.set(gram, position);
|
|
53
|
+
position += 1;
|
|
54
|
+
previous = gram;
|
|
55
|
+
}
|
|
56
|
+
return positioned;
|
|
57
|
+
}
|
|
58
|
+
function tokenWeight(token, sourceWeight) {
|
|
59
|
+
if (token.kind === 'gram')
|
|
60
|
+
return 'D';
|
|
61
|
+
if (token.kind === 'exact' || token.kind === 'identifier')
|
|
62
|
+
return sourceWeight;
|
|
63
|
+
return lowerWeight(sourceWeight);
|
|
64
|
+
}
|
|
65
|
+
function lowerWeight(weight) {
|
|
66
|
+
switch (weight) {
|
|
67
|
+
case 'A':
|
|
68
|
+
return 'B';
|
|
69
|
+
case 'B':
|
|
70
|
+
return 'C';
|
|
71
|
+
case 'C':
|
|
72
|
+
case 'D':
|
|
73
|
+
return 'D';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function addLexeme(lexemes, lexeme, position, weight) {
|
|
77
|
+
let entry = lexemes.get(lexeme);
|
|
78
|
+
if (entry == null) {
|
|
79
|
+
entry = { positions: new Map(), beyondPositionLimit: false };
|
|
80
|
+
lexemes.set(lexeme, entry);
|
|
81
|
+
}
|
|
82
|
+
if (position > MAX_TSVECTOR_POSITION) {
|
|
83
|
+
entry.beyondPositionLimit = true;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const current = entry.positions.get(position);
|
|
87
|
+
if (current == null || weightRank(weight) > weightRank(current)) {
|
|
88
|
+
entry.positions.set(position, weight);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function weightRank(weight) {
|
|
92
|
+
return WEIGHT_CLASSES.length - WEIGHT_CLASSES.indexOf(weight);
|
|
93
|
+
}
|
|
94
|
+
function renderTsvector(lexemes) {
|
|
95
|
+
return [...lexemes.entries()]
|
|
96
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
97
|
+
.map(([lexeme, entry]) => {
|
|
98
|
+
const positions = [...entry.positions.entries()]
|
|
99
|
+
.sort(([left], [right]) => left - right)
|
|
100
|
+
.map(([position, weight]) => `${position}${weight}`);
|
|
101
|
+
// A bare lexeme still participates in matching when all of its
|
|
102
|
+
// occurrences exceed PostgreSQL's positional ceiling.
|
|
103
|
+
if (positions.length === 0 && entry.beyondPositionLimit)
|
|
104
|
+
return `'${lexeme}'`;
|
|
105
|
+
return positions.length > 0 ? `'${lexeme}':${positions.join(',')}` : `'${lexeme}'`;
|
|
106
|
+
})
|
|
107
|
+
.join(' ');
|
|
108
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { createPortableSearchAnalyzer, encodeSqlToken, } from '@byline/search-analysis';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { buildPortableIndexVector } from './portable-index-vector.js';
|
|
11
|
+
const englishExpander = {
|
|
12
|
+
fingerprint: 'english-test1',
|
|
13
|
+
supports: (locale) => locale.startsWith('en'),
|
|
14
|
+
expand: (token) => (token.value === 'running' ? [{ kind: 'stem', value: 'run' }] : []),
|
|
15
|
+
};
|
|
16
|
+
function row(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
collectionPath: 'publications',
|
|
19
|
+
documentId: 'doc-1',
|
|
20
|
+
locale: 'en',
|
|
21
|
+
status: 'published',
|
|
22
|
+
zones: ['site'],
|
|
23
|
+
title: 'Running database',
|
|
24
|
+
path: 'running-database',
|
|
25
|
+
body: 'Running 数据库',
|
|
26
|
+
weighted: { A: 'Running 数据库', B: '', C: '', D: '' },
|
|
27
|
+
facets: {},
|
|
28
|
+
filters: {},
|
|
29
|
+
updatedAt: '2026-07-26T00:00:00.000Z',
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
describe('buildPortableIndexVector', () => {
|
|
34
|
+
it('keeps exact and derived terms at one position with lower derived weight', () => {
|
|
35
|
+
const analyzer = createPortableSearchAnalyzer({ expanders: [englishExpander] });
|
|
36
|
+
const vector = buildPortableIndexVector(row(), analyzer);
|
|
37
|
+
const exact = encodeSqlToken({ kind: 'exact', value: 'running' });
|
|
38
|
+
const stem = encodeSqlToken({ kind: 'stem', value: 'run' });
|
|
39
|
+
expect(vector.value).toContain(`'${exact}':1A`);
|
|
40
|
+
expect(vector.value).toContain(`'${stem}':1B`);
|
|
41
|
+
expect(vector.analyzerFingerprint).toBe(analyzer.fingerprint);
|
|
42
|
+
});
|
|
43
|
+
it('always assigns Han grams to weight D', () => {
|
|
44
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
45
|
+
const vector = buildPortableIndexVector(row(), analyzer);
|
|
46
|
+
expect(vector.value).toContain(`'${encodeSqlToken({ kind: 'gram', value: '数据' })}':4D`);
|
|
47
|
+
expect(vector.value).toContain(`'${encodeSqlToken({ kind: 'gram', value: '据库' })}':5D`);
|
|
48
|
+
});
|
|
49
|
+
it('separates weight buckets positionally', () => {
|
|
50
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
51
|
+
const input = row({
|
|
52
|
+
body: 'alpha\nbeta',
|
|
53
|
+
weighted: { A: 'alpha', B: 'beta', C: '', D: '' },
|
|
54
|
+
});
|
|
55
|
+
const vector = buildPortableIndexVector(input, analyzer);
|
|
56
|
+
const alpha = encodeSqlToken({ kind: 'exact', value: 'alpha' });
|
|
57
|
+
const beta = encodeSqlToken({ kind: 'exact', value: 'beta' });
|
|
58
|
+
expect(vector.value).toContain(`'${alpha}':1A`);
|
|
59
|
+
expect(vector.value).toContain(`'${beta}':3B`);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { type PortableQueryPlan } from '@byline/search-analysis';
|
|
9
|
+
export interface PortablePostgresQuery {
|
|
10
|
+
/** Complete matching/ranking tsquery source for `to_tsquery('simple', …)`. */
|
|
11
|
+
tsquery: string;
|
|
12
|
+
/** One query per source concept, used to enforce minimum-should-match. */
|
|
13
|
+
conceptTsqueries: string[];
|
|
14
|
+
minimumShouldMatch?: number;
|
|
15
|
+
}
|
|
16
|
+
/** Translate a backend-neutral plan into parser-safe PostgreSQL tsquery text. */
|
|
17
|
+
export declare function buildPortablePostgresQuery(plan: PortableQueryPlan): PortablePostgresQuery;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { encodeSqlToken, } from '@byline/search-analysis';
|
|
9
|
+
/** Translate a backend-neutral plan into parser-safe PostgreSQL tsquery text. */
|
|
10
|
+
export function buildPortablePostgresQuery(plan) {
|
|
11
|
+
const conceptTsqueries = plan.concepts.map(conceptExpression);
|
|
12
|
+
if (conceptTsqueries.some((expression) => expression.length === 0)) {
|
|
13
|
+
throw new TypeError('Portable query plan contains a concept with no searchable terms');
|
|
14
|
+
}
|
|
15
|
+
if (conceptTsqueries.length === 0) {
|
|
16
|
+
return { tsquery: '', conceptTsqueries: [] };
|
|
17
|
+
}
|
|
18
|
+
const operator = plan.matching.operator === 'all' ? '&' : '|';
|
|
19
|
+
const groupedConcepts = groupCrossConceptGrams(plan, conceptTsqueries, operator);
|
|
20
|
+
const clauses = [joinExpressions(groupedConcepts, operator)];
|
|
21
|
+
for (const phrase of plan.phrases) {
|
|
22
|
+
const expressions = phrase.conceptIndexes.map((index) => conceptTsqueries[index] ?? '');
|
|
23
|
+
if (expressions.some((expression) => expression.length === 0)) {
|
|
24
|
+
throw new RangeError('Portable query phrase references an unknown concept');
|
|
25
|
+
}
|
|
26
|
+
clauses.push(joinExpressions(expressions, '<->'));
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
tsquery: joinExpressions(clauses, '&'),
|
|
30
|
+
conceptTsqueries,
|
|
31
|
+
...(plan.matching.minimumShouldMatch != null
|
|
32
|
+
? { minimumShouldMatch: plan.matching.minimumShouldMatch }
|
|
33
|
+
: {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function conceptExpression(concept) {
|
|
37
|
+
const alternatives = uniqueTerms([
|
|
38
|
+
...concept.exactTokens,
|
|
39
|
+
...concept.stemTokens,
|
|
40
|
+
...concept.lemmaTokens,
|
|
41
|
+
...concept.normalizedTokens,
|
|
42
|
+
...concept.identifierTokens,
|
|
43
|
+
]);
|
|
44
|
+
const grams = orderedTerms(concept.gramTokens);
|
|
45
|
+
if (grams.length > 0)
|
|
46
|
+
alternatives.push(joinExpressions(grams, '<->'));
|
|
47
|
+
return joinExpressions(alternatives, '|');
|
|
48
|
+
}
|
|
49
|
+
function groupCrossConceptGrams(plan, concepts, operator) {
|
|
50
|
+
const replacements = new Map();
|
|
51
|
+
for (const sequence of plan.gramSequences) {
|
|
52
|
+
if (sequence.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
const start = Math.min(...sequence.map((token) => token.normalizedStart));
|
|
55
|
+
const end = Math.max(...sequence.map((token) => token.normalizedEnd));
|
|
56
|
+
const indexes = plan.concepts
|
|
57
|
+
.filter((concept) => conceptOverlaps(concept, start, end))
|
|
58
|
+
.map((concept) => concept.index);
|
|
59
|
+
if (indexes.length < 2)
|
|
60
|
+
continue;
|
|
61
|
+
const first = indexes[0];
|
|
62
|
+
const last = indexes.at(-1);
|
|
63
|
+
if (first == null || last == null || last - first + 1 !== indexes.length)
|
|
64
|
+
continue;
|
|
65
|
+
const original = joinExpressions(indexes.map((index) => concepts[index] ?? ''), operator);
|
|
66
|
+
const gramPhrase = joinExpressions(orderedTerms(sequence), '<->');
|
|
67
|
+
if (original.length === 0 || gramPhrase.length === 0)
|
|
68
|
+
continue;
|
|
69
|
+
replacements.set(first, {
|
|
70
|
+
expression: joinExpressions([original, gramPhrase], '|'),
|
|
71
|
+
through: last,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const grouped = [];
|
|
75
|
+
for (let index = 0; index < concepts.length; index++) {
|
|
76
|
+
const replacement = replacements.get(index);
|
|
77
|
+
if (replacement != null) {
|
|
78
|
+
grouped.push(replacement.expression);
|
|
79
|
+
index = replacement.through;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if ([...replacements.entries()].some(([start, entry]) => index > start && index <= entry.through)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const concept = concepts[index];
|
|
86
|
+
if (concept != null)
|
|
87
|
+
grouped.push(concept);
|
|
88
|
+
}
|
|
89
|
+
return grouped;
|
|
90
|
+
}
|
|
91
|
+
function conceptOverlaps(concept, start, end) {
|
|
92
|
+
const source = [...concept.exactTokens, ...concept.identifierTokens];
|
|
93
|
+
return source.some((token) => token.normalizedStart < end && token.normalizedEnd > start);
|
|
94
|
+
}
|
|
95
|
+
function uniqueTerms(tokens) {
|
|
96
|
+
return [...new Set(tokens.map((token) => encodeSqlToken(token)))];
|
|
97
|
+
}
|
|
98
|
+
function orderedTerms(tokens) {
|
|
99
|
+
return tokens
|
|
100
|
+
.toSorted((left, right) => left.normalizedStart - right.normalizedStart || left.normalizedEnd - right.normalizedEnd)
|
|
101
|
+
.map((token) => encodeSqlToken(token));
|
|
102
|
+
}
|
|
103
|
+
function joinExpressions(expressions, operator) {
|
|
104
|
+
const nonEmpty = expressions.filter((expression) => expression.length > 0);
|
|
105
|
+
if (nonEmpty.length === 0)
|
|
106
|
+
return '';
|
|
107
|
+
if (nonEmpty.length === 1)
|
|
108
|
+
return nonEmpty[0] ?? '';
|
|
109
|
+
return `(${nonEmpty.map((expression) => `(${expression})`).join(` ${operator} `)})`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { createPortableSearchAnalyzer, encodeSqlToken, } from '@byline/search-analysis';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { buildPortablePostgresQuery } from './portable-query.js';
|
|
11
|
+
const expander = {
|
|
12
|
+
fingerprint: 'english-test1',
|
|
13
|
+
supports: (locale) => locale.startsWith('en'),
|
|
14
|
+
expand: (token) => (token.value === 'running' ? [{ kind: 'stem', value: 'run' }] : []),
|
|
15
|
+
};
|
|
16
|
+
describe('buildPortablePostgresQuery', () => {
|
|
17
|
+
it('keeps recall variants grouped inside all-concept matching', () => {
|
|
18
|
+
const analyzer = createPortableSearchAnalyzer({ expanders: [expander] });
|
|
19
|
+
const translated = buildPortablePostgresQuery(analyzer.analyzeQuery({ query: 'running restoration', locale: 'en' }));
|
|
20
|
+
const exact = encodeSqlToken({ kind: 'exact', value: 'running' });
|
|
21
|
+
const stem = encodeSqlToken({ kind: 'stem', value: 'run' });
|
|
22
|
+
const restoration = encodeSqlToken({ kind: 'exact', value: 'restoration' });
|
|
23
|
+
expect(translated.conceptTsqueries).toHaveLength(2);
|
|
24
|
+
expect(translated.conceptTsqueries[0]).toContain(exact);
|
|
25
|
+
expect(translated.conceptTsqueries[0]).toContain(stem);
|
|
26
|
+
expect(translated.tsquery).toContain(' | ');
|
|
27
|
+
expect(translated.tsquery).toContain(' & ');
|
|
28
|
+
expect(translated.tsquery).toContain(restoration);
|
|
29
|
+
});
|
|
30
|
+
it('preserves phrase order and minimum-should-match inputs', () => {
|
|
31
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
32
|
+
const translated = buildPortablePostgresQuery(analyzer.analyzeQuery({
|
|
33
|
+
query: '"forest restoration" database',
|
|
34
|
+
locale: 'en',
|
|
35
|
+
matching: { operator: 'any', minimumShouldMatch: 2 },
|
|
36
|
+
}));
|
|
37
|
+
expect(translated.conceptTsqueries).toHaveLength(3);
|
|
38
|
+
expect(translated.minimumShouldMatch).toBe(2);
|
|
39
|
+
expect(translated.tsquery).toContain(' | ');
|
|
40
|
+
expect(translated.tsquery).toContain(' <-> ');
|
|
41
|
+
expect(translated.tsquery).toMatch(/^[a-z0-9()|&<>\-\s]+$/);
|
|
42
|
+
});
|
|
43
|
+
it('returns an empty translation for punctuation-only input', () => {
|
|
44
|
+
const analyzer = createPortableSearchAnalyzer();
|
|
45
|
+
const translated = buildPortablePostgresQuery(analyzer.analyzeQuery({ query: '---' }));
|
|
46
|
+
expect(translated).toEqual({ tsquery: '', conceptTsqueries: [] });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -6,32 +6,17 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import type { SearchCapabilities, SearchDocument, SearchProvider, SearchQuery, SearchResults } from '@byline/core';
|
|
9
|
+
import { type PortableSearchAnalyzer } from '@byline/search-analysis';
|
|
9
10
|
import type { Pool } from 'pg';
|
|
10
|
-
import type { RegconfigResolver } from './locale-regconfig.js';
|
|
11
11
|
/**
|
|
12
|
-
* The built-in
|
|
13
|
-
*
|
|
14
|
-
* `websearch_to_tsquery` + `ts_rank`. Owns its schema (see `migrate`).
|
|
12
|
+
* The built-in PostgreSQL `SearchProvider`. Stores portable logical terms in
|
|
13
|
+
* one weighted tsvector per `(collection_path, document_id, locale)`.
|
|
15
14
|
*/
|
|
16
15
|
export declare class PostgresSearchProvider implements SearchProvider {
|
|
17
16
|
private readonly pool;
|
|
18
|
-
private readonly
|
|
19
|
-
/**
|
|
20
|
-
* Locale used to pick the query `regconfig` when a search omits `locale`.
|
|
21
|
-
* Without it, a locale-less query falls back to `simple` (unstemmed) and
|
|
22
|
-
* silently fails to match locale-stemmed vectors. Set to the host's
|
|
23
|
-
* default content locale.
|
|
24
|
-
*/
|
|
25
|
-
private readonly defaultLocale?;
|
|
17
|
+
private readonly analyzer;
|
|
26
18
|
readonly capabilities: SearchCapabilities;
|
|
27
|
-
constructor(pool: Pool,
|
|
28
|
-
/**
|
|
29
|
-
* Locale used to pick the query `regconfig` when a search omits `locale`.
|
|
30
|
-
* Without it, a locale-less query falls back to `simple` (unstemmed) and
|
|
31
|
-
* silently fails to match locale-stemmed vectors. Set to the host's
|
|
32
|
-
* default content locale.
|
|
33
|
-
*/
|
|
34
|
-
defaultLocale?: string | undefined);
|
|
19
|
+
constructor(pool: Pool, analyzer: PortableSearchAnalyzer);
|
|
35
20
|
upsert(doc: SearchDocument): Promise<void>;
|
|
36
21
|
remove(ref: {
|
|
37
22
|
collectionPath: string;
|
|
@@ -42,4 +27,5 @@ export declare class PostgresSearchProvider implements SearchProvider {
|
|
|
42
27
|
collectionPath?: string;
|
|
43
28
|
}): Promise<void>;
|
|
44
29
|
search(query: SearchQuery): Promise<SearchResults>;
|
|
30
|
+
private assertAnalyzerFingerprint;
|
|
45
31
|
}
|
|
@@ -5,78 +5,72 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { highlightPortableText } from '@byline/search-analysis';
|
|
8
9
|
import { buildIndexRow } from './build-index-row.js';
|
|
10
|
+
import { SearchAnalyzerMismatchError } from './errors.js';
|
|
11
|
+
import { buildPortableIndexVector } from './portable-index-vector.js';
|
|
12
|
+
import { buildPortablePostgresQuery } from './portable-query.js';
|
|
9
13
|
const CAPABILITIES = {
|
|
10
|
-
// tsvector + ts_rank floor: no IDF,
|
|
11
|
-
//
|
|
14
|
+
// Portable tsvector + ts_rank floor: no IDF, fuzzy matching, vectors,
|
|
15
|
+
// aggregation, or structured `where` filtering yet.
|
|
12
16
|
facets: false,
|
|
13
17
|
typoTolerance: false,
|
|
14
18
|
semantic: false,
|
|
15
19
|
bm25: false,
|
|
16
20
|
weighting: true,
|
|
17
21
|
highlights: true,
|
|
22
|
+
fullText: {
|
|
23
|
+
nativeAnalysis: false,
|
|
24
|
+
portableAnalysis: true,
|
|
25
|
+
allTerms: true,
|
|
26
|
+
anyTerms: true,
|
|
27
|
+
minimumShouldMatch: true,
|
|
28
|
+
phrase: true,
|
|
29
|
+
},
|
|
18
30
|
};
|
|
19
31
|
/**
|
|
20
|
-
* The built-in
|
|
21
|
-
*
|
|
22
|
-
* `websearch_to_tsquery` + `ts_rank`. Owns its schema (see `migrate`).
|
|
32
|
+
* The built-in PostgreSQL `SearchProvider`. Stores portable logical terms in
|
|
33
|
+
* one weighted tsvector per `(collection_path, document_id, locale)`.
|
|
23
34
|
*/
|
|
24
35
|
export class PostgresSearchProvider {
|
|
25
36
|
pool;
|
|
26
|
-
|
|
27
|
-
defaultLocale;
|
|
37
|
+
analyzer;
|
|
28
38
|
capabilities = CAPABILITIES;
|
|
29
|
-
constructor(pool,
|
|
30
|
-
/**
|
|
31
|
-
* Locale used to pick the query `regconfig` when a search omits `locale`.
|
|
32
|
-
* Without it, a locale-less query falls back to `simple` (unstemmed) and
|
|
33
|
-
* silently fails to match locale-stemmed vectors. Set to the host's
|
|
34
|
-
* default content locale.
|
|
35
|
-
*/
|
|
36
|
-
defaultLocale) {
|
|
39
|
+
constructor(pool, analyzer) {
|
|
37
40
|
this.pool = pool;
|
|
38
|
-
this.
|
|
39
|
-
this.defaultLocale = defaultLocale;
|
|
41
|
+
this.analyzer = analyzer;
|
|
40
42
|
}
|
|
41
43
|
async upsert(doc) {
|
|
42
44
|
const row = buildIndexRow(doc);
|
|
43
|
-
const
|
|
44
|
-
await this.pool.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
row.weighted.A,
|
|
73
|
-
row.weighted.B,
|
|
74
|
-
row.weighted.C,
|
|
75
|
-
row.weighted.D,
|
|
76
|
-
JSON.stringify(row.facets),
|
|
77
|
-
JSON.stringify(row.filters),
|
|
78
|
-
row.updatedAt,
|
|
79
|
-
]);
|
|
45
|
+
const vector = buildPortableIndexVector(row, this.analyzer);
|
|
46
|
+
const client = await this.pool.connect();
|
|
47
|
+
try {
|
|
48
|
+
await client.query('BEGIN');
|
|
49
|
+
await client.query(`INSERT INTO byline_search_index_metadata
|
|
50
|
+
(collection_path, analyzer_fingerprint, zones, updated_at)
|
|
51
|
+
VALUES ($1, $2, $3, now())
|
|
52
|
+
ON CONFLICT (collection_path) DO UPDATE
|
|
53
|
+
SET zones = ARRAY(
|
|
54
|
+
SELECT DISTINCT unnest(byline_search_index_metadata.zones || EXCLUDED.zones)
|
|
55
|
+
)`, [row.collectionPath, vector.analyzerFingerprint, row.zones]);
|
|
56
|
+
const metadata = await client.query(`SELECT analyzer_fingerprint
|
|
57
|
+
FROM byline_search_index_metadata
|
|
58
|
+
WHERE collection_path = $1
|
|
59
|
+
FOR UPDATE`, [row.collectionPath]);
|
|
60
|
+
const actualFingerprint = metadata.rows[0]?.analyzer_fingerprint ?? null;
|
|
61
|
+
if (actualFingerprint !== vector.analyzerFingerprint) {
|
|
62
|
+
throw new SearchAnalyzerMismatchError(row.collectionPath, vector.analyzerFingerprint, actualFingerprint);
|
|
63
|
+
}
|
|
64
|
+
await client.query(UPSERT_SQL, upsertParams(row, vector.value, vector.analyzerFingerprint));
|
|
65
|
+
await client.query('COMMIT');
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
await rollback(client);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
client.release();
|
|
73
|
+
}
|
|
80
74
|
}
|
|
81
75
|
async remove(ref) {
|
|
82
76
|
if (ref.locale != null) {
|
|
@@ -89,68 +83,171 @@ export class PostgresSearchProvider {
|
|
|
89
83
|
}
|
|
90
84
|
}
|
|
91
85
|
async reindex(opts = {}) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
86
|
+
const client = await this.pool.connect();
|
|
87
|
+
try {
|
|
88
|
+
await client.query('BEGIN');
|
|
89
|
+
if (opts.collectionPath != null) {
|
|
90
|
+
await client.query('DELETE FROM byline_search_index_metadata WHERE collection_path = $1', [
|
|
91
|
+
opts.collectionPath,
|
|
92
|
+
]);
|
|
93
|
+
await client.query('DELETE FROM byline_search_documents WHERE collection_path = $1', [
|
|
94
|
+
opts.collectionPath,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
await client.query('TRUNCATE byline_search_documents');
|
|
99
|
+
await client.query('TRUNCATE byline_search_index_metadata');
|
|
100
|
+
}
|
|
101
|
+
await client.query('COMMIT');
|
|
98
102
|
}
|
|
99
|
-
|
|
100
|
-
await
|
|
103
|
+
catch (error) {
|
|
104
|
+
await rollback(client);
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
client.release();
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
async search(query) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
const plan = this.analyzer.analyzeQuery({
|
|
113
|
+
query: query.query,
|
|
114
|
+
locale: query.locale,
|
|
115
|
+
matching: query.matching,
|
|
116
|
+
});
|
|
117
|
+
const translated = buildPortablePostgresQuery(plan);
|
|
118
|
+
if (translated.tsquery.length === 0)
|
|
119
|
+
return { hits: [], total: 0 };
|
|
120
|
+
await this.assertAnalyzerFingerprint(query);
|
|
121
|
+
const params = [translated.tsquery];
|
|
109
122
|
const where = ['d.search_vector @@ q.query'];
|
|
110
|
-
if (
|
|
111
|
-
params.push(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
params.push(query.locale);
|
|
120
|
-
where.push(`d.locale = $${params.length}`);
|
|
121
|
-
}
|
|
122
|
-
// Default to published-only; 'any' is the admin escape hatch.
|
|
123
|
-
if (query.status !== 'any') {
|
|
124
|
-
params.push('published');
|
|
125
|
-
where.push(`d.status = $${params.length}`);
|
|
123
|
+
if (translated.minimumShouldMatch != null) {
|
|
124
|
+
params.push(translated.conceptTsqueries);
|
|
125
|
+
const conceptsParam = params.length;
|
|
126
|
+
params.push(translated.minimumShouldMatch);
|
|
127
|
+
const minimumParam = params.length;
|
|
128
|
+
where.push(`(SELECT count(*)
|
|
129
|
+
FROM unnest($${conceptsParam}::text[]) AS concept(query_text)
|
|
130
|
+
WHERE d.search_vector @@ to_tsquery('simple', concept.query_text))
|
|
131
|
+
>= $${minimumParam}`);
|
|
126
132
|
}
|
|
133
|
+
appendScopeFilters(query, params, where, true);
|
|
134
|
+
const cte = `WITH q AS (SELECT to_tsquery('simple', $1) AS query)`;
|
|
127
135
|
const whereSql = where.join(' AND ');
|
|
128
|
-
const cte = `WITH q AS (SELECT websearch_to_tsquery($1::regconfig, $2) AS query)`;
|
|
129
136
|
const countResult = await this.pool.query(`${cte}
|
|
130
137
|
SELECT count(*)::text AS total
|
|
131
138
|
FROM byline_search_documents d, q
|
|
132
139
|
WHERE ${whereSql}`, params);
|
|
133
140
|
const total = Number(countResult.rows[0]?.total ?? 0);
|
|
141
|
+
const limit = query.limit ?? 20;
|
|
142
|
+
const offset = query.offset ?? 0;
|
|
134
143
|
const limitParam = params.length + 1;
|
|
135
144
|
const offsetParam = params.length + 2;
|
|
136
145
|
const hitResult = await this.pool.query(`${cte}
|
|
137
|
-
SELECT d.collection_path, d.document_id, d.locale, d.title, d.path,
|
|
138
|
-
ts_rank(d.search_vector, q.query) AS score
|
|
139
|
-
ts_headline($1::regconfig, d.body, q.query,
|
|
140
|
-
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MaxWords=24, MinWords=8') AS highlight
|
|
146
|
+
SELECT d.collection_path, d.document_id, d.locale, d.title, d.path, d.body,
|
|
147
|
+
ts_rank(d.search_vector, q.query) AS score
|
|
141
148
|
FROM byline_search_documents d, q
|
|
142
149
|
WHERE ${whereSql}
|
|
143
|
-
ORDER BY score DESC, d.updated_at DESC
|
|
150
|
+
ORDER BY score DESC, d.updated_at DESC,
|
|
151
|
+
d.collection_path, d.document_id, d.locale
|
|
144
152
|
LIMIT $${limitParam} OFFSET $${offsetParam}`, [...params, limit, offset]);
|
|
145
|
-
const hits = hitResult.rows.map((
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
const hits = hitResult.rows.map((row) => {
|
|
154
|
+
const highlight = highlightPortableText({
|
|
155
|
+
text: row.body,
|
|
156
|
+
plan,
|
|
157
|
+
analyzer: this.analyzer,
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
collectionPath: row.collection_path,
|
|
161
|
+
documentId: row.document_id,
|
|
162
|
+
locale: row.locale,
|
|
163
|
+
title: row.title,
|
|
164
|
+
path: row.path,
|
|
165
|
+
score: Number(row.score),
|
|
166
|
+
...(highlight == null ? {} : { highlights: { body: [highlight] } }),
|
|
167
|
+
};
|
|
168
|
+
});
|
|
154
169
|
return { hits, total };
|
|
155
170
|
}
|
|
171
|
+
async assertAnalyzerFingerprint(query) {
|
|
172
|
+
const params = [this.analyzer.fingerprint];
|
|
173
|
+
const where = ['m.analyzer_fingerprint IS DISTINCT FROM $1'];
|
|
174
|
+
appendMetadataScopeFilters(query, params, where);
|
|
175
|
+
const incompatible = await this.pool.query(`SELECT m.collection_path, m.analyzer_fingerprint
|
|
176
|
+
FROM byline_search_index_metadata m
|
|
177
|
+
WHERE ${where.join(' AND ')}
|
|
178
|
+
LIMIT 1`, params);
|
|
179
|
+
const row = incompatible.rows[0];
|
|
180
|
+
if (row != null) {
|
|
181
|
+
throw new SearchAnalyzerMismatchError(row.collection_path, this.analyzer.fingerprint, row.analyzer_fingerprint);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function appendMetadataScopeFilters(query, params, where) {
|
|
186
|
+
if (query.collectionPath != null) {
|
|
187
|
+
params.push(query.collectionPath);
|
|
188
|
+
where.push(`m.collection_path = $${params.length}`);
|
|
189
|
+
}
|
|
190
|
+
if (query.zone != null) {
|
|
191
|
+
params.push([query.zone]);
|
|
192
|
+
where.push(`m.zones @> $${params.length}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function appendScopeFilters(query, params, where, includeStatus) {
|
|
196
|
+
if (query.collectionPath != null) {
|
|
197
|
+
params.push(query.collectionPath);
|
|
198
|
+
where.push(`d.collection_path = $${params.length}`);
|
|
199
|
+
}
|
|
200
|
+
if (query.zone != null) {
|
|
201
|
+
params.push([query.zone]);
|
|
202
|
+
where.push(`d.zones @> $${params.length}`);
|
|
203
|
+
}
|
|
204
|
+
if (query.locale != null) {
|
|
205
|
+
params.push(query.locale);
|
|
206
|
+
where.push(`d.locale = $${params.length}`);
|
|
207
|
+
}
|
|
208
|
+
if (includeStatus && query.status !== 'any') {
|
|
209
|
+
params.push('published');
|
|
210
|
+
where.push(`d.status = $${params.length}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function upsertParams(row, vector, analyzerFingerprint) {
|
|
214
|
+
return [
|
|
215
|
+
row.collectionPath,
|
|
216
|
+
row.documentId,
|
|
217
|
+
row.locale,
|
|
218
|
+
row.status,
|
|
219
|
+
row.zones,
|
|
220
|
+
row.title,
|
|
221
|
+
row.path,
|
|
222
|
+
row.body,
|
|
223
|
+
vector,
|
|
224
|
+
analyzerFingerprint,
|
|
225
|
+
JSON.stringify(row.facets),
|
|
226
|
+
JSON.stringify(row.filters),
|
|
227
|
+
row.updatedAt,
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
async function rollback(client) {
|
|
231
|
+
try {
|
|
232
|
+
await client.query('ROLLBACK');
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Preserve the original operation error.
|
|
236
|
+
}
|
|
156
237
|
}
|
|
238
|
+
const UPSERT_SQL = `INSERT INTO byline_search_documents
|
|
239
|
+
(collection_path, document_id, locale, status, zones, title, path, body,
|
|
240
|
+
search_vector, analyzer_fingerprint, facets, filters, updated_at)
|
|
241
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::tsvector, $10,
|
|
242
|
+
$11::jsonb, $12::jsonb, $13)
|
|
243
|
+
ON CONFLICT (collection_path, document_id, locale) DO UPDATE SET
|
|
244
|
+
status = EXCLUDED.status,
|
|
245
|
+
zones = EXCLUDED.zones,
|
|
246
|
+
title = EXCLUDED.title,
|
|
247
|
+
path = EXCLUDED.path,
|
|
248
|
+
body = EXCLUDED.body,
|
|
249
|
+
search_vector = EXCLUDED.search_vector,
|
|
250
|
+
analyzer_fingerprint = EXCLUDED.analyzer_fingerprint,
|
|
251
|
+
facets = EXCLUDED.facets,
|
|
252
|
+
filters = EXCLUDED.filters,
|
|
253
|
+
updated_at = EXCLUDED.updated_at`;
|
package/migrations/0001_init.sql
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
-- @byline/search-postgres — 0001_init
|
|
2
2
|
--
|
|
3
|
-
-- The full-text search index, owned entirely by this driver. One
|
|
4
|
-
-- (collection_path, document_id, locale).
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
3
|
+
-- The disposable full-text search index, owned entirely by this driver. One
|
|
4
|
+
-- row per (collection_path, document_id, locale). `search_vector` stores
|
|
5
|
+
-- parser-safe physical tokens produced by @byline/search-analysis and
|
|
6
|
+
-- `analyzer_fingerprint` identifies the exact portable analysis pipeline.
|
|
7
|
+
-- Search indexes created with an older schema must be dropped and rebuilt.
|
|
8
8
|
--
|
|
9
9
|
-- Idempotent (IF NOT EXISTS throughout) so re-applying is safe. The driver's
|
|
10
10
|
-- migration runner records applied versions in byline_search_migrations.
|
|
@@ -18,7 +18,8 @@ CREATE TABLE IF NOT EXISTS byline_search_documents (
|
|
|
18
18
|
title text NOT NULL DEFAULT '',
|
|
19
19
|
path text,
|
|
20
20
|
body text NOT NULL DEFAULT '',
|
|
21
|
-
search_vector tsvector,
|
|
21
|
+
search_vector tsvector NOT NULL,
|
|
22
|
+
analyzer_fingerprint text NOT NULL,
|
|
22
23
|
facets jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
23
24
|
filters jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
24
25
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
@@ -40,3 +41,13 @@ CREATE INDEX IF NOT EXISTS byline_search_documents_facets_idx
|
|
|
40
41
|
-- Single-collection scoping + status filtering.
|
|
41
42
|
CREATE INDEX IF NOT EXISTS byline_search_documents_collection_idx
|
|
42
43
|
ON byline_search_documents (collection_path, status);
|
|
44
|
+
|
|
45
|
+
-- One locked row per collection prevents concurrent processes with different
|
|
46
|
+
-- analyzer fingerprints from mixing incompatible projections. Collection
|
|
47
|
+
-- zones let fingerprint guards retain zone scope without scanning documents.
|
|
48
|
+
CREATE TABLE IF NOT EXISTS byline_search_index_metadata (
|
|
49
|
+
collection_path text PRIMARY KEY,
|
|
50
|
+
analyzer_fingerprint text NOT NULL,
|
|
51
|
+
zones text[] NOT NULL DEFAULT '{}',
|
|
52
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
53
|
+
);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/search-postgres",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.9.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"migrations"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@byline/core": "4.
|
|
44
|
+
"@byline/core": "4.9.0",
|
|
45
|
+
"@byline/search-analysis": "4.9.0"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
48
|
"pg": "^8.21.0"
|
|
@@ -52,12 +53,14 @@
|
|
|
52
53
|
"@types/pg": "^8.20.0",
|
|
53
54
|
"chokidar": "^5.0.0",
|
|
54
55
|
"chokidar-cli": "^3.0.0",
|
|
56
|
+
"dotenv": "^17.4.2",
|
|
55
57
|
"npm-run-all": "^4.1.5",
|
|
56
58
|
"pg": "^8.22.0",
|
|
57
59
|
"tsc-alias": "^1.9.1",
|
|
58
60
|
"tsx": "^4.23.1",
|
|
59
61
|
"typescript": "^7.0.2",
|
|
60
|
-
"vitest": "^4.1.10"
|
|
62
|
+
"vitest": "^4.1.10",
|
|
63
|
+
"@byline/search-conformance": "0.0.2"
|
|
61
64
|
},
|
|
62
65
|
"publishConfig": {
|
|
63
66
|
"access": "public"
|
|
@@ -68,6 +71,7 @@
|
|
|
68
71
|
"clean": "node scripts/clean.js node_modules dist build .turbo",
|
|
69
72
|
"lint": "biome check --write --unsafe --diagnostic-level=error",
|
|
70
73
|
"test": "vitest run --mode=node",
|
|
74
|
+
"test:integration": "vitest run --mode=integration",
|
|
71
75
|
"test:watch": "vitest --mode=node",
|
|
72
76
|
"typecheck": "tsc --noEmit"
|
|
73
77
|
}
|