Considérez ces tableaux simplifiés:
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,
)
La table words
contient environ 9000 enregistrements, chacun contenant un seul mot ('téléphone', 'canapé', 'maison', 'chien', ...) La table items
contient environ 12000 enregistrements, chacun avec un corps de texte ne dépassant pas 256 caractères.
Maintenant, je dois mettre à jour la table words
, en comptant le nombre d'enregistrements dans la table items
qui contiennent (au moins une fois) le texte dans le champ Word . Je dois tenir compte des mots partiels, donc tous ces 4 enregistrements doivent être comptés pour le mot chien:
'This is my dog'
'I really like the movie dogma'
'my cousin has sheepdogs'
'dog dog dog doggerdy dog dog'
Ce dernier exemple devrait compter comme un seul enregistrement (contient le terme "chien" au moins une fois).
Je peux utiliser cette requête:
UPDATE dbo.words
SET hits = (SELECT COUNT(*) FROM dbo.items WHERE body like '%' + Word + '%')
Mais, c'est extrêmement lent, cela prendra plus de 10 minutes pour terminer sur le serveur pas trop lourd que j'ai pour cela.
Les index AFAIK n'aideront pas, je fais des recherches COMME. Je pense également que le texte intégral ne m'aidera pas, car je recherche des mots commençant, terminant ou contenant mon terme de recherche. Je peux me tromper ici.
Des conseils sur la façon d'accélérer cela?
Le meilleur moyen que j'ai trouvé pour accélérer les recherches de caractères génériques LIKE
est d'utiliser des n-grammes. Je décris la technique et fournit un exemple d'implémentation dans Trigram Wildcard String Search dans SQL Server .
L'idée de base d'une recherche de trigrammes est assez simple:
- Conserver les sous-chaînes à trois caractères (trigrammes) des données cibles.
- Divisez le ou les termes de recherche en trigrammes.
- Faites correspondre les trigrammes de recherche avec les trigrammes stockés (recherche d'égalité).
- Intersectez les lignes qualifiées pour trouver des chaînes qui correspondent à tous les trigrammes.
- Appliquez le filtre de recherche d'origine à l'intersection très réduite.
Il peut convenir à vos besoins, mais sachez:
La recherche de trigrammes n'est pas une panacée. Les exigences de stockage supplémentaires, la complexité de la mise en œuvre et l'impact sur les performances de mise à jour pèsent lourdement sur lui.
J'ai effectué un test rapide en utilisant la Complete Works of Shakespeare pour remplir la colonne body
de la table items
avec 15 838 lignes. J'ai chargé la table words
avec 7 669 mots uniques du même texte.
Les structures de trigrammes construites en environ 2 secondes et l'instruction de mise à jour suivante terminée en 5 secondes sur mon ordinateur portable de milieu de gamme:
UPDATE dbo.words WITH (TABLOCK)
SET hits =
(
SELECT COUNT_BIG(*)
FROM dbo.Items_TrigramSearch
('%' + Word +'%') AS ITS
);
Une sélection du tableau des mots mis à jour:
Les scripts trigrammes modifiés de mon article sont ci-dessous:
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;
La seule autre modification consistait à ajouter un index clusterisé à la table items
:
CREATE UNIQUE CLUSTERED INDEX cuq ON dbo.items (id);
Êtes-vous sûr d'en avoir besoin pour être plus rapide? Vous avez annulé la requête après 10 minutes, mais vous n'avez pas vraiment de moyen de juger des progrès. Que se passe-t-il si la requête a été effectuée à 90% + lorsque vous l'avez annulée? À quelle vitesse avez-vous vraiment besoin de la requête? À quelle fréquence exécuterez-vous une mise à jour comme celle-ci?
Je pose ces questions car je peux obtenir un UPDATE
similaire pour terminer sur ma machine en 144 secondes lors de l'exécution à MAXDOP 1
. La requête convient également très bien au parallélisme des requêtes. Lorsque je force l'exécution de la requête à MAXDOP 8
il se termine en 20 secondes sur ma machine.
Notez que le classement peut avoir beaucoup d'importance ici. Les nombres ci-dessus sont avec le classement SQL_Latin1_General_CP1_CS_AS
. Si je change le classement des colonnes en Latin1_General_CI_AS
alors le code est environ huit fois plus lent. De plus, mes données de test et mon matériel sont peut-être sensiblement différents des vôtres. Je suggère toujours d'estimer la durée totale d'exécution de votre requête, puis de décider si vous devez essayer une solution plus exotique. Vous pouvez le faire en créant une table temporaire avec 1% des lignes dans dbo.words
et voir combien de temps le UPDATE
prend pour la petite table. Si vous multipliez le temps d'exécution de la requête par 100, cela devrait être une assez bonne estimation de la réalité.
Dans le code ci-dessous, j'ai utilisé CHARINDEX
au lieu de LIKE
car c'est plus rapide lorsque l'on vérifie simplement l'occurrence d'une chaîne dans une autre chaîne. Si nécessaire, la requête UPDATE
peut être encouragée à s'exécuter en parallèle avec une indication d'utilisation non documentée ENABLE_PARALLEL_PLAN_PREFERENCE
. Voici la requête:
UPDATE #words
SET hits = (SELECT COUNT(*) FROM #items WHERE CHARINDEX(Word, body) > 0)
OPTION (MAXDOP 1);
Données de test:
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;
Je ne peux pas penser à une manière SQL, mais si vous êtes prêt à sortir des sentiers battus, il existe une approche différente qui peut être viable. Votre jeu de données est assez petit. 256 * 12000 + 32*9000 = 3360000
. C'est un peu plus de 3 Mo; ces données s'intègrent facilement même dans le cache CPU de la plupart des processeurs modernes. Ainsi, vous pouvez écrire une petite application dans le langage de programmation de votre choix qui sélectionne simplement toutes les données, effectue le calcul et met à jour les données. Cela ne devrait prendre que quelques secondes.
Si c'est encore trop lent, vérifiez quel type de boucle est le plus rapide - d'abord sur les mots puis sur les éléments, ou inversement. Si la surcharge de votre langage de programmation est suffisamment grande pour que les données pas tout à fait tiennent dans le cache CPU, l'une d'elles sera plus rapide que l'autre.