Improve video searchability by allowing to search through descriptions (#7612)

* feat: use PostgreSQL FTS for video search instead of trigram

Add a videoSearch table with tsvector column and GIN index to store
search vectors for video titles and descriptions. Titles are weighted
higher than descriptions via setweight (A/B).

Trigram search is kept as a fallback to handle typos.

A migration script (server/scripts/migrations/peertube-8.2.ts) is
provided to backfill existing videos.

See #7386

* fix: add missing VideoSearchModel

search indexes did not appear in the database when migrating

* Improve FTS

* Requires PG >= 14

---------

Co-authored-by: oliwierjaszczyszyn <h@h>
Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
Oliwier Jaszczyszyn
2026-08-06 15:26:41 +02:00
committed by GitHub
co-authored by oliwierjaszczyszyn Chocobozzz
parent ea91321150
commit 9e2eaf13ad
11 changed files with 288 additions and 16 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- 6379:6379
postgres:
image: postgres:10
image: postgres:14
ports:
- 5432:5432
env:
+1 -1
View File
@@ -46,7 +46,7 @@ handled via FFmpeg, with optional distributed runners.
- Node.js >= 22.x
- pnpm >= 10.9 (do **not** use npm or yarn for install)
- PostgreSQL >= 10 with `pg_trgm` and `unaccent` extensions
- PostgreSQL >= 14 with `pg_trgm` and `unaccent` extensions
- Redis >= 6.x
- FFmpeg >= 4.3
- Python >= 3.8 (for some test tooling)
@@ -582,6 +582,90 @@ describe('Test videos search', function () {
}
})
describe('Full text search', function () {
// Distinctive tokens so these videos can't collide with the search assertions above
before(async function () {
this.timeout(120000)
await server.videos.upload({
attributes: {
name: 'kryptonite harmonica',
description: 'A quenelle of pamplemousse served on a bed of zarzuela'
}
})
await server.videos.upload({
attributes: { name: 'quenelle & pamplemousse!' }
})
// Only the first 1000 chars of the description are indexed
await server.videos.upload({
attributes: {
name: 'long description video',
description: 'sarrasine '.repeat(150) + 'bouillabaisse'
}
})
})
it('Should find a video by a word of its description only', async function () {
const body = await command.searchVideos({ search: 'zarzuela' })
expect(body.total).to.equal(1)
expect(body.data[0].name).to.equal('kryptonite harmonica')
})
it('Should rank a name match above a description only match', async function () {
const body = await command.searchVideos({ search: 'quenelle', sort: '-match' })
expect(body.total).to.equal(2)
expect(body.data[0].name).to.equal('quenelle & pamplemousse!')
expect(body.data[1].name).to.equal('kryptonite harmonica')
})
it('Should match every word of a multi word search', async function () {
const body = await command.searchVideos({ search: 'harmonica kryptonite' })
expect(body.total).to.equal(1)
expect(body.data[0].name).to.equal('kryptonite harmonica')
})
it('Should not fail on searches containing tsquery operators', async function () {
// to_tsquery() would raise "syntax error in tsquery" on these if they reached it unsanitized
for (const search of [ 'quenelle!', 'quenelle & pamplemousse', 'quenelle | pamplemousse', 'what? (really)', '12:30' ]) {
const body = await command.searchVideos({ search })
expect(body.total).to.be.at.least(0)
}
})
it('Should not fail on a search without any lexeme', async function () {
for (const search of [ ' ', '!!!', '((()))', '...' ]) {
const body = await command.searchVideos({ search })
expect(body.total).to.equal(0)
}
})
it('Should still find a video with an accented and prefixed search', async function () {
const body = await command.searchVideos({ search: 'pämplemoussé' })
expect(body.total).to.equal(2)
})
it('Should index the beginning of a long description', async function () {
const body = await command.searchVideos({ search: 'sarrasine' })
expect(body.total).to.equal(1)
expect(body.data[0].name).to.equal('long description video')
})
it('Should not index a description past the indexed length limit', async function () {
const body = await command.searchVideos({ search: 'bouillabaisse' })
expect(body.total).to.equal(0)
})
})
after(async function () {
await cleanupTests([ server ])
})
+4 -1
View File
@@ -62,7 +62,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 1110
export const LAST_MIGRATION_VERSION = 1115
// ---------------------------------------------------------------------------
@@ -594,6 +594,9 @@ export const REMOTE_VIEWS = {
export const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 100
// Changing this requires re-indexing existing videos
export const VIDEO_SEARCH_INDEXED_DESCRIPTION_LENGTH = 1000
export let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
export const DEFAULT_AUDIO_MERGE_RESOLUTION = VideoResolution.H_480P
+16 -4
View File
@@ -74,12 +74,14 @@ import { VideoImportModel } from '../models/video/video-import.js'
import { VideoLiveModel } from '../models/video/video-live.js'
import { VideoPlaylistElementModel } from '../models/video/video-playlist-element.js'
import { VideoPlaylistModel } from '../models/video/video-playlist.js'
import { VideoSearchModel } from '../models/video/video-search.js'
import { VideoShareModel } from '../models/video/video-share.js'
import { VideoInfohashModel } from '../models/video/video-infohash.js'
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js'
import { VideoTagModel } from '../models/video/video-tag.js'
import { VideoModel } from '../models/video/video.js'
import { CONFIG } from './config.js'
import { VIDEO_SEARCH_INDEXED_DESCRIPTION_LENGTH } from './constants.js'
pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@@ -223,7 +225,8 @@ export async function initDatabaseModels (silent: boolean) {
PlayerSettingModel,
VideoChannelCollaboratorModel,
ActorReservedModel,
VideoEmbedPrivacyDomainModel
VideoEmbedPrivacyDomainModel,
VideoSearchModel
])
// Check extensions exist in the database
@@ -269,12 +272,21 @@ async function checkPostgresExtension (extension: 'pg_trgm' | 'unaccent') {
}
}
function createFunctions () {
const query = `CREATE OR REPLACE FUNCTION immutable_unaccent(text)
async function createFunctions () {
const unaccentQuery = `CREATE OR REPLACE FUNCTION immutable_unaccent(text)
RETURNS text AS
$func$
SELECT public.unaccent('public.unaccent', $1::text)
$func$ LANGUAGE sql IMMUTABLE;`
return sequelizeTypescript.query(query, { raw: true })
await sequelizeTypescript.query(unaccentQuery, { raw: true })
const searchVectorQuery = `CREATE OR REPLACE FUNCTION video_search_vector(name text, description text)
RETURNS tsvector AS
$func$
SELECT setweight(to_tsvector('simple', immutable_unaccent(coalesce(name, ''))), 'A') ||
setweight(to_tsvector('simple', immutable_unaccent(left(coalesce(description, ''), ${VIDEO_SEARCH_INDEXED_DESCRIPTION_LENGTH}))), 'B')
$func$ LANGUAGE sql IMMUTABLE;`
await sequelizeTypescript.query(searchVectorQuery, { raw: true })
}
+26
View File
@@ -28,6 +28,7 @@ async function installApplication () {
createOAuthClientIfNotExist(),
createOAuthAdminIfNotExist(),
createRunnerRegistrationTokenIfNotExist(),
createVideoSearchTriggerIfNotExist(),
initPNPM()
])
}),
@@ -187,3 +188,28 @@ async function createRunnerRegistrationTokenIfNotExist () {
await token.save()
}
async function createVideoSearchTriggerIfNotExist () {
try {
// video_search_vector() is created in database.ts
await sequelizeTypescript.query(`
CREATE OR REPLACE FUNCTION "video_search_vector_update"() RETURNS trigger AS $$
BEGIN
INSERT INTO "videoSearch" ("videoId", "searchVector")
VALUES (NEW."id", video_search_vector(NEW.name, NEW.description))
ON CONFLICT ("videoId") DO UPDATE SET
"searchVector" = EXCLUDED."searchVector";
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`)
await sequelizeTypescript.query(`
CREATE OR REPLACE TRIGGER "video_search_vector_trigger"
AFTER INSERT OR UPDATE OF name, description ON "video"
FOR EACH ROW EXECUTE FUNCTION "video_search_vector_update"()
`)
} catch (err) {
logger.error('Cannot create video search trigger.', { err })
}
}
@@ -0,0 +1,28 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const { transaction } = utils
await utils.sequelize.query(
`CREATE TABLE IF NOT EXISTS "videoSearch" (
"id" SERIAL,
"videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"searchVector" tsvector NOT NULL,
PRIMARY KEY ("id")
)`,
{ transaction }
)
}
function down () {
throw new Error('Not implemented.')
}
export {
down,
up
}
@@ -22,6 +22,21 @@ import { createSafeIn, parseRowCountResult } from '../../../shared/index.js'
* We don't list classic SQL builder classes used by other models because for performance reasons
*/
// Used to normalize ts_rank() into the [0, 1] range of word_similarity()
const TS_RANK_NAME_MATCH = 0.61
// to_tsquery() parses its argument as a tsquery expression: `&`, `|`, `!`, `<->`, parentheses and `:` weight markers
// So raw user input like "rock & roll", "hello!" or "12:30" makes it throw a syntax error
// Returns '' when the search holds no lexeme at all
function buildTSQueryTerms (search: string) {
return search
.replace(/[^\p{L}\p{N}_]/gu, ' ')
.split(/\s+/)
.filter(term => term.length !== 0)
.map(term => `${term}:*`)
.join(' & ')
}
export type DisplayOnlyForFollowerOptions = {
actorId: number
orLocalVideos: boolean
@@ -829,10 +844,32 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
this.queryConfig = 'SET pg_trgm.word_similarity_threshold = 0.40;'
// A search made only of punctuation ("!!!", "...") has no lexeme to look for: skip the full text search
const tsQueryTerms = buildTSQueryTerms(search)
const hasFTS = tsQueryTerms !== ''
if (hasFTS) {
this.cte.push(
// Build the tsquery once instead of once per referencing expression
`"tsQuery" AS (SELECT to_tsquery('simple', immutable_unaccent(${this.sequelize.escape(tsQueryTerms)})) AS "query")`,
'"ftsSearch" AS (' +
' SELECT "videoSearch"."videoId" AS "id", ' +
// ts_rank tops out at ~0.61 for a name-only match while word_similarity returns 1 for the same match.
// Normalize by that constant so both CTE similarities share a [0, 1]
// A description-only match scores ~0.4, i.e. below any name match
` LEAST(ts_rank("videoSearch"."searchVector", "tsQuery"."query") / ${TS_RANK_NAME_MATCH}, 1) AS similarity ` +
' FROM "videoSearch", "tsQuery" ' +
' WHERE "videoSearch"."searchVector" @@ "tsQuery"."query"' +
')'
)
this.joins.push('LEFT JOIN "ftsSearch" ON "video"."id" = "ftsSearch"."id"')
}
this.cte.push(
'"trigramSearch" AS (' +
' SELECT "video"."id", ' +
` word_similarity(lower(immutable_unaccent(${escapedSearch})), lower(immutable_unaccent("video"."name"))) as similarity ` +
` word_similarity(lower(immutable_unaccent(${escapedSearch})), lower(immutable_unaccent("video"."name"))) AS similarity ` +
' FROM "video" ' +
' WHERE lower(immutable_unaccent(' + escapedSearch + ')) <% lower(immutable_unaccent("video"."name")) OR ' +
' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
@@ -842,6 +879,9 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
this.joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
let base = '(' +
(hasFTS
? ' "ftsSearch"."id" IS NOT NULL OR '
: '') +
' "trigramSearch"."id" IS NOT NULL OR ' +
' EXISTS (' +
' SELECT 1 FROM "videoTag" ' +
@@ -858,7 +898,10 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
this.and.push(base)
let attribute = `COALESCE("trigramSearch"."similarity", 0)`
let attribute = hasFTS
? 'GREATEST(COALESCE("ftsSearch"."similarity", 0), COALESCE("trigramSearch"."similarity", 0))'
: 'COALESCE("trigramSearch"."similarity", 0)'
if (this.group) attribute = `AVG(${attribute})`
if (!isCount) {
+30
View File
@@ -0,0 +1,30 @@
import { AllowNull, BelongsTo, Column, DataType, ForeignKey, Table } from 'sequelize-typescript'
import { SequelizeModel } from '../shared/index.js'
import { VideoModel } from '../video/video.js'
@Table({
tableName: 'videoSearch',
timestamps: false,
indexes: [
// Must be unique: the search vector trigger inserts with ON CONFLICT ("videoId")
{ fields: [ 'videoId' ], unique: true },
{ fields: [ 'searchVector' ], using: 'gin' }
]
})
export class VideoSearchModel extends SequelizeModel<VideoSearchModel> {
@AllowNull(false)
@Column(DataType.TSVECTOR)
declare searchVector: string
@ForeignKey(() => VideoModel)
@Column
declare videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
declare Video: Awaited<VideoModel>
}
+27
View File
@@ -0,0 +1,27 @@
import { sequelizeTypescript } from '@server/initializers/database.js'
run()
.then(() => process.exit(0))
.catch(err => {
console.error(err)
process.exit(-1)
})
async function run () {
await fillVideoSearchTable()
}
async function fillVideoSearchTable () {
console.log('Filling videoSearch table with existing videos...')
// video_search_vector() is created by the server on startup, so this builds the exact same vector as the trigger
await sequelizeTypescript.query(`
INSERT INTO "videoSearch" ("videoId", "searchVector")
SELECT "id", video_search_vector(name, description)
FROM "video"
ON CONFLICT ("videoId") DO UPDATE SET
"searchVector" = EXCLUDED."searchVector"
`)
console.log('videoSearch table filled.\n')
}
+26 -7
View File
@@ -6,7 +6,7 @@ Main dependencies supported by PeerTube:
* `node` LTS (**>= 22.12 and <25**)
* `pnpm` >= 10.x
* `postgres` >=10.x
* `postgres` >=14.x
* `redis-server` >=6.2
* `ffmpeg` >=4.3 (using a ffmpeg static build [is not recommended](https://github.com/Chocobozzz/PeerTube/issues/6308))
* `python` >=3.8
@@ -15,6 +15,10 @@ Main dependencies supported by PeerTube:
_note_: only **LTS** versions of external dependencies are supported. If no LTS version matching the version constraint is available, only **release** versions are supported.
_note_: some distributions still ship a PostgreSQL older than 14 in their default repositories. Check with `psql --version` after
installing, and if it is too old, install a supported release from the
[PostgreSQL official repositories](https://www.postgresql.org/download/) instead of the distribution package.
[[toc]]
## Debian / Ubuntu and derivatives
@@ -51,8 +55,12 @@ _note_: only **LTS** versions of external dependencies are supported. If no LTS
ffmpeg -version # Should be >= 4.1
g++ -v # Should be >= 5.x
redis-server --version # Should be >= 6.x
psql --version # Should be >= 14
```
Debian 11 (bullseye) and Ubuntu 20.04 ship PostgreSQL 13 or older: on these releases install PostgreSQL from the
[PostgreSQL APT repository](https://www.postgresql.org/download/linux/ubuntu/) instead.
Now that dependencies are installed, before running PeerTube you should start PostgreSQL and Redis:
```sh
@@ -97,6 +105,9 @@ sudo systemctl start redis postgresql
sudo yum install nginx postgresql postgresql-server postgresql-contrib openssl gcc-c++ make wget redis git devtoolset-7
```
:warning: The CentOS 7 base repository provides PostgreSQL 9.2, which is too old for PeerTube. Install PostgreSQL >= 14 from
the [PostgreSQL Yum repository](https://www.postgresql.org/download/linux/redhat/) instead of the `postgresql*` packages above.
1. You need to use a more up to date version of G++ in order to run the `npm run install-node-dependencies` command, hence the installation of devtoolset-7.
```sh
@@ -147,9 +158,13 @@ sudo systemctl enable --now postgresql
sudo dnf update
sudo dnf install epel-release
sudo dnf update
sudo dnf module enable postgresql:16 # Default stream is PostgreSQL 10, too old for PeerTube
sudo dnf install nginx postgresql postgresql-server postgresql-contrib openssl gcc-c++ make wget redis git unzip
psql --version # Should be >= 14
```
Use `sudo dnf module list postgresql` to see the streams available on your release, and pick the most recent one >= 14.
1. You'll need a symlink for python3 to python for youtube-dl to work
```sh
@@ -197,7 +212,9 @@ sudo systemctl enable --now postgresql
1. Install PostgreSQL and Python3 and other stuff:
```sh
sudo dnf module enable -y postgresql:16 # Default stream is PostgreSQL 10, too old for PeerTube
sudo dnf install -y nginx postgresql postgresql-server postgresql-contrib openssl gcc-c++ make wget redis git python3 python3-pip
psql --version # Should be >= 14
sudo ln -s /usr/bin/python3 /usr/bin/python
sudo PGSETUP_INITDB_OPTIONS='--auth-host=md5' postgresql-setup --initdb --unit postgresql
sudo systemctl enable --now redis
@@ -338,7 +355,9 @@ sudo systemctl enable --now postgresql
1. Run:
```sh
sudo dnf module enable postgresql:16 # Default stream is PostgreSQL 10, too old for PeerTube
sudo dnf install nginx postgresql postgresql-server postgresql-contrib openssl gcc-c++ make wget redis git
psql --version # Should be >= 14
```
1. You'll need a symlink for python3 to python for youtube-dl to work
@@ -413,7 +432,7 @@ On a fresh install of [FreeBSD](https://www.freebsd.org), new system or new jail
```sh
pkg
pkg update
pkg install -y sudo bash wget git python nginx pkgconf postgresql13-server postgresql13-contrib redis openssl node npm ffmpeg unzip
pkg install -y sudo bash wget git python nginx pkgconf postgresql16-server postgresql16-contrib redis openssl node npm ffmpeg unzip
```
1. install `sharp` build dependencies: https://sharp.pixelplumbing.com/install/#building-from-source
@@ -457,14 +476,14 @@ On a fresh install of [FreeBSD](https://www.freebsd.org), new system or new jail
1. Add the packages:
```sh
brew install ffmpeg nginx postgresql openssl gcc make redis git
brew install ffmpeg nginx postgresql@16 openssl gcc make redis git
brew install pnpm
```
1. Run the services:
```sh
brew services run postgresql
brew services run postgresql@16
brew services run redis
```
@@ -479,7 +498,7 @@ On a fresh install of [FreeBSD](https://www.freebsd.org), new system or new jail
net-libs/nodejs
sys-apps/pnpm
media-video/ffmpeg[x264] # Optionally add vorbis,vpx
dev-db/postgresql
dev-db/postgresql:16 # Any slot >= 14 works
dev-db/redis
dev-vcs/git
app-arch/unzip
@@ -517,9 +536,9 @@ On a fresh install of [FreeBSD](https://www.freebsd.org), new system or new jail
```sh
rc-update add redis
rc-update add postgresql-11
rc-update add postgresql-16
rc-service redis start
rc-service postgresql-11 start
rc-service postgresql-16 start
```
1. Create Python version symlink for youtube-dl: