web-dev-qa-db-ja.com

テーブル内の単語の出現を数えるのが遅い

次の簡略化されたテーブルを考えてみます。

CREATE TABLE dbo.words
(
    id bigint NOT NULL IDENTITY (1, 1),
    Word varchar(32) NOT NULL,
    hits int NULL
)

CREATE TABLE dbo.items
(
    id bigint NOT NULL IDENTITY (1, 1),
    body varchar(256) NOT NULL,
)

wordsテーブルには約9000レコードが保持され、それぞれに1つの単語( 'phone'、 'sofa'、 'house'、 'dog'、...)が保持されますitemsテーブルには約12000のレコードが保持されますそれぞれ256文字以下の本文テキストを持つレコード。

次に、wordsテーブルを更新し、_Wordフィールドのテキストを(少なくとも1回)保持するitemsテーブル内のレコード数をカウントする必要があります。 。部分的な単語を説明する必要があるので、これらの4つのレコードすべてをWordとしてカウントする必要がありますdog

'This is my dog'  
'I really like the movie dogma'  
'my cousin has sheepdogs'  
'dog dog dog doggerdy dog dog'

最後の例は、1つのレコードとして数える必要があります(「犬」という用語が少なくとも1回含まれています)。

このクエリを使用できます:

UPDATE dbo.words
SET hits = (SELECT COUNT(*) FROM dbo.items WHERE body like '%' + Word + '%')

しかし、これは非常に遅く、私が持っているそれほど重いサーバーでは完了までに10分以上かかります。

AFAIKインデックスは役に立ちません。私はLIKE検索を行っています。また、検索用語の先頭、末尾、または検索語を含む単語を探しているため、フルテキストは役に立たないと思います。私はここで間違っている可能性があります。

これをスピードアップする方法についてアドバイスはありますか?

6
palloquin

主要なワイルドカードLIKE検索を高速化するために私が見つけた最良の方法は、n-gramを使用することです。 SQL Serverでのトライグラムワイルドカード文字列検索 でテクニックを説明し、サンプル実装を提供します。

トライグラム検索の基本的な考え方は非常に簡単です。

  1. ターゲットデータの3文字の部分文字列(トライグラム)を保持します。
  2. 検索語をトライグラムに分割します。
  3. 保存されたトライグラムに対して検索トライグラムを照合します(等価検索)。
  4. 修飾された行を交差させて、すべてのトライグラムに一致する文字列を見つけます。
  5. 大幅に削減された交差に元の検索フィルターを適用します。

それはあなたのニーズに適しているかもしれませんが、注意してください:

トライグラム検索は万能薬ではありません。追加のストレージ要件、実装の複雑さ、および更新パフォーマンスへの影響はすべて、それに対する重荷になります。

テスト

Complete Works of Shakespeareを使用してクイックテストを実行し、bodyテーブルのitems列に15,838行を設定しました。 wordsテーブルに、同じテキストから7,669の一意の単語をロードしました。

私のミッドレンジラップトップでは、約2秒で構築されたトライグラム構造と次の更新ステートメントが5秒で完了しました。

UPDATE dbo.words WITH (TABLOCK)
SET hits = 
(
    SELECT COUNT_BIG(*) 
    FROM dbo.Items_TrigramSearch
        ('%' + Word +'%') AS ITS
);

更新された単語テーブルの選択:

sample

私の記事から変更されたトライグラムスクリプトは以下のとおりです。

CREATE FUNCTION dbo.GenerateTrigrams (@string varchar(255))
RETURNS table
WITH SCHEMABINDING
AS RETURN
    WITH
        N16 AS 
        (
            SELECT V.v 
            FROM 
            (
                VALUES 
                    (0),(0),(0),(0),(0),(0),(0),(0),
                    (0),(0),(0),(0),(0),(0),(0),(0)
            ) AS V (v)),
        -- Numbers table (256)
        Nums AS 
        (
            SELECT n = ROW_NUMBER() OVER (ORDER BY A.v)
            FROM N16 AS A 
            CROSS JOIN N16 AS B
        ),
        Trigrams AS
        (
            -- Every 3-character substring
            SELECT TOP (CASE WHEN LEN(@string) > 2 THEN LEN(@string) - 2 ELSE 0 END)
                trigram = SUBSTRING(@string, N.n, 3)
            FROM Nums AS N
            ORDER BY N.n
        )
    -- Remove duplicates and ensure all three characters are alphanumeric
    SELECT DISTINCT 
        T.trigram
    FROM Trigrams AS T
    WHERE
        -- Binary collation comparison so ranges work as expected
        T.trigram COLLATE Latin1_General_BIN2 NOT LIKE '%[^A-Z0-9a-z]%';
