使用 Postgres 的全文搜尋構建可擴充套件的事件驅動搜尋架構

banq發表於2022-11-24

需求:
搜尋由三個可搜尋欄位、標題和描述(使用全文搜尋)和文件 ID 組成的文件,能夠查詢包含文件 ID 的字串。
搜尋應該在不超過 200 毫秒的時間內執行超過 100 萬個文件。

PostgreSQL 支援全文搜尋。全文索引允許對文件進行預處理並儲存索引以供以後快速搜尋。

-- Create the Documents index table
CREATE TABLE IF NOT EXISTS index."documents_index" 
                    (
                     "id" SERIAL,
                     "created_on" bigint NOT NULL,
                     "updated_on" bigint NOT NULL,
                     "customer_id" character varying(150) NOT NULL,
                     "document_id" character varying(255) NOT NULL,
                     "document_type" character varying(50) NOT NULL,
                     "document_title" text,
                     "document_description" text,
                     "words" text,
                     "ts" tsvector GENERATED ALWAYS AS (to_tsvector('english',document_id || ' ' || document_title || ' ' || document_description)) STORED,
                     "metadata" jsonb,
                     CONSTRAINT "documents_index.primary_key" PRIMARY KEY ("customer_id", "document_id", "document_type"))

-- Create GIN index on the ts (tsvector) column to improve search.
CREATE INDEX IF NOT EXISTS documents_index_ts_idx ON index."documents_index" USING GIN (ts);

-- Create Trigram Index on words column. (Requires installing pg_trgm Postgres extension)
CREATE INDEX IF NOT EXISTS documents_index_trgm_idx on index."documents_index" USING GIN ("words" gin_trgm_ops);

  • 我們在表中新增了一個新的列ts,以儲存預處理的搜尋檔案(即詞庫列表)。ts是一個生成的列(Postgres 12的新列),自動與源資料同步。然後我們在ts列上建立了一個tsvector型別的GIN索引。
  • 為了實現模糊搜尋,我們使用了pg_trgm Postgres擴充套件,並在表中新增了一個word列來儲存可搜尋文字。該列儲存的是可搜尋欄位的連線字串。
  • 最後,pg_trgm擴充套件提供了GiST和GIN索引運算子類。該索引允許我們在單詞文字列上建立索引,以便進行快速的相似性搜尋。


import {BeforeInsert, BeforeUpdate, Column, Entity, Generated, PrimaryColumn} from 'typeorm';

@Entity({name: 'index', schema: 'documents_index'})
export class Document {

    @PrimaryColumn({name: 'id', nullable: false})
    @Generated('increment')
    id: number;

    @Column({name: 'created_on', nullable: false})
    createdOn: number;

    @Column({name: 'updated_on', nullable: false})
    updatedOn: number;
    
    @Column({name: 'customer_id', nullable: false})
    zoneId: string;

    @Column({name: 'document_id', nullable: false})
    documentId: string;

    @Column({name: 'document_type', nullable: false})
    documentType: string;

    @Column({name: 'document_title', nullable: false})
    documentTitle: string;

    @Column({name: 'document_description', nullable: false})
    documentDescription: string;

    @Column({name: 'words', type: 'text'})
    words: string;

    @BeforeInsert()
    @BeforeUpdate()
    async calculateWords() {
        const fullText = this.documentId + ' ' + this.documentTitle + ' ' + this.documentDescription;
        const unique = Array.from(new Set(fullText.split(' ')));
        this.words = unique.join(' ');
    }

    @Column({name: 'metadata', type: 'jsonb'})
    metadata: any;

    @Column({name: 'ts', type: 'tsvector'})
    tsVector: any;

}


我們測試了下面的查詢,它使用pgbench工具返回 436 行:

SELECT id, document_id, document_title, document_description, 
COALESCE(similarity(words, 'management system'),0) + COALESCE(ts_rank_cd(ts, 'management & system'),0) as relevancy 
FROM "index"."documents_index" WHERE 
customer_id = '1' AND (
    ts @@ to_tsquery('english', 'management & system') 
--    OR words ILIKE '%management system%'
    ) 
ORDER BY relevancy DESC, id ASC


我們能夠實現每秒約 170 筆交易。


大量資料儲存在資料庫中,效能和擴充套件會隨著資料的增長而受到影響。分割槽透過將大表分成較小的表來解決這個問題,減少記憶體交換問題和表掃描,並提高效能。

-- Create the Documents index table
CREATE TABLE IF NOT EXISTS index."documents_index" 
                    (
                     "id" SERIAL,
                     "created_on" bigint NOT NULL,
                     "updated_on" bigint NOT NULL,
                     "customer_id" character varying(150) NOT NULL,
                     "document_id" character varying(255) NOT NULL,
                     "document_type" character varying(50) NOT NULL,
                     "document_title" text,
                     "document_description" text,
                     "words" text,
                     "ts" tsvector GENERATED ALWAYS AS (to_tsvector('english',document_id || ' ' || document_title || ' ' || document_description)) STORED,
                     "metadata" jsonb,
                     CONSTRAINT "documents_index.primary_key" PRIMARY KEY ("customer_id", "document_id", "document_type"))  PARTITION by HASH("customer_id")

-- Create GIN index on the ts (tsvector) column to improve search.
CREATE INDEX IF NOT EXISTS documents_index_ts_idx ON index."documents_index" USING GIN (ts);

-- Create Trigram Index on words column. (Requires installing pg_trgm Postgres extension)
CREATE INDEX IF NOT EXISTS documents_index_trgm_idx on index."documents_index" USING GIN ("words" gin_trgm_ops);

CREATE TABLE IF NOT EXISTS index."documents_index_part_1" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 0);
CREATE TABLE IF NOT EXISTS index."documents_index_part_2" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 1);
CREATE TABLE IF NOT EXISTS index."documents_index_part_3" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 2);
CREATE TABLE IF NOT EXISTS index."documents_index_part_4" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 3);
CREATE TABLE IF NOT EXISTS index."documents_index_part_5" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 4);
CREATE TABLE IF NOT EXISTS index."documents_index_part_6" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 5);
CREATE TABLE IF NOT EXISTS index."documents_index_part_7" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 6);
CREATE TABLE IF NOT EXISTS index."documents_index_part_8" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 7);
CREATE TABLE IF NOT EXISTS index."documents_index_part_9" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 8);
CREATE TABLE IF NOT EXISTS index."documents_index_part_10" partition of index."documents_index_part" for values with (MODULUS 10, REMAINDER 9);


對 Index Storage 表進行分割槽後,我們實現了近 60% 的查詢效能提升。


術語
1、詞幹化Stemming
這是一個將一個詞還原為其詞幹的過程,該詞幹與字尾和字首或詞根相連,被稱為詞根,以確保該詞的變體在搜尋中與結果相匹配。例如,管理、經理、管理可以從一個詞Manag中提取詞幹,在搜尋manag這個詞時,將返回具有這個詞的任何變體的結果。線上詞幹工具


2、詞幹NGram
它就像一個在單詞上移動的滑動視窗--一個連續的字元序列,直到指定的長度。例如,單詞將變成{'w', 'wo, 'wor', 'ord', 'rd'}。NGram可以用來搜尋一個詞的各個部分,甚至從中間搜尋。最常用的NGram型別是Trigram 。

3、模糊性
模糊性 "指的是在比較兩個字串時,解決方案不尋求完美的、逐個位置的匹配。相反,它們允許一些不匹配(或'模糊性')。例如,對succesful這個詞的搜尋也會返回有success的結果。常見的應用包括拼寫檢查和垃圾郵件過濾。

4、相似性
兩個詞的相似性可以透過計算它們共有的卦數來衡量。這個簡單的想法對於測量許多自然語言中單詞的相似性非常有效。

5、排名
排名試圖衡量文件與特定查詢的相關程度,這樣當有許多匹配時,最相關的文件可以被首先顯示出來 Postgres支援排名和加權排名。通常情況下,加權是用來標記文件的特殊區域的詞,如標題或最初的摘要,以便它們可以比文件正文中的詞有更多或更少的重要性。

相關文章