SQL Server: Implementierung eines N-Gram Suchindex (de-DE)
Alle aktuellen Web Suchmaschinen haben nur ein Eingabefeld für die Suchkriterien und liefern immer ein Ergebnis, egal was man eingibt.
Das macht uns Entwicklern nun das Leben schwer, denn mittlerweile erwarten die Endanwender dieses auch in Line-of-Business (LOB) Anwendungen, z.B. bei der Suche nach einer Adresse oder eines Artikels. Wie man eine vergleichbare Funktion mit dem MS SQL Servers implementieren kann soll dieser Artikel zeigen.
Einführung
Die Build-In Funktion Volltextindex des Microsoft SQL Server bietet eine einfache Möglichkeit, ganze Worte des Datenbestandes zu indizieren, sowie dieses flexibel und performant abzufragen. Es gibt nur ein paar Einschränkungen, die dazu führen, dass es nicht ganz für eine Websuche ähnliche Funktionalität geeignet ist.
keine Suffixsuche möglich wie *Teilbegriff
keine Schreibfehlertoleranz bezüglich Suchbegriff und/oder Daten
nur bedingte Indizierung von Satztrennzeichen und anderen Noise Words
Eine Alternative ist es, eine eigene Indizierung zu implementieren und zwar in diesem Fall den sogenannten N-Gram-Index.
Bei einem N-Gram Index werden immer alle N aufeinander folgende Buchstaben als Einheit (Fragment) gewertet und diese indiziert, wobei N ein beliebiger numerischer Wert von 1-x sein kann.
Ein Beispiel mit "Hello World" und einem 3-Gram ergibt die Einheiten
"HEL", "ELL", "LLO", "LO ", "O W", " WO", "WOR", "ORL", "RLD"
Normalisierung
Ziel ist es eine fehlertolerante und Akzent-Insensitive Suche von Adressen zu implementieren, dafür muss man die Datenbasis für den N-Gram Index entsprechend normalisiert bereitstellen. Das setzt ein paar Überlegungen vorab voraus.
Umlaute
Deutsche Umlaute wie Ü kann man durch UE ersetzen, so finden man mit dem Suchbegriff "Müller" auch den Namen "Mueller".
In anderen Sprachen gibt es Sonderzeichen, die dem lateinischen Alphabet entstammen und Kennzeichen für die Betonung enthalten, wie das polnische Ł. Solche Zeichen kann man auch ersetzen.
Satztrennzeichen
Beim Volltextindex werden Satztrennzeichen als Wortende gewertet und nicht mit indiziert. Es gibt aber durchaus Firmen- und Produktnamen, die ein Satztrennzeichen beinhalten und somit sollten dies e in einem gewissen Rahmen mit suchbar sein. Hier muss man nach Datenbestand entscheiden, ob die Satztrennzeichen beibehalten oder ersetzt werden sollen.
In diesem Beispiel werden zur Vereinfachung alle Satztrennzeichen durch ein einziges Zeichen ersetzt. So bleibt der Textaufbau erhalten, aber es wird zur Indizierung und Suche nur ein Zeichen verwendet.
Sonderzeichen
Auch hier muss man nach Datenbestand entscheiden, ob man Sonderzeichen ersetzt oder nicht. Gutes Beispiel ist das @ Zeichen bei E-Mail Adressen; möchte man gezielt nur nach der E-Mail Adresse suchen können oder soll nach den Teilen der E-Mail Adresse auch in anderen Datenbereich gesucht werden.
Damit sind in diesem Beispiel nur
Ziffern
Lateinische Großbuchstaben
Ausrufezeichen ! als allgemeiner Ersatz für alle Satztrennzeichen
Doppelkreuz # als allgemeiner Ersatz für alle anderen Sonderzeichen als Zeichen für den normalisierten Text zugelassen.
Um die Liste der zulässigen Zeichen zentral pflegen zu können, wird eine Funktion angelegt, die diese zurück liefert; globale Variablen gibt es im MS SQL Server nicht.CREATE FUNCTION dbo.fnGetNGramValidChars() RETURNS nvarchar(39) AS BEGIN -- Approved Signs for N-Gram Index -- Western capital letters + Space -- Digits -- Delimiter sign: ! -- Special character sign: # RETURN N'ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789!#'; END;
Alle anderen Zeichen werden entsprechend ersetzt. Dazu wird eine weitere Funktion erstellt, die den übergebenen Text entsprechend normalisiert, sie wird später sowohl für die Indizierung der Daten als auch für die Normalisierung des Suchbegriffes genutzt.
Hier die gekürzte Version:CREATE FUNCTION dbo.fnNormalizeDatastring (@string nvarchar(max)) RETURNS nvarchar(max) AS BEGIN -- Get all valid characters DECLARE @validChars nvarchar(50) = (SELECT dbo.dbo.fnGetNGramValidChars() AS Result); -- Only capital characters SET @string = UPPER(@string); -- Replace umlauts SET @string = REPLACE(@string, N'Ä', N'AE'); SET @string = REPLACE(@string, N'Æ', N'AE'); -- usw. ... -- Registered Marks SET @string = REPLACE(@string, N'℅', N'C#O'); SET @string = REPLACE(@string, N'™', N'TM'); -- usw. ... -- Replace delimiter by ! SET @string = REPLACE(@string, N'.', N'!'); SET @string = REPLACE(@string, N',', N'!'); -- usw. ... -- Replace special characeters by hash mark. SET @string = REPLACE(@string, N'+', N'#'); SET @string = REPLACE(@string, N'-', N'#'); -- usw. ... -- Replace all other invalid characters by space. DECLARE @loop int = 1; DECLARE @len int = LEN(@string); DECLARE @invalidChar nchar(1); WHILE @loop <= @len BEGIN SET @invalidChar = SUBSTRING(@string, @loop, 1); IF CHARINDEX(@invalidChar, @validChars) <= 0 BEGIN SET @string = REPLACE(@string, @invalidChar, N' '); END; SET @loop = @loop + 1; END; -- Remove multiple spaces DECLARE @goOn tinyint = 1; DECLARE @oldLen int; WHILE @goOn <> 0 BEGIN SET @oldLen = LEN(@string); SET @string = REPLACE(@string, N' ', N' '); SET @string = REPLACE(@string, N' ', N' '); IF @oldLen = LEN(@string) BEGIN SET @goOn = 0; END; END; RETURN @string; END;
Anmerkung: Solche aufwendigen String Operationen sind im SQL Server sehr teuer. Denkbar wäre zur Optimierung eine Implementierung als CLR Assembly.
Indexerstellung
Der Index kann einfach über die Anzahl der erlaubten Zeichen, die Position des Zeichens und dem N-Gram Wert errechnet.
Für das Fragment "BDHZ" errechnet sich der Index-Wert beispielhaft so über (Position ist 0-based)
B = Position 1 * (39^0) = 1
D = Position 4 * (39^1) = 156
H = Position 7 * (39^2) = 10.647
Z = Position 25 * (39^3) = 1.482.975
Summe = 1.493.779 abzgl. Integer Untergrenze -2.147.483.648 => Indexwert -2.145.989.869
Nehmen wir als Beispiel eine 4-Gram Index mit den oben definierten 39 Zeichen, da können 39^4 = 2.313.441 Zeichenkombinationen (Fragmente) entstehen.
Lassen wir auch negative Werte zu, reicht der Wertebereich des int Datentypen bis zum 6-Gram Index bequem aus.
Die Anzahl möglicher Indexwerte je N-Gram:
2-Gram = 39^2 = 1.521 => smallint
3-Gram = 39^3 = 59.319 => smallint
4-Gram = 39^4 = 2.313.441 => int
5-Gram = 39^5 = 90.224.199 => int
6-Gram = 39^6 = 3.518.743.761 => int
7-Gram = 39^7 = 137.231.006.679 => bigint
Die Berechnungsfunktion in T-SQL sieht wie folgt aus, wobei davon ausgegangen wird, dass das Fragment normalisiert übergeben wird.
CREATE FUNCTION dbo.fnCalculate4GramIndex
(@fragment nchar(4))
RETURNS int
AS
BEGIN
-- Get all valid characters
DECLARE @validChars varchar(50) = (SELECT dbo.fnGetNGramValidChars() AS Result);
DECLARE @size int = (SELECT LEN(@validChars) AS size);
DECLARE @id int = -2147483648;
-- Calculate position of each char
DECLARE @pos1 int = CHARINDEX(SUBSTRING(@fragment, 1, 1), @validChars);
DECLARE @pos2 int = CHARINDEX(SUBSTRING(@fragment, 2, 1), @validChars);
DECLARE @pos3 int = CHARINDEX(SUBSTRING(@fragment, 3, 1), @validChars);
DECLARE @pos4 int = CHARINDEX(SUBSTRING(@fragment, 4, 1), @validChars);
SET @id = @id + (@pos1 - 1) +
(@pos2 - 1) * @size +
(@pos3 - 1) * POWER(@size, 2) +
(@pos4 - 1) * POWER(@size, 3);
RETURN @id;
END;
Um nun alle Index-Werte für einen ganzen Text zu erzeugen, wird eine Table Valued Function (TVF) angelegt, die den Text in die Fragmente zerlegt, die Werte errechnet und das Ergebnis als eine Tabelle zurück liefert.
CREATE FUNCTION dbo.fnBuildIndex4Gram
(@string nvarchar(max))
RETURNS @ids TABLE (id int)
AS
BEGIN
SET @string = (SELECT dbo.fnNormalizeDatastring(@string));
DECLARE @pos int = 1;
DECLARE @len int = LEN(@string) - 4;
IF @len <= 0
RETURN;
WHILE @pos <= @len
BEGIN
INSERT INTO @ids (id)
SELECT dbo.fnCalculate4GramIndex(SUBSTRING(@string, @pos, 4));
SET @pos = @pos + 1;
END
RETURN;
END;
-- Small test:
SELECT *
FROM dbo.fnBuildIndex4Gram(N'Hello World') AS IDX;
Funktioniert soweit.
Als Beispiel für die Indizierung dienen die Adress-Daten im Schema "Person" aus der Datenbank AdventureWorks.
Um alle relevanten Daten in einem Feld zu erhalten, inkl. der 0-n Telefonnummern, wird eine View angelegt, die die Daten bereitstellt. Diese View hat zudem den Vorteil, dass man die Daten jederzeit erweitern kann, ohne weitere Logiken und Prozessschritte anpassen zu müssen.
CREATE VIEW Person.vAddressIndexData
AS
SELECT ADR.AddressID,
ADR.AddressLine1 + N' ' +
ISNULL(ADR.AddressLine2 + N' ', N'') +
ADR.City + N' ' +
ADR.PostalCode + N' ' +
SPR.StateProvinceCode + N' ' +
SPR.Name + N' ' +
CR.Name + N' ' +
ISNULL(PER.Title + N' ', N'') +
ISNULL(PER.FirstName + N' ', N'') +
ISNULL(PER.MiddleName + N' ', N'') +
ISNULL(PER.LastName + N' ', N'') +
ISNULL(PER.Suffix + N' ', N'') +
ISNULL(EA.EmailAddress + N' ', N'') +
ISNULL((SELECT CAST((SELECT (SELECT PHO.PhoneNumber + ','
FROM Person.PersonPhone AS PHO
WHERE PHO.BusinessEntityID = PER.BusinessEntityID
FOR XML PATH (''), Type
) AS ColCSV
) AS nvarchar(MAX))) + N' ', N'')
AS IndexData
FROM Person.Address AS ADR
INNER JOIN
Person.StateProvince AS SPR
ON ADR.StateProvinceID = SPR.StateProvinceID
INNER JOIN
Person.CountryRegion AS CR
ON SPR.CountryRegionCode = CR.CountryRegionCode
LEFT JOIN
Person.BusinessEntityAddress AS BEA
ON ADR.AddressID = BEA.AddressID
LEFT JOIN
Person.Person AS PER
ON BEA.BusinessEntityID = PER.BusinessEntityID
LEFT JOIN
Person.EmailAddress AS EA
ON PER.BusinessEntityID = EA.BusinessEntityID
AND PER.EmailPromotion = EA.EmailAddressID;
Anmerkung: Die View ist nur als Beispiel gedacht, auf Sinn und Korrektheit wurde hier nicht sonderlich geachtet.
Als nächstes brauchen wir zum Speichern der Indexwerte eine Tabelle. Benötigt wird der Primary Key des Datensatzes, hier die AddressId, und der N-Gram Indexwert. Da ein Fragment/Indexwert je Datensatz mehrfach vorkommen kann, ist ein Feld für die Häufigkeit vorgesehen statt jeden Indexwert einzeln zu speichern; was natürlich auch ging.
CREATE TABLE dbo.Gram4Index(
AddressId int NOT NULL,
ID_4Gram int NOT NULL,
Occurrence smallint NOT NULL,
CONSTRAINT PK_Gram4Index PRIMARY KEY CLUSTERED
(
AddressId ASC,
ID_4Gram ASC
)
);
Anmerkung: Die Vermischung von verschiedenen Schemas wie hier ist normalerweise nicht sinnvoll.
Bei der späteren Suchfunktion wird direkt nur der Indexwert benötigt, dazu ist der Primary Key nicht so geeignet, deswegen wird ein weiterer Index angelegt, der selektiver ist. Um unnötige Key Lookups auf die AddressId und Occurrence in der Basis Tabelle zu vermeiden, werden die Felder als Include mit aufgenommen.
CREATE NONCLUSTERED INDEX IDX_Gram4Index_Search
ON dbo.Gram4Index
(ID_4Gram)
INCLUDE (AddressId, Occurrence);
-- Some additional statistics.
CREATE STATISTICS [STAT_Gram4Index_AddressId]
ON [dbo].[Gram4Index]
([AddressId]);
CREATE STATISTICS [STAT_Gram4Index_Occurrence]
ON [dbo].[Gram4Index]
([Occurrence]);
CREATE STATISTICS [STAT_Gram4Index_AddressId_Occurrence]
ON [dbo].[Gram4Index]
(AddressId, [Occurrence]);
Wie es sich gehört sollte das Füllen der Index-Wert zentral über ein Stored Procedure erfolgen, die dann aus anderen SP's oder Triggern heraus aufgerufen werden kann.
Die folgende SP erfüllt diesen Zweck, sie löscht zunächst die alte Index-Werte und fügt die Neuen in die Index Tabelle ein; erwartet wird nur der PK AddressId als Parameter.
CREATE PROCEDURE dbo.spUpdate4Gram
@addressId int
AS
BEGIN
SET NOCOUNT ON;
-- Delete old index data
DELETE FROM dbo.Gram4Index
WHERE AddressId = @addressId;
-- Re-create Index for the Address.
INSERT INTO dbo.Gram4Index
(AddressId, ID_4Gram, Occurrence)
SELECT ADR.AddressId, NG.ID AS ID_NGram4, COUNT(*) AS Occurrence
FROM Person.vAddressIndexData AS ADR
CROSS APPLY
dbo.fnBuildIndex4Gram(ADR.IndexData) AS NG
WHERE ADR.AddressId = @addressId
GROUP BY ADR.AddressId, NG.ID;
END;
Da wir mit der AdventureWorks eine bestehende Datenbank mit vorhandenen Daten haben, brauchen wir zusätzlich noch eine Routine, die Initial die vorhandenen Daten indiziert. Dazu arbeitet man per Cursor alle vorhandenen Adressen ab.
SET NOCOUNT ON;
DECLARE @addressId int;
DECLARE addresses CURSOR LOCAL FOR
SELECT AddressId
FROM Person.Address;
OPEN addresses;
FETCH NEXT FROM addresses INTO @addressId;
WHILE (@@FETCH_STATUS = 0)
BEGIN
EXEC dbo.spUpdate4Gram @addressId;
FETCH NEXT FROM addresses INTO @addressId;
END;
CLOSE addresses;
DEALLOCATE addresses;
PRINT N'Finished';
Speicherbedarf für den Index
Jeder Index benötigt zusätzlichen Speicherplatz, das ist bei diesem N-Gram Index natürlich nicht anders.
Bei einem N-Gram Index wird für einen Text der Länge x dann (x - n) Fragmente gebildet. Je Fragment wird
AddressId = int = 4 Byte
ID_4Gram = int = 4 Byte
Occurrence = smallint = 2 Byte
Primary Key = 8 Byte
Suchindex = 10 Byte gespeichert, in der Summe alo 28 Bytes je Fragment. Hat ein Text 1.000 Zeichen, kommen bei einem 4-Gram Index also 996 * 28 Bytes = 27.888 Bytes zustande. Im Vergleich benötigt ein einfacher Index auf ein NVarChar Feld 2 Bytes je Zeichen, hier als 2.000 Bytes. Der N-Gram Index ist also ~14-mal so groß und somit vom Speicherplatz her sehr teuer.
In diesem konkreten Fall werden 30,4 MB für die Daten und 21 MB für die Indizes benötigt, Gesamt also 51,4 MB.Anmerkung: Die Größenberechnung ist stark vereinfacht, die genauere Berechnung kann man in der MSDN nachlesen: Schätzen der Größe einer Tabelle
Suchlogik
Geeignete Suchtreffer sind nun die Adressen, die möglichst viele der gleichen Indexwerte aufweisen wie im Suchstring. Je weniger Indexwerte übereinstimmen, desto weniger stimmt die Adresse mit dem Suchtext überein.
Im Gegensatz zu einem normalen Index ist die Reihenfolge einzelner Begriff im Suchtext wie auch in den Daten nahezu unerheblich.
Andererseits können natürlich Fragmente aus dem Suchtext auch in völlig anderen Kombinationen in den Daten vorkommen.
Für die Suche wird zunächst der gesamte Suchtext mittels der Funktion dbo.fnBuildIndex4Gram wie zuvor in die einzelnen Indexwerte zerlegt, diese mit den gespeicherten Indexwerten gejoint und nun die Übereinstimmungen gezählt, genauer gesagt wird die Summe über "Occurrence" gebildet und so häufige im Text vorkommende, übereinstimmende Indexwerte stärker gewichtet.
Über den Parameter @factor kann gesteuert werden, wieviele Indexwerte prozentual übereinstimmen müssen, um als Suchergebnis zu gelten. Der Parameter kann Werte zwischen 0.0 und 1.0 haben, je größer der Wert, desto genauer ist das Suchergebis. Verringert man den Faktor, werden auch ungenaue Ergebnisse zurück geliefert, z.B. auch welche mit Schreibfehler.
CREATE PROCEDURE dbo.spSearch4Gram
(@search nvarchar(4000),
@factor decimal(9, 4)
)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @len int;
-- Normalize search string.
SET @search = (SELECT dbo.fnNormalizeDatastring(@search));
SET @len = (LEN(@search) - 4) * @factor;
IF @len <= 0
SET @len = 1;
-- Weighted 4-Gram index search
;WITH idx AS
(SELECT GR4.AddressId, SUM(GR4.Occurrence) AS Weigth
FROM dbo.fnBuildIndex4Gram(@search) AS BIG
INNER JOIN
dbo.Gram4Index AS GR4
ON GR4.ID_4Gram = BIG.id
GROUP BY GR4.AddressId
HAVING SUM(GR4.Occurrence) > @len
)
SELECT ADR.AddressID,
ADR.AddressLine1,
ADR.City,
ADR.City,
idx.Weigth
FROM Person.Address AS ADR WITH (NOLOCK)
INNER JOIN
idx
ON ADR.AddressID = idx.AddressId
ORDER BY idx.weigth DESC;
END;
-- Test cases
-- Umlaut search => "Buergermeister" as result
EXEC dbo.spSearch4Gram N'Saarland Bürgermeister', 0.8;
-- and counter wise
EXEC dbo.spSearch4Gram N'rotthaeuser germany saarbruecken', 0.8;
-- City name and telephon number without area code.
EXEC dbo.spSearch4Gram N'Springfield -555-0181', 0.7;
-- Typeo: "Bery" instead "Berry"
EXEC dbo.spSearch4Gram N'Bery court', 0.6;
-- Some part terms
EXEC dbo.spSearch4Gram N'West gloria California 91791', 0.75;
-- Long search text
EXEC dbo.spSearch4Gram N'5157 Washington 98027 lane marywood Issaquah', 0.8;
-- Large result set
EXEC dbo.spSearch4Gram N'Washington', 0.8;
Zusammenfassung
Der N-Gram Index ist leicht mit Standard Transact-SQL zu implementieren und kann einfach an Besonderheiten des jeweiligen Datenbestandes und der gewünschten Suchlogik angepasst werden.
Die Suchfunktion ist sehr performant und die Genauigkeit oder Toleranz gegenüber Schreibfehlern kann über einen Parameter individuell gesteuert werden sowie über die Wahl des N Wertes.
Diese Vorteile erkauft man sich dafür mit einem hohen Speicherbedarf für die Indexwerte.
Wie bei jeder Lösung kann man auch bei dieser noch Optimierungspotential finden, z.B. die Verwendung eines Column Store Indexes, was aber dann den Einsatz auf die Enterprise Edition einschränkt.
Andere Sprachen
SQL Server: Implementation of N-Gram Search Index
Weblinks
https://de.wikipedia.org/wiki/N-Gramm
https://en.wikipedia.org/wiki/N-gram