GO
-- Trigrams for items table
CREATE TABLE dbo.ItemsTrigrams
(
    id integer NOT NULL,
    trigram char(3) NOT NULL
);
GO
-- Generate trigrams
INSERT dbo.ItemsTrigrams WITH (TABLOCKX)
    (id, trigram)
SELECT
    E.id,
    GT.trigram
FROM dbo.items AS E
CROSS APPLY dbo.GenerateTrigrams(E.body) AS GT;
GO
-- Trigram search index
CREATE UNIQUE CLUSTERED INDEX
    [CUQ dbo.ItemsTrigrams (trigram, id)]
ON dbo.ItemsTrigrams (trigram, id)
WITH (DATA_COMPRESSION = ROW);
GO
-- Selectivity of each trigram (performance optimization)
CREATE OR ALTER VIEW dbo.ItemsTrigramCounts
WITH SCHEMABINDING
AS
SELECT ET.trigram, cnt = COUNT_BIG(*)
FROM dbo.ItemsTrigrams AS ET
GROUP BY ET.trigram;
GO
-- Materialize the view
CREATE UNIQUE CLUSTERED INDEX
    [CUQ dbo.ItemsTrigramCounts (trigram)]
ON dbo.ItemsTrigramCounts (trigram);
GO
-- Most selective trigrams for a search string
-- Always returns a row (NULLs if no trigrams found)
CREATE FUNCTION dbo.Items_GetBestTrigrams (@string varchar(255))
RETURNS table
WITH SCHEMABINDING AS
RETURN
    SELECT
        -- Pivot
        trigram1 = MAX(CASE WHEN BT.rn = 1 THEN BT.trigram END),
        trigram2 = MAX(CASE WHEN BT.rn = 2 THEN BT.trigram END),
        trigram3 = MAX(CASE WHEN BT.rn = 3 THEN BT.trigram END)
    FROM 
    (
        -- Generate trigrams for the search string
        -- and choose the most selective three
        SELECT TOP (3)
            rn = ROW_NUMBER() OVER (
                ORDER BY ETC.cnt ASC),
            GT.trigram
        FROM dbo.GenerateTrigrams(@string) AS GT
        JOIN dbo.ItemsTrigramCounts AS ETC
            WITH (NOEXPAND)
            ON ETC.trigram = GT.trigram
        ORDER BY
            ETC.cnt ASC
    ) AS BT;
GO
-- Returns Example ids matching all provided (non-null) trigrams
CREATE FUNCTION dbo.Items_GetTrigramMatchIDs
(
    @Trigram1 char(3),
    @Trigram2 char(3),
    @Trigram3 char(3)
)
RETURNS @IDs table (id integer PRIMARY KEY)
WITH SCHEMABINDING AS
BEGIN
    IF  @Trigram1 IS NOT NULL
    BEGIN
        IF @Trigram2 IS NOT NULL
        BEGIN
            IF @Trigram3 IS NOT NULL
            BEGIN
                -- 3 trigrams available
                INSERT @IDs (id)
                SELECT ET1.id
                FROM dbo.ItemsTrigrams AS ET1 
                WHERE ET1.trigram = @Trigram1
                INTERSECT
                SELECT ET2.id
                FROM dbo.ItemsTrigrams AS ET2
                WHERE ET2.trigram = @Trigram2
                INTERSECT
                SELECT ET3.id
                FROM dbo.ItemsTrigrams AS ET3
                WHERE ET3.trigram = @Trigram3
                OPTION (MERGE JOIN);
            END;
            ELSE
            BEGIN
                -- 2 trigrams available
                INSERT @IDs (id)
                SELECT ET1.id
                FROM dbo.ItemsTrigrams AS ET1 
                WHERE ET1.trigram = @Trigram1
                INTERSECT
                SELECT ET2.id
                FROM dbo.ItemsTrigrams AS ET2
                WHERE ET2.trigram = @Trigram2
                OPTION (MERGE JOIN);
            END;
        END;
        ELSE
        BEGIN
            -- 1 trigram available
            INSERT @IDs (id)
            SELECT ET1.id
            FROM dbo.ItemsTrigrams AS ET1 
            WHERE ET1.trigram = @Trigram1;
        END;
    END;

    RETURN;
END;
GO
-- Search implementation
CREATE FUNCTION dbo.Items_TrigramSearch
(
    @Search varchar(255)
)
RETURNS table
WITH SCHEMABINDING
AS
RETURN
    SELECT
        Result.body
    FROM dbo.Items_GetBestTrigrams(@Search) AS GBT
    CROSS APPLY
    (
        -- Trigram search
        SELECT
            E.id,
            E.body
        FROM dbo.Items_GetTrigramMatchIDs
            (GBT.trigram1, GBT.trigram2, GBT.trigram3) AS MID
        JOIN dbo.Items AS E
            ON E.id = MID.id
        WHERE
            -- At least one trigram found 
            GBT.trigram1 IS NOT NULL
            AND E.body LIKE @Search

        UNION ALL

        -- Non-trigram search
        SELECT
            E.id,
            E.body
        FROM dbo.Items AS E
        WHERE
            -- No trigram found 
            GBT.trigram1 IS NULL
            AND E.body LIKE @Search
    ) AS Result;

その他の唯一の変更は、クラスター化インデックスをitemsテーブルに追加することでした。

CREATE UNIQUE CLUSTERED INDEX cuq ON dbo.items (id);
9
Paul White 9

もっと速くする必要がありますか? 10分後にクエリをキャンセルしましたが、実際に進行状況を判断する方法がありません。キャンセルしたときにクエリが90%以上完了した場合はどうなりますか?クエリは本当にどれほど速く必要ですか?このような更新をどのくらいの頻度で実行しますか?

MAXDOP 1で実行すると、同様のUPDATEが144秒で私のマシンで終了するため、これらの質問をします。クエリは、クエリの並列処理にも適しています。クエリをMAXDOP 8で強制的に実行すると、私のマシンでは20秒で終了します。

ここでは照合がかなり重要になることに注意してください。上記の数値は、照合順序SQL_Latin1_General_CP1_CS_ASを使用したものです。列の照合順序をLatin1_General_CI_ASに変更すると、コードは約8倍遅くなります。さらに、おそらく私のテストデータとハードウェアはあなたのものとはかなり異なります。それでも、クエリの合計実行時間を見積もり、よりエキゾチックなソリューションを試す必要があるかどうかを判断することをお勧めします。これを行うには、dbo.wordsの行の1%を含む一時テーブルを作成し、小さいテーブルでUPDATEにかかる時間を確認します。クエリの実行時間に100を掛けると、実際の見積もりはかなり良くなります。

以下のコードでは、CHARINDEXではなくLIKEを使用しました。これは、別の文字列内の文字列の出現をチェックするだけの方が速いためです。必要に応じて、文書化されていない使用のヒント ENABLE_PARALLEL_PLAN_PREFERENCE と並行してUPDATEクエリを実行することを推奨できます。これがクエリです:

UPDATE #words
SET hits = (SELECT COUNT(*) FROM #items WHERE CHARINDEX(Word, body) > 0)
OPTION (MAXDOP 1);

テストデータ:

CREATE TABLE #items
(
    body varchar(256) NOT NULL
)

INSERT INTO #items WITH (TABLOCK)
SELECT TOP (12000) text
FROM sys.messages
WHERE LEN(text) <= 256
AND CAST(text AS VARCHAR(256)) = CAST(text AS NVARCHAR(256))
ORDER BY LEN(text) DESC;

CREATE TABLE #words
(
    id bigint NOT NULL IDENTITY (1, 1),
    Word varchar(32) NOT NULL,
    hits int NULL,
    PRIMARY KEY (id)
)

INSERT INTO #words (Word, hits)
SELECT DISTINCT TOP (9000)  LEFT(Word, 32), NULL
FROM (
    SELECT LEFT(body, CHARINDEX(' ', body)) Word
    FROM #items

    UNION ALL

    SELECT LEFT(body, -1 + CHARINDEX(' ', body)) a
    FROM #items

    UNION ALL

    SELECT RIGHT(body, CHARINDEX(' ', REVERSE(body)))
    FROM #items

    UNION ALL

    SELECT RIGHT(body, -1 + CHARINDEX(' ', REVERSE(body)))
    FROM #items
) q;
4
Joe Obbish

私はSQLの方法を考えることはできませんが、箱から出して考えたければ、実行可能な別のアプローチがあります。データセットはかなり小さいです。 256 * 12000 + 32*9000 = 3360000。これは3MB強です。このデータは、ほとんどの最新のCPUのCPUキャッシュ内にも簡単に収まります。したがって、すべてのデータを選択し、計算を実行し、データを更新する、選択したプログラミング言語で小さなアプリケーションを作成できます。これには数秒かかります。

それでも遅すぎる場合は、どの種類のループが速いかを確認してください。最初に単語に対して、次にアイテムに対して、またはその逆です。プログラミング言語のオーバーヘッドがデータ完全ではないがCPUキャッシュに収まるほど大きい場合、これらの1つは他よりも高速になります。

0
Vilx-