Montag, 10. September 2012

Einige Gedanken zu Oracle TEXT und Tabellen-Partitionierung

Dieses Blog Posting widmet sich dem Thema Partitionierung und Oracle TEXT. Die Grundzüge der Partitionierung werden hier jedoch nicht mehr erläutert; es wird davon ausgegangen, dass der Leser weiss, wie Partitionierung funktioniert. Informationen zum Thema finden sich in der der Dokumentation im "VLDB and Partitioning Guide" und im Data Sheet "Partitioning".
Es wird also darüber nachgedacht, eine Tabelle mit Dokumenten, auf die ein Oracle TEXT Index angelegt werden soll, zu partitionieren. Partitionierung kann folgende Vorteile bieten:
  • Wenn der Partitionierungsschlüssel gleichzeitig ein Abfragekriterium ist, kann der Optimizer die jeweilige Abfrage auf die relevanten Partitionen beschränken (Partition Pruning). Damit Oracle TEXT davon profitiert, braucht es einen lokal partitionierten Textindex.
  • Partitionierung erlaubt administrative Arbeiten an ganzen Partitionen - so können Partitionen als Ganzes gelöscht werden. Als eigene Tabelle vorhandene Daten können per Partition Exchange als neue Partition an die Tabelle gehängt werden. Auch hier sollte der Oracle TEXT Index lokal partitioniert sein; ein globaler Textindex müsste ansonsten komplett neu gebaut werden.
Ein Oracle TEXT Index sollte also fast immer lokal partitioniert sein, also die gleiche Partitionierung aufweisen, wie die zugrundeliegende Tabelle. Ist der Index nicht partitioniert, gehen nicht nur einige Vorteile verloren; es können sogar Mehraufwände entstehen; bspw. wenn eine Tabellenpartition (alte Daten) gelöscht wird. Ein lokal partitionierter Oracle TEXT Index wird wie folgt erstellt - zunächst erzeugen wir eine Beispieltabelle.

create table doktest_part (
  id          number(10),
  text        varchar2(200),
  datum       date
)
partition by range(datum) (
  partition p_alt values less than (to_date('2010-12-31','YYYY-MM-DD')),
  partition p_2011 values less than (to_date('2011-12-31','YYYY-MM-DD')),
  partition p_2012 values less than (to_date('2012-12-31', 'YYYY-MM-DD'))
)
/

insert into doktest_part values (1, 'Dies ist ein Text aus 2011', DATE'2011-08-01');
insert into doktest_part values (2, 'Oracle TEXT indiziert Texte auch in 2012', DATE'2012-09-01');

commit
/
Danach kommt der Volltextindex:
create index ft_doktest on doktest_part (text)
indextype is ctxsys.context
local
/
Das Schlüsselwort local erzeugt einen zur Tabelle "lokal" partitionierten Textindex. Eine wichtige Einschränkung sei an dieser Stelle genannt. Alle Domain-Indizes, also auch Oracle TEXT, unterstützen nur RANGE-Partitioning. Die Tabelle muss also RANGE-Partitioniert sein, damit ein lokal partitionierter Textindex gebaut werden kann. Ist die Tabelle anders partitioniert, stößt das CREATE INDEX auf einen Fehler.
Wenn nun aber eher eine List oder Hash-Partitionierung gebraucht wird, muss man diese mit einer RANGE-Partitionierung emulieren. Wird beispielsweise ein HASH-Partitioning mit 4 Partitionen benötigt, so kann man die Partitionsnummer mit der Funktion ORA_HASH selbst generieren. Dann lässt sich die Tabelle mit einer virtuellen Spalte ausstatten - und danach kann man wieder eine normale RANGE-Partitionierung einsetzen. Diese wirkt nun aber wie eine Hash-Partitionierung; anhand des Primärschlüssels werden die Zeilen nun möglichst gleichmäßig über die vier Partitionen verteilt.
create table doktest_hashpart (
  id          number(10),
  text        varchar2(200),
  part# as (ora_hash(id, 4, 81978923))
)
partition by range(part#) (
  partition p_h1 values less than (2),
  partition p_h2 values less than (3),
  partition p_h3 values less than (4),
  partition p_h4 values less than (5)
)
/
Auch LIST-Partitioning lässt sich mit RANGE-Partitioning emulieren. Geht es um einfache Buchstabenkürzel, so kann man diese alphabetisch sortieren und direkt mit LESS THAN arbeiten; Oracle ordnet die Zeilen dann anhand binärer Sortierung zu. Manchmal ist die Praxis aber nicht so einfach. Angenommen, es soll nach einem Abteilungskürzel wie folgt partitioniert werden:
  • Partition 1: Abteilungen A, B, C
  • Partition 2: Abteilungen A1, A2, A3
  • Partition 3: Abteilungen D-F
Also - "A" ist eine andere Abteilung als "A1" - und die werden in unterschiedliche Partitionen einsortiert. Mit einem einfachen LESS THAN kann das nicht mehr ausgedrückt werden; da die Partition 1 als LESS THAN ('B') definiert werden müsste, würden A1 bis A3 ebenfalls dort einsortiert. Allerdings kann uns ein SQL CASE Konstrukt weiterhelfen ...
  case
    when abteilung in ('A', 'B', 'C') then 1
    when abteilung in ('A1', 'A2', 'A3') then 2
    when abteilung in ('D', 'E', 'F') then 3
  end
Damit können wir die Tabelle wieder als RANGE-Partionierte Tabelle erzeugen, wobei wir aber de-facto eine LIST-Partionierte Tabelle haben.
create table doktest_listpart (
  id          number(10),
  text        varchar2(200),
  abteilung   varchar2(3),
  part# as (case 
    when abteilung in ('A', 'B', 'C') then 1
    when abteilung in ('A1', 'A2', 'A3') then 2
    when abteilung in ('D', 'E', 'F') then 3
    end
  )
)
partition by range(part#) (
  partition p_abc    values less than (2),
  partition p_a1a2a3 values less than (3),
  partition p_def    values less than (4)
)
/
An dieser Stelle aber eine Warnung zu den virtuellen Spalten: Sobald eine Tabelle anhand einer virtuelle Spalte partitioniert wurde, kann diese virtuelle Spalte nicht mehr geändert werden! Wenn also in diesem Beispiel neue Abteilungen eingeführt werden, wäre es nicht mehr möglich, den CASE-Ausdruck zu erweitern, um die neue Abteilung auf eine Partition abzubilden. Man sollte also sicher sein, dass man mit der virtuellen Spalte die ganze Fachlichkeit korrekt, umfassend und nachhaltig abbildet. Wenn Ihr euch da nicht sicher seid, ist eine "normale" Spalte mit einem Trigger (wie früher) vielleicht die sicherere Alternative ...
create table doktest_listpart_tr (
  id          number(10),
  text        varchar2(200),
  abteilung   varchar2(3),
  part#       number(4)
)
partition by range(part#) (
  partition p_abc    values less than (2),
  partition p_a1a2a3 values less than (3),
  partition p_def    values less than (4)
)
/

create or replace trigger tr_setpart#
before insert or update on doktest_listpart_tr
for each row
begin
  :new.part# := ( 
    case 
      when :new.abteilung in ('A', 'B', 'C') then 1
      when :new.abteilung in ('A1', 'A2', 'A3') then 2
      when :new.abteilung in ('D', 'E', 'F') then 3
    end
  );
end;
/
... denn den Trigger kann man auch nachträglich noch beliebig ändern.
Nun kann auf jede der Tabelle mit obigem CREATE INDEX-Kommando und dem Schlüsselwort LOCAL ein lokal partitinierter Textindex erzeugt werden. For Composite Partitioning kann das Verfahren ähnlich funktionieren; allerdings dürfte dieses Partitionsverfahren für Oracle TEXT Anwendungen meist nicht das richtige sein. In Oracle10g gab es mit 9999 noch ein eigenes Limit für die Anzahl der Partitionen. Ab Oracle11g unterstützt Oracle TEXT ebensoviele Partitionen wie die Datenbank selbst, nämlich 1048575 Die Tabellenstruktur sieht nach Erstellung des Index wie folgt aus.
TNAME                          TABTYPE  CLUSTERI
------------------------------ ------- ---------
DR#FT_PART10K0001$I            TABLE
DR#FT_PART10K0001$K            TABLE
DR#FT_PART10K0001$N            TABLE
DR#FT_PART10K0001$R            TABLE
DR#FT_PART10K0002$I            TABLE
DR#FT_PART10K0002$K            TABLE
DR#FT_PART10K0002$N            TABLE
DR#FT_PART10K0002$R            TABLE
:
  • DR#
  • Name des Volltextindex
  • Laufende Nummer der Partition. Ab Partition 10000 arbeitet Oracle TEXT mit Buchstaben: 10000 ist also AAAA, 10001 ist AAAB und so fort.
  • Kürzel für den Tabellentyp ($I, $N, $K, $R, $P, $S)
Ist der Partitionsschlüssel nun Teil der Abfrage, so findet (wie immer bei Partitioning) ein Partition Pruning statt; der Optimizer beschränkt die Abfrage also auf die relevante Partition. Im Ausführungsplan sieht das dann wie folgt aus.
---------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                          | Name                | Rows  | Bytes | Cost (%CPU)| Time     | Pstart | Pstop |
---------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                   |                     |     1 |    14 |     4   (0)| 00:00:01 |        |       |
|   1 |  PARTITION RANGE SINGLE            |                     |     1 |    14 |     4   (0)| 00:00:01 |      2 |     2 |
|*  2 |   TABLE ACCESS BY LOCAL INDEX ROWID| DOKTEST_LISTPART    |     1 |    14 |     4   (0)| 00:00:01 |      2 |     2 |
|*  3 |    DOMAIN INDEX                    | FT_DOKTEST_LISTPART |       |       |     4   (0)| 00:00:01 |        |       |
---------------------------------------------------------------------------------------------------------------------------
Man erkennt, dass die Abfrage auf die Partition 2 beschränkt wurde. Wurde allerdings, wie oben beschrieben, eine Hash- oder List-Partitionierung "emuliert", so muss man aufpassen. Ein Beispiel anhand der oben vorgestellten Emulation eines List-Partitioning:
create table doktest_listpart (
  id          number(10),
  text        varchar2(200),
  abteilung   varchar2(3),
  part# as (case 
    when abteilung in ('A', 'B', 'C') then 1
    when abteilung in ('A1', 'A2', 'A3') then 2
    when abteilung in ('D', 'E', 'F') then 3
    end
  )
)
partition by range(part#) (
  partition p_abc    values less than (2),
  partition p_a1a2a3 values less than (3),
  partition p_def    values less than (4)
)
/
Ein paar Zeilen einfügen ...
SQL> insert into doktest_listpart values (1, 'Die Oracle-Datenbank enthält Oracle TEXT','A1', DEFAULT);

1 Zeile wurde erstellt.

SQL> insert into doktest_listpart values (2, 'Mit Oracle Spatial werden Geodaten verwaltet.','D', DEFAULT);

1 Zeile wurde erstellt.
Nun wird eine Abfrage ausgeführt - der "Partitionsschlüssel" liegt in Form der Abteilung vor ...
SQL> select * from doktest_listpart where contains(text, 'Spatial') > 0 and abteilung='D';

---------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                          | Name                | Rows  | Bytes | Cost (%CPU)| Time     | Pstart | Pstop |
---------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                   |                     |     1 |    14 |     4   (0)| 00:00:01 |        |       |
|   1 |  PARTITION RANGE ALL               |                     |     1 |    14 |     4   (0)| 00:00:01 |      1 |     3 |
|*  2 |   TABLE ACCESS BY LOCAL INDEX ROWID| DOKTEST_LISTPART    |     1 |    14 |     4   (0)| 00:00:01 |      1 |     3 |
|*  3 |    DOMAIN INDEX                    | FT_DOKTEST_LISTPART |       |       |     4   (0)| 00:00:01 |        |       |
---------------------------------------------------------------------------------------------------------------------------
Am PARTITION RANGE ALL erkennt man, dass der Optimizer kein Partition Pruning durchgeführt, sondern die Abfrage über alle Partitionen ausgeführt hat. Das ist -strenggenommen- auch logisch, denn aus Sicht von Oracle TEXT ist die Tabelle gar nicht anhand der Spalte ABTEILUNG partitioniert - sondern anhand der Spalte PART#. Das muss sich auch in der Abfrage widerspiegeln. Zunächst macht es an dieser Stelle absolut Sinn, das "Mapping" der Abteilung zu einer Partitionsnummer in einer PL/SQL-Funktion zu kapseln.
create or replace function get_partid_for_range (
  p_abteilung in varchar2
) return number deterministic is
  l_partnum number;
begin
  l_partnum := case 
    when p_abteilung in ('A', 'B', 'C') then 1
    when p_abteilung in ('A1', 'A2', 'A3') then 2
    when p_abteilung in ('D', 'E', 'F') then 3
    end;
  return l_partnum;
end;
/
Mit Hilfe dieser Funktion lässt sich die Abfrage recht einfach formulieren ...
select  * from doktest_listpart
where contains(text, 'Spatial') > 0 
and part# = get_partid_for_range('D')
/
Der Ausführungsplan sieht dann so aus ...

--------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                          | Name                | Rows  | Bytes | Cost (%CPU)| Time     | Pstart| Pstop |
--------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                   |                     |     1 |    42 |     0   (0)| 00:00:01 |       |       |
|   1 |  PARTITION RANGE SINGLE            |                     |     1 |    42 |     0   (0)| 00:00:01 |   KEY |   KEY |
|*  2 |   TABLE ACCESS BY LOCAL INDEX ROWID| DOKTEST_LISTPART    |     1 |    42 |     0   (0)| 00:00:01 |   KEY |   KEY |
|*  3 |    DOMAIN INDEX                    | FT_DOKTEST_LISTPART |       |       |     0   (0)| 00:00:01 |       |       | 
--------------------------------------------------------------------------------------------------------------------------
KEY in den Spalten PSTART und PSTOP meint lediglich, dass der Wert zur Compile-Zeit des SQL noch nicht bekannt ist und erst zur Ausführungszeit Run-Time ermittelt wird. Die Abfrage wird aber, wie man erkennen kann, auf eine Partition begrenzt. Für eine "emulierte" Hash-Partition arbeitet man genauso.
Die Tatsache, ob ein Textindex partitioniert ist oder nicht, hat großen Einfluß auf die Wartung desselben. Die Aufrufe zur Synchronisierung oder zur Optimierung erwarten einen Parameter part_name. Für nicht-partitionierte Indizes kann dieser SQL NULL sein. Bei einem lokal partitinierten Index wird dagagen der Name einer Partition erwartet. Das bedeutet, dass das komplette Synchronisieren einex lokal partitionierten Oracle TEXT Index nicht mehr mit einem einzigen Aufruf von CTX_DDL.SYNC_INDEX erledigt wird, sondern es wird etwas mehr gebraucht ...
SQL> exec ctx_ddl.sync_index('FT_DOKTEST_LISTPART');
BEGIN ctx_ddl.sync_index('FT_DOKTEST_LISTPART'); END;

*
FEHLER in Zeile 1:
ORA-20000: Oracle Text-Fehler:
DRG-13102: Name von Index-Partition muss angegeben werden
ORA-06512: in "CTXSYS.DRUE", Zeile 160
ORA-06512: in "CTXSYS.CTX_DDL", Zeile 848
ORA-06512: in Zeile 1
Der Name der Indexpartition entspricht normalerweise dem der Tabellenpartition. Im Zweifelsfalle kann man sie aber aus dem Oracle TEXT Data Dictionary auslesen:
SQL> select IXP_INDEX_PARTITION_NAME from CTX_USER_INDEX_PARTITIONS where ixp_index_name='FT_DOKTEST_LISTPART';

IXP_INDEX_PARTITION_NAME
------------------------------
P_ABC
P_A1A2A3
P_DEF
Braucht man nun doch ein Kommando, welches den ganzen Index synchronisiert, kann man sich also mit dieser Abfrage helfen ...
create or replace procedure sync_full_index(p_idx_name in varchar2) as 
begin
  for ip in (
    select IXP_INDEX_PARTITION_NAME from CTX_USER_INDEX_PARTITIONS 
    where ixp_index_name =  p_idx_name
  ) loop
    ctx_ddl.sync_index(
      idx_name      => p_idx_name,  
      part_name     => ip.IXP_INDEX_PARTITION_NAME
  );
  end loop;
end;
/
Wobei die Möglichkeit, eine Synchronisierung bzw. eine Optimierung nur partitionsweise durchführen zu können, in vielen Fällen gerade ein Vorteil sein dürfte. Denn es kann ja sein, dass unterschiedliche Partitionen unterschiedliche Anforderungen haben. Und mit der Partitionierung kann man den Aufwand auf das nötige Maß begrenzen. Nicht nur die Synchronisierung wird partitionsweise durchgeführt, auch einige andere Operationen arbeiten so ...
  • Index-Synchronisierung (CTX_DDL.SYNC_INDEX)
  • Asynchroner Index-Aufbau (CTX_DDL.POPULATE_PENDING)
  • Indexoptimierung (CTX_DDL.OPTIMIZE_INDEX)
  • Indexoptimierung (CTX_DDL.OPTIMIZE_INDEX)
  • Indexstatistiken werden ebenfalls partitionsweise ermittelt (CTX_REPORT.INDEX_STATS, CTX_REPORT.INDEX_SIZE und CTX_REPORT.TOKEN_INFO)
  • Online Index Rebuild (CTX_DDL.CREATE_SHADOW_INDEX und CTX_DDL.REBUILD_INDEX_ONLINE) arbeiten ebenfalls partitionsweise
Eine Operation wie ALTER TABLE DROP PARTITION funktioniert mit Oracle TEXT transparent; die relevante Partition des Oracle TEXT Index wird mitsamt der Tabellenpartition gelöscht. Gerade bei Oracle TEXT ergibt sich jedoch ein entscheidender Vorteil: Durch ein "klassisches" SQL DELETE würden die Einträge in die Negativliste wandern ($N-Tabelle) und es würde eine Indexoptimierung nötig. Beim partitinierten Textindex wird die Tabellenpartition einfach mitsamt der Indexpartition in einem Zug gelöscht. Es ist weder ein Index-Sync noch ein Index Optimize nötig. Ein partitionierter Oracle TEXT Index kann bei großen Datenmengen also eine Menge Vorteile bieten - und das betrifft nicht nur die Abfrageperformance, sondern auch, und besonders die Wartungs- und "Housekeeping"-Arbeiten an Tabelle und Index.
  • Partition Pruning bei Volltextabfragen - Abfragen werden auf die relevante Indexpartition begrenzt - was zu besserer Performance führt. Davon profitieren übrigens nicht die die Abfragen, sondern auch etwaige Sortierungen.
  • Mit einem ALTER TABLE DROP PARTITION können veraltete Daten recht einfach und ohne viel Aufwand gelöscht werden.
  • Neue Datenbestände können in eine eigene Tabelle geladen, indiziert und dann per ALTER TABLE EXCHANGE PARTITION in die Zieltabelle eingehängt werden. Auf gleichem Wege kann so ein Online-Rebuild einer Indexpartition erfolgen. Die Daten werden in eine eigene Tabelle kopiert; darauf wird der Index neu erstellt und ein ALTER TABLE EXCHANGE PARTITION nimmt Tabelle und Index auf einmal auf.
  • Auch Oracle-TEXT spezifische Operationen können partitionsweise durchgeführt und damit besser gesteuert werden.

Donnerstag, 19. Juli 2012

Filter Preferences - Grundsätzliches

Wir haben in unseren Textblogs schon mehrfach über einige Texteinstellungen - die sogenannten Preferences - gesprochen.  Nun ist es an der Zeit, Grundlagen zu den Filter Preferencen zu legen. Filter Preferences sind, wie der Name schon verrät, dazu da den Text zum Indizieren zu filtern. Formatierte Dokumente werden zwar in nativer Format gespeichert; der Filter sorgt allerdings dafür, dass eine vorläufige Version in reinem (plain) Text oder HTML Version des Dokuments zur Verfügung steht, um dann die Wörter zum Indizieren zu erhalten.

Filter-Preferences werden mit dem CREATE INDEX oder ALTER INDEX Statement erzeugt. Man sollte sich dabei nicht auf System Preferencen verlassen, sondern den Filter immer beim Anlegen des Index mitangeben. Folgende Filter Typen existieren:

  • CHARSET_FILTER: Character Set Konvertierung
  • AUTO_FILTER: Für formatierte Dokumente
  • NULL_FILTER: Kein Filter ist erforderlich, nützlich für Plain Text, HTML und XML
  • MAIL_FILTER: Transformiert RFC-822 und RFC-2045 Nachrichten in indizierbaren Text
  • USER_FILTER: Externer Filter für benutzerdefinertes Filtern
  • PROCEDURE_FILTER: Benutzerdefiniertes Filtern definiert über eine Prozedur

Hier im Blog gebe ich Beispiele für NULL_FILTER, AUTO_FILTER und den USER_FILTER. Weitere Beispiele dazu finden sich im Handbuch.
Zuerst wird eine Tabelle erzeugt, die Dokumente im Format Powerpoint, PDF, HTML und ASCII enthält.

drop table filter_test; 
create table filter_test(id number primary key, docs blob);

Zum Laden verwende ich den SQL *Loader. Die CTL Datei sieht folgendermassen aus:
LOAD DATA
INFILE 'filter_load.dat'
INTO TABLE null_filter
FIELDS TERMINATED BY ','
(id SEQUENCE (MAX,1) ,
ext_fname FILLER CHAR(50),
docs LOBFILE(ext_fname) TERMINATED BY EOF)

Die filter_load.dat Datei hat folgende Inhalte.
snaps.sql
powerp.pptx
plain.txt
replay.html
one_page.pdf

Im ersten Beispiel wird ein Index mit Filter Preference NULL_FILTER erzeugt. Um zu überprüfen, ob im Index Create Prozeß keine Fehler passiert sind, schalte ich Logging ein und überprüfe den Index nach dem Anlegen mit CTX_USER_INDEX_ERRORS.
drop index filter_test_idx force;
execute CTXSYS.CTX_ADM.SET_PARAMETER ('LOG_DIRECTORY','/tmp'); 
exec CTX_OUTPUT.START_QUERY_LOG('filterlog');

create index filter_test_idx on filter_test ( docs )
indextype is ctxsys.context
parameters ('FILTER ctxsys.NULL_FILTER');

execute CTX_OUTPUT.END_QUERY_LOG;
SQL> select * from ctx_user_index_errors;
no rows selected

Nun wird das Ergebnis überprüft. Um einen Einruck von dem Inhalt der Ergebnismenge zu bekommen, verwende ich CTX_DOC.SNIPPED. Die REGEXP_REPLACE Funktion macht das Ergebnis lesbarer und eliminiert die überflüssigen Leerzeilen.

SQL> execute ctx_doc.set_key_type('ROWID');
SQL> select id, regexp_replace(ctx_doc.snippet 
('FILTER_TEST_IDX', rowid, 'plain or OLTP or replay or snapshot or SPA or
 REPLAY',starttag=>'###',endtag =>'###'),'\s+',' ') snippet
 from filter_test
 where contains ( docs, 'plain or OLTP or replay or snapshot or SPA or REPLAY') > 0; 
ID
----------
SNIPPET
--------------------------------------------------------------------------------
1
hh24:mi') from dba_hist_###snapshot### order by BEGIN_INTERVAL_TIME
3
dies ist ein ###plain### text
4
performance of a workload ###replay### against the performance of...The f
irst ###replay### would try to mimic the captured

Wie zu erwarten, enthält das Ergebnis die Dokumente snaps.sql (mit 1), plain.txt (mit 2) und replay.html (mit 3).
Nun verwenden wir den AUTO_FILTER. Das Vorgehen ist ähnlich wie oben, nur die FILTER Preference lautet nun AUTO_FILTER.
drop index filter_test_idx force; 
execute CTXSYS.CTX_ADM.SET_PARAMETER ('LOG_DIRECTORY','/tmp'); 
execute CTX_OUTPUT.START_QUERY_LOG('filterlog');

create index filter_test_idx on filter_test (docs)
indextype is ctxsys.context
parameters ('FILTER ctxsys.AUTO_FILTER');

execute CTX_OUTPUT.END_QUERY_LOG;
SQL> select * from ctx_user_index_errors;
no rows selected
Nun wird wieder das Ergebnis selektiert.
SQL> execute ctx_doc.set_key_type('ROWID');

SQL>select id, regexp_replace(ctx_doc.snippet
('FILTER_TEST_IDX', rowid, 'plain or OLTP or replay or snapshot or SPA or
 REPLAY',starttag=>'###',endtag =>'###'),'\s+',' ') snippet
 from filter_test
 where contains ( docs, 'plain or OLTP or replay or snapshot or SPA or REPLAY') > 0; 
ID
----------
SNIPPET
--------------------------------------------------------------------------------
1
hh24:mi') from dba_hist_###snapshot### order by BEGIN_INTERVAL_TIME
2
Sesssions und Calls ###OLTP###-Datenbanken mit ca.12.000...Siehe Enhancem
ent Request ?7523016: ###REPLAY### REPORTS ORA-933 ON SET ROLE
3
dies ist ein ###plain### text
4
performance of a workload ###replay### against the performance of...The f
irst ###replay### would try to mimic the captured
5
2) Before using the ###SPA### or Database Replay, understand...3) Perform
STS Capture for ###SPA### & workload capture for Database

Es werden nun alle Dokumente gefiltert, indiziert und gefunden. Möchte man einen Überblick über alle unterstützten Formate erhalten, kann man dies im Handbuch nachschlagen.

Nun kommen wir zum letzten Beispiel - einem USER_FILTER. Hierzu ist ein Programm notwendig, das beim Filtern für jede Zeile ausgeführt wird. Explizit wird nun eine Preference MY_FILTER mit einem speziellen Attribut erzeugt, das den Programmnamen festlegt - hier upper.pl
execute ctx_ddl.drop_preference('my_filter');
BEGIN
ctx_ddl.create_preference ('my_filter', 'user_filter');
ctx_ddl.set_attribute ('my_filter', 'command', 'upper.pl');
END;
/
PL/SQL procedure successfully completed.
Das Programm ist ein Perl-Programm, das den Text in Großbuchstaben konvertiert. Es muß unbedingt im Verzeichnis $ORACLE_HOME/ctx/bin zu finden sein.

#!/usr/bin/perl
open(IN, $ARGV[0]);
open(OUT, ">".$ARGV[1]);
while ()
{
tr/a-z/A-Z/;
print OUT;
}
close (IN);
close (OUT);

Nun wird der Index erzeugt.

drop index filter_test_idx force;

create index filter_test_idx on filter_test (docs) 
indextype is ctxsys.context
parameters ('filter my_filter');

SQL> select * from ctx_user_index_errors;
no rows selected

Das Ergebnis sieht dann folgendermassen aus.

SQL> select id, regexp_replace(ctx_doc.snippet
('FILTER_TEST_IDX', rowid, 'plain or OLTP or replay or snapshot or SPA or
REPLAY',starttag=>'###',endtag =>'###'),'\s+',' ') snippet
from filter_test
where contains ( docs, 'plain or OLTP or replay or snapshot or SPA or REPLAY') > 0;
ID
----------
SNIPPET
--------------------------------------------------------------------------------
4
CAPTURE VS. ###REPLAY###
...PERFORMANCE OF A WORKLOAD ###REPLAY### AGAINST THE PERFORMANCE
OF...CAPTURED SYSTEM, WHILE "###REPLAY###" REFERS TO THE
REPLAYED WORKLOAD.

Mittwoch, 6. Juni 2012

Name Matching (NDATA) mit einem Thesaurus "erweitern"

Zum Thema Oracle TEXT Name Matching hatten wir ja bereits einige Blog Postings. Heute möchte ich das Thema nochmal aufgreifen und besonders die Erweiterbarkeit mit einem Thesaurus vorstellen. Zunächst zur Ausgangssituation: Wir legen eine Tabelle an und füllen diese mit ein paar Namen:
create table tab_namen (
  id           number(10),
  vorname      varchar2(100),
  nachname     varchar2(100)
)
/

insert into tab_namen values (1, 'Carsten','Czarski');
insert into tab_namen values (2, 'Ulrike', 'Schwinn');
insert into tab_namen values (3, 'Max',    'Meier');
insert into tab_namen values (4, 'Moritz', 'Meyer');
insert into tab_namen values (5, 'Franz',  'Mayer');
insert into tab_namen values (6, 'Fritz',  'Maier');
Damit man einen Oracle TEXT Index anlegen kann, der auch alle Namen enthält, muss man nun mit einem Multicolumn-Datastore oder einem User-Datastore arbeiten. Da ich die Namen aber gerne per NDATA (Name Matching) und per "klassischer" Fuzzy-Suche (FUZZY-Operator) finden möchte, brauche ich einen User-Datastore (Blog-Posting). Also zuerst die PL/SQL-Prozedur anlegen ...
 create or replace procedure namen_uds_proc(
  rid  in            rowid,
  tlob in out nocopy varchar2
) is
  l_vorname  tab_namen.vorname%type;
  l_nachname tab_namen.nachname%type;
begin
  select vorname, nachname into l_vorname, l_nachname
  from tab_namen where rowid = rid;
  tlob := 
    '<VORNAME>'   || l_vorname                       || '</VORNAME>'  ||  
    '<NACHNAME>'  || l_nachname                      || '</NACHNAME>' ||  
    '<NAME>'      || l_vorname || ' ' || l_nachname  || '</NAME>';
end;
/
sho err
Die Prozedur als User Datastore registrieren ...

begin
  ctx_ddl.create_preference('names_ds', 'user_datastore');
  ctx_ddl.set_attribute('names_ds', 'procedure', 'namen_uds_proc');
end;
/
sho err
Mit dem Section Group-Objekt wird festgelegt, wie die einzelnen Sections (XML-Tags) von Oracle TEXT behandelt werden sollen ... VORNAME und NACHNAME werden normal behandelt; der zusammengesetzte "NAME" wird als NDATA-Section deklariert.
begin
  ctx_ddl.create_section_group('names_sg', 'xml_section_group');
  ctx_ddl.add_field_section('names_sg', 'VORNAME', 'VORNAME', false);
  ctx_ddl.add_field_section('names_sg', 'NACHNAME', 'NACHNAME', false);
  ctx_ddl.add_ndata_section('names_sg', 'NAME', 'NAME');
end;
/
Schließlich den Index anlegen ...
create index ft_names on tab_namen (nachname)
indextype is ctxsys.context
parameters('
  datastore     names_ds
  section group names_sg
  memory        100m
')
/
Und dann kann man abfragen - zuerst mit "Meier" ...
select * from tab_namen where contains(nachname, 'ndata(name, Meier)')>0
/

        ID VORNAME         NACHNAME
---------- --------------- ---------------
         3 Max             Meier
         4 Moritz          Meyer
         6 Fritz           Maier
Ups ... da fehlt doch einer ... der "Mayer" ist für den NDATA-Algorithmus wohl "zu weit" weg ... Probieren wir noch ein wenig mit dem "Czarski".
select * from tab_namen where contains(nachname, 'ndata(name, Czarsky)')>0
/

        ID VORNAME         NACHNAME
---------- --------------- -------------
         1 Carsten         Czarski

select * from tab_namen where contains(nachname, 'ndata(name, Tsarski)')>0
/

        ID VORNAME         NACHNAME
---------- --------------- -------------
         1 Carsten         Czarski

select * from tab_namen where contains(nachname, 'ndata(name, Tsarsky)')>0
/

Es wurden keine Zeilen ausgewählt
Funktioniert ganz gut, lässt aber doch noch Wünsche offen. Eine Ähnlichkeitssuche wird aber immer Wünsche offenlassen, gerade wenn sie nicht für eine bestimmte Sprache optimiert ist und global funktionieren soll. Die gute Nachricht ist aber, dass NDATA durch einen Thesaurus erweitert werden kann. In diesem Thesaurus können unterschiedliche Schreibweisen von Namen als Synonymbeziehungen hinterlegt und die Treffermenge von NDATA so erweitert werden. Und das Gute ist, dass NDATA dann auch zu den Synonymen ähnliche (!) Namen finden wird. Die Treffermenge wird also nicht nur um die Synonyme selbst, sondern auch um den Synonymen (nach NDATA) ähnliche Namen erweitert.
Wie man einen Thesaurus anlegt und pflegt, ist in diesem Blog-Posting beschrieben. Also erzeugen wir einen Thesaurus wie folgt ...
begin
  ctx_thes.create_thesaurus('namesthes');
  ctx_thes.create_relation('namesthes', 'meyer', 'SYN', 'maier');
  ctx_thes.create_relation('namesthes', 'meyer', 'SYN', 'meier');
  ctx_thes.create_relation('namesthes', 'meyer', 'SYN', 'mayer');
  ctx_thes.create_relation('namesthes', 'czarski', 'SYN', 'czarsky');
end;
/
Als nächstes müssen wir Oracle TEXT sagen, dass es den Thesaurus für NDATA nutzen soll - das geschieht mit einer Wordlist Preference.
begin
  ctx_ddl.create_preference('names_wl', 'BASIC_WORDLIST');
  ctx_ddl.set_attribute('names_wl', 'NDATA_ALTERNATE_SPELLING', 'FALSE');
  ctx_ddl.set_attribute('names_wl', 'NDATA_BASE_LETTER',        'TRUE');
  ctx_ddl.set_attribute('names_wl', 'NDATA_THESAURUS',          'namesthes');
end;
/
Und dann muss der Index gelöscht und neu angelegt werden ...
drop index ft_names
/

create index ft_names on tab_namen (nachname)
indextype is ctxsys.context
parameters('
  datastore     names_ds
  section group names_sg
  wordlist      names_wl
  memory        100m
')
/
Und dann probieren wir die Abfragen erneut ...
select * from tab_namen where contains(nachname, 'ndata(name, Mayer)')>0
/

        ID VORNAME         NACHNAME
---------- --------------- ---------------
         3 Max             Meier
         4 Moritz          Meyer
         5 Franz           Mayer
         6 Fritz           Maier

select * from tab_namen where contains(nachname, 'ndata(name, Tsarsky)')>0
/

        ID VORNAME         NACHNAME
---------- --------------- ---------------
         1 Carsten         Czarski
Voilá - man sieht, dass man die NDATA-Funktionalität sehr gut erweitern kann. Wenn man das von vorneherein in seinen Index einbaut, so kann man den Index aufgrund von Nutzerfeedback "lernen" lassen. Mit einem Namens-Thesaurus lässt sich die NDATA-Funktinalität auf jeden Fall sehr gut abrunden.

Freitag, 25. Mai 2012

Oracle TEXT Workshop im Mai in Düsseldorf und Stuttgart

Im Mai 2012 führten wir in Düsseldorf und Stuttgart einen Workshop zum Thema Oracle TEXT durch. Dort wurde Oracle TEXT und die Möglichkeiten im Detail vorgestellt.

Die Agenda
10:00 Beginn der Veranstaltung
Oracle TEXT: Ein Überblick
Konzepte und generelle Funktionsweise von Oracle TEXT, Einsatzszenarien
Umgang mit Oracle TEXT: Tipps & Tricks aus der Praxis
Einblick in die Wartung eines Textindex, wie formuliert man Oracle TEXT Abfragen richtig?
Welche grundsätzlichen Möglichkeiten habe ich?
Mittagspause
Überwachung und Wartung eines Oracle Text Index im Detail
Synchronisierungs- und Optimierungsintervalle, Strategien, Feststellung der Fragmentierung,
Hochverfügbarkeit mit Schattenindex-Technologie und mehr ...
"Textindex nach Art des Hauses": Konfiguration eines TEXT-Index
Lexer-Einstellungen, Stopwortlisten, Umgang mit Umlauten, Thesaurus und mehr ...
Oracle TEXT für alle Daten: User Datastore
Wie erzeugt man einen Oracle TEXT-Index auf mehrere Tabellen ...?
17:00: Ende der Veranstaltung

Die Folien des Workshops stehen ab sofort hier zum Download bereit.

http://apex.oracle.com/folien

Schlüsselwort: oracle-text-workshop

Dienstag, 13. März 2012

Nochmal USER_DATASTORE: Ein umfassendes Beispiel - Teil I

Da wir immer wieder gefragt werden, wie man einen User Datastore mit Oracle TEXT erstellt und auch sehr oft bemerken, dass ein User Datastore DIE Lösung für alle möglichen Suchszenarien ist, möchten wir dieses Blog-Posting nochmals dem User Datastore widmen. Das Beispiel heute ist etwas anspruchsvoller: Wir möchten eine Mitarbeitersuche im Oracle-Beispielschema HR umsetzen. Die zu indizierenden, suchrelevanten Daten sind über mehrere Tabellen verteilt (genau 7) - dennoch soll genau ein Index entstehen, über den ein Mitarbeiter anhand aller möglicher Suchkriterien gefunden werden kann.
  • Anhand des Namens, auch Ähnlichkeitssuche soll möglich sein
  • Anhand der Abteilung
  • Anhand des Ortes, des Landes, der Region
  • Anhand des Jobs
  • Anhand des Managers
Man kann sich schon vorstellen, dass die Lösung dieser Aufgabe mit "klassischem" SQL nicht besonders einfach ist. Aber Oracle TEXT passt hier genau. Zur Verdeutlichung nochmals eine Übersicht über die vorhandenen, zu indizierenden Tabellen.
Damit man die Physik der eigentlichen Datentabellen beliebig ändern kann, werden wir den Volltextindex komplett davon lösen. Es wird also eine eigene "Suchtabelle" angelegt, auf die später der Index erzeugt wird. Damit wir beim Anzeigen der Trefferliste nicht zu den eigentlichen Datentabellen joinen müssen, nehmen wir alle Informationen, die in der Trefferliste angezeigt werden sollen, hier mit auf. Wobei das "nur" die Daten sind, die potenziell in der Trefferliste dargestellt werden - in den Index werden noch mehr Daten aufgenommen.
create table employees_search(
  employee_id      number(6) primary key,
  FIRST_NAME       varchar2(20),
  LAST_NAME        varchar2(25),
  EMAIL            varchar2(25),
  PHONE_NUMBER     varchar2(20),
  JOB_TITLE        varchar2(35),
  DEPARTMENT_NAME  varchar2(30),
  POSTAL_CODE      varchar2(12),
  CITY             varchar2(30),
  COUNTRY_NAME     varchar2(40)
)
/

insert into employees_search(
  select 
    e.EMPLOYEE_ID,
    e.FIRST_NAME,
    e.LAST_NAME,
    e.EMAIL,
    e.PHONE_NUMBER,
    j.JOB_TITLE,
    d.department_name,
    l.postal_code,
    l.city,
    c.country_name
  from 
    employees e 
      join jobs j on (j.job_id = e.job_id)
      join departments d on (d.department_id = e.department_id)
      join locations l on (d.location_id = l.location_id)
      join countries c on (c.country_id = l.country_id)
)
/
Als nächstes wird die Prozedur erstellt, welche die zu indizierenden Daten aufbereitet. Oracle TEXT wird diese Prozedur später für jede Zeile der zu indizierenden Tabelle (EMPLOYEES_SEARCH) aufrufen und das, was die Prozedur zurückgibt, indizieren. Der PL/SQL-Code sollte dann die zu indizierenden Daten aus allen Tabellen "zusammensammeln" und dabei möglichst effizient arbeiten. Wir bedienen uns eines expliziten Cursors in einem Helper-Package. Da wir den Index auf die Tabelle EMPLOYEES_SEARCH erzeugen wollen, müssen wir mit dem Cursor, anhand einer ROWID aus dieser Tabelle, alle Daten zusammenstellen.
create or replace package emp_suche_uds_helper is
 cursor emp_suche_cur (emp_rid rowid) is
  select 
    e.EMPLOYEE_ID,
    e.FIRST_NAME || ' ' || e.LAST_NAME as full_name,
    e.EMAIL,
    e.PHONE_NUMBER,
    to_char(e.hire_date, 'YYYY-MM-DD') hire_date,
    m.first_name || ' ' ||m.last_name as mgr_full_name,
    j.JOB_TITLE,
    d.department_name,
    l.street_address,
    l.postal_code,
    l.city,
    l.state_province,
    c.country_name,
    r.region_name
  from 
    employees_search es 
      join employees e on (e.employee_id = es.employee_id) 
      join jobs j on (j.job_id = e.job_id)
      join departments d on (d.department_id = e.department_id)
      join locations l on (d.location_id = l.location_id)
      join countries c on (c.country_id = l.country_id)
      join regions r on (r.region_id = c.region_id)
      left outer join employees m on (m.employee_id = e.manager_id)
  where es.rowid = emp_rid;
end emp_suche_uds_helper;
/ 
sho err
Danach kommt die eigentliche Prozedur für den User Datastore. Beachtet bitte immer deren Signatur - die ist von Oracle TEXT vorgegeben:
  • Als erstes wird eine ROWID als IN-Parameter erwartet.
  • Der zweite Parameter muss IN OUT und vom Datentyp VARCHAR2, CLOB oder BLOB sein.
Der Code sieht dann wie folgt aus.
create or replace procedure emp_suche_uds_proc(
  rid         in rowid,
  tlob        in out nocopy varchar2
) is
  l_row emp_suche_uds_helper.emp_suche_cur%ROWTYPE;
begin
  if emp_suche_uds_helper.emp_suche_cur%ISOPEN then 
    close emp_suche_uds_helper.emp_suche_cur;
  end if;
 
  open emp_suche_uds_helper.emp_suche_cur(rid);
  fetch emp_suche_uds_helper.emp_suche_cur into l_row;
  tlob := 
    '<EMPLOYEE_ID>'      || l_row.employee_id     || '</EMPLOYEE_ID>'      ||
    '<FULL_NAME>'        || l_row.full_name       || '</FULL_NAME>'        ||
    '<ND_FULL_NAME>'     || l_row.full_name       || '</ND_FULL_NAME>'     ||
    '<EMAIL>'            || l_row.email           || '</EMAIL>'            ||
    '<PHONE_NUMBER>'     || l_row.phone_number    || '</PHONE_NUMBER>'     ||
    '<HIRE_DATE>'        || l_row.hire_date       || '</HIRE_DATE>'        ||
    '<MGR_FULL_NAME>'    || l_row.mgr_full_name   || '</MGR_FULL_NAME>'    ||
    '<ND_MGR_FULL_NAME>' || l_row.mgr_full_name   || '</ND_MGR_FULL_NAME>' ||
    '<JOB_TITLE>'        || l_row.job_title       || '</JOB_TITLE>'        ||
    '<DEPARTMENT_NAME>'  || l_row.department_name || '</DEPARTMENT_NAME>'  ||
    '<STREET_ADDRESS>'   || l_row.street_address  || '</STREET_ADDRESS>'   ||
    '<POSTAL_CODE>'      || l_row.postal_code     || '</POSTAL_CODE>'      ||
    '<CITY>'             || l_row.city            || '</CITY>'             ||
    '<STATE_PROVINCE>'   || l_row.state_province  || '</STATE_PROVINCE>'   ||
    '<COUNTRY_NAME>'     || l_row.country_name    || '</COUNTRY_NAME>'     ||
    '<REGION_NAME>'      || l_row.region_name     || '</REGION_NAME>';
  close emp_suche_uds_helper.emp_suche_cur;
end emp_suche_uds_proc;
/
sho err
Als nächstes sollte man die Prozedur mal testen - eine ROWID der Tabelle EMPLOYEES_SEARCH reingeben und es sollte ein XML-Dokument zurückkommen. Mit SQL*Plus sieht der Test dann so aus ...
SQL> var XML varchar2(4000);
SQL> exec emp_suche_uds_proc('AAArJdAAEAAAJx7AAA', :XML);
SQL> print

XML
--------------------------------------------------------------------------------
<EMPLOYEE_ID>198</EMPLOYEE_ID><FULL_NAME>Donald OConnell</FULL_NAME><ND_FULL_NAM
E>Donald OConnell</ND_FULL_NAME><EMAIL>DOCONNEL</EMAIL><PHONE_NUMBER>650.507.983
3</PHONE_NUMBER><HIRE_DATE>2007-06-21</HIRE_DATE><MGR_FULL_NAME>Kevin Mourgos</M
GR_FULL_NAME><ND_MGR_FULL_NAME>Kevin Mourgos</ND_MGR_FULL_NAME><JOB_TITLE>Shippi
ng Clerk</JOB_TITLE><DEPARTMENT_NAME>Shipping</DEPARTMENT_NAME><STREET_ADDRESS>2
011 Interiors Blvd</STREET_ADDRESS><POSTAL_CODE>99236</POSTAL_CODE><CITY>South S
an Francisco</CITY><STATE_PROVINCE>California</STATE_PROVINCE><COUNTRY_NAME>Unit
ed States of America</COUNTRY_NAME><REGION_NAME>Americas</REGION_NAME>

Puristen mögen einwenden, dass dies gar kein richtiges XML-Dokument ist - denn es fehlt das Root-Tag. Aber das ist Oracle TEXT egal: Ein Root-Tag braucht es nicht unbedingt, also verzichten wir darauf. Wenn die PL/SQL-Prozedur soweit funktioniert, können wir damit beginnen, sie im Oracle TEXT Dictionary zu registrieren. Dazu werden Preference-Objekte erzeugt. Wir beginnen mit der Datastore-Preference:
begin
  ctx_ddl.drop_preference(
    preference_name => 'employee_ds'
  );
end;
/
sho err

begin
  ctx_ddl.create_preference(
    preference_name => 'employee_ds',
    object_name     => 'user_datastore'
  );
  ctx_ddl.set_attribute(
    preference_name => 'employee_ds',
    attribute_name  => 'procedure',
    attribute_value => 'emp_suche_uds_proc'
  );
end;
/
sho err
Jetzt könnte man den Index schon erzeugen und über alle Attribute suchen. Allerdings wollen wir noch zwei Dinge zusätzlich:
  • Wir wollen auch gezielt nach bestimmten Attributen suchen, also nach Manager, Abteilung oder Adresse
  • Für den Employee- und den Managernamen gezielt das Feature "Name Search" einsetzen. Aus diesem Grund sind beide Namen im generieren XML auch zweimal enthalten (FULL_NAME und ND_FULL_NAME, MGR_FULL_NAME und ND_MGR_FULL_NAME).
  • Wir wollen für das HIREDATE die in Version 11 neu eingeführten SDATA-Sections nutzen. Damit wird es möglich, eine Datumssuche (<, >) über den Volltextindex zu machen.
begin
  ctx_ddl.drop_section_group(
    group_name    => 'employee_sg'
  );
end;
/

begin
  ctx_ddl.create_section_group(
     group_name      => 'employee_sg',
     group_type      => 'XML_SECTION_GROUP'
  );
  /*
   * Einfache "Field Sections" für die Suchelemente. Der letzte Parameter legt fest,
   * ob der Section-Name bei Suchen angegeben werden muss ("false") oder ob es auch
   * ohne geht ("true"). 
   */
  ctx_ddl.add_field_section('employee_sg', 'EMPLOYEE_ID',      'EMPLOYEE_ID',      false);
  ctx_ddl.add_field_section('employee_sg', 'FULL_NAME',        'FULL_NAME',        false);
  ctx_ddl.add_field_section('employee_sg', 'EMAIL',            'EMAIL',            false);
  ctx_ddl.add_field_section('employee_sg', 'PHONE_NUMBER',     'PHONE_NUMBER',     false);
  ctx_ddl.add_field_section('employee_sg', 'MGR_FULL_NAME',    'MGR_FULL_NAME',    false);
  ctx_ddl.add_field_section('employee_sg', 'JOB_TITLE',        'JOB_TITLE',        false);
  ctx_ddl.add_field_section('employee_sg', 'DEPARTMENT_NAME',  'DEPARTMENT_NAME',  false);
  ctx_ddl.add_field_section('employee_sg', 'STREET_ADDRESS',   'STREET_ADDRESS',   false);
  ctx_ddl.add_field_section('employee_sg', 'POSTAL_CODE',      'POSTAL_CODE',      false);
  ctx_ddl.add_field_section('employee_sg', 'CITY',             'CITY',             false);
  ctx_ddl.add_field_section('employee_sg', 'STATE_PROVINCE',   'STATE_PROVINCE',   false);
  ctx_ddl.add_field_section('employee_sg', 'COUNTRY_NAME',     'COUNTRY_NAME',     false);
  ctx_ddl.add_field_section('employee_sg', 'REGION_NAME',      'REGION_NAME',      false);
  /*
   * Auf das Hiredate soll die Suche auch mit ">" und "<" möglich sein, daher SDATA-Section 
   */
  ctx_ddl.add_sdata_section('employee_sg', 'HIRE_DATE',        'HIRE_DATE',        'DATE');
  /*
   * Zusätzliche NDATA-Sections für Namenssuche 
   */
  ctx_ddl.add_ndata_section('employee_sg', 'ND_MGR_FULL_NAME', 'ND_MGR_FULL_NAME');
  ctx_ddl.add_ndata_section('employee_sg', 'ND_FULL_NAME',     'ND_FULL_NAME'    );
end;
/
sho err
Es werden drei unterschiedliche Section-Typen innerhalb der Section Group employee_sg erzeugt. Eine Field Section ist der einfachste Typ: In einer solchen Section kann einfacher Text stehen, der normal indiziert wird. Untertags sind jedoch nicht erlaubt; geschachtelte Strukturen müssen als Zone-Sections deklariert werden - die brauchen aber etwas mehr Platz im Index. Wichtig beim Aufruf von ADD_FIELD_SECTION ist der letzte Parameter VISIBLE. Wird er auf "false" gesetzt, so muss die Section bei der Suche stets angebenen werden - es muss also immer "MILLER within (FULL_NAME)" gesucht werden. Steht er auf "true", kann man auch einfach nur nach "MILLER" suchen - letzteres macht den Index aber auch größer - man muss einfach anhand der Anforderungen entscheiden. Mehr zu Zone- und Field-Sections findet Ihr hier.
Für die Namenssuche haben wir, wie schon gesagt, zusätzliche XML-Tags erzeugt - für diese Tags werden eigene NDATA-Sections erzeugt. Diese machen die in 11.2 neu eingeführte Name Search möglich. Man kann also für eine Namenssuche entweder mit der "normalen" Fuzzy-Suche arbeiten, oder, wenn diese "nicht genug" findet, die spezielle Namenssuche anwerfen.
Als nächstes legen wir eine Wordlist-Preference an. Damit werden wir einige Einstellungen für Name Search vornehmen und allgemein Dinge wie die Reduktion von diakritischen Zeichen auf ihre Grundformen aktivieren. Ein Herr "Müller" wird also als "MULLER" in den Index geschrieben - man kann ihn dann entweder als Müller oder als Muller finden - letzteres ist wichtig, wenn international gearbeitet werden soll - schließlich ist nicht jede Tastatur mit deutschen Umlauten gesegnet. Im folgenden werden die dazu nötigen Wordlist und Lexer Preferences eingestellt.
begin
 ctx_ddl.drop_preference('employee_wl');
end;
/
sho err

begin
  ctx_ddl.create_preference('employee_wl', 'BASIC_WORDLIST');
  ctx_ddl.set_attribute('employee_wl', 'NDATA_ALTERNATE_SPELLING', 'TRUE');
  ctx_ddl.set_attribute('employee_wl', 'NDATA_BASE_LETTER',        'TRUE');
end;
/
sho err


begin
 ctx_ddl.drop_preference('employee_lx');
end;
/
sho err

begin
  ctx_ddl.create_preference('employee_lx', 'BASIC_LEXER');
  ctx_ddl.set_attribute('employee_lx', 'MIXED_CASE',       'NO');
  ctx_ddl.set_attribute('employee_lx', 'BASE_LETTER',      'YES');
  ctx_ddl.set_attribute('employee_lx', 'BASE_LETTER_TYPE', 'GENERIC');
end;
/
sho err
Mehr Informationen zu den verfügbaren Einstellungen findet sich in der Dokumentation:
Nun ist es geschafft. Wir können den Index (endlich) anlegen.
create index ft_employee_suche on employees_search(last_name)
indextype is ctxsys.context
parameters('
  datastore      employee_ds
  section group  employee_sg
  wordlist       employee_wl
  lexer          employee_lx
  stoplist       ctxsys.empty_stoplist
  memory 500M
')
/
Wenn der Index fertig ist, kann man suchen ... die SQL-Abfrage sieht immer etwa so aus ..
select employee_id, first_name, last_name from employees_search 
where contains(last_name, '{contains-query}') > 0
  • Suche nach einem "Accountant" namens "Higins" oder so ähnlich:
    ?Higins within (FULL_NAME) and ?Accountant within (JOB_TITLE)
  • Suche nach dem Team von einem Manager, dessen Name irgendwie auf "assuriz" endet (klassisch mit "Fuzzy" findet nichts):
    ?assuriz within (MGR_FULL_NAME)
  • Suche nach dem Team von einem Manager, dessen Name irgendwie auf "assuriz" endet (Name Search ist erfolgreich):
    NDATA(ND_MGR_FULL_NAME, assuriz)
  • Mit Name Search kann man Vor- und Nachnamen auch verdrehen:
    NDATA(FULL_NAME, Baer Hermann)
  • Alle Angestellten in Oxford, die nach dem 01.01.2008 eingestellt wurden:
    Oxford within (CITY) and SDATA(HIRE_DATE >= '2008-01-01')
Es sind beliebige Abfragen denkbar. Innerhalb von CONTAINS kann man ja mit AND, OR, NOT arbeiten und sich damit beliebig komplexe Abfrageausdrücke überlegen. Und das alles wird aus ein- und demselben Index bedient und zusätzlich hat man noch linguistische Features wie die Base-Letter Konvertierung (Ä -> A), Ähnlichkeits- und Namenssuche. Für Suchapplikationen (bspw. im Callcenter) kann Oracle TEXT so eine sehr mächtige Angelegenheit sein und durchaus auch mit spezieller Software mithalten. Und bei allem immer im Auge behalten: Oracle TEXT ist in der Datenbank "drin" und kostet nix extra!.
Es bleibt nun die Frage, was bei Datenänderungen (DML) an den zugrundeliegenden Tabellen passiert. Soviel vorab: Der bis hierher angelegte Index bekommt von etwaigen Änderungen überhaupt nichts mit. Um ihn zu aktualisieren, müsste man ihn neu bauen - und für manche Anwendungen würde das vielleicht auch in einem nächtlichen Wartungsfenster reichen. Andere Anwendungen brauchen eher einen ständig aktuellen Index - und wie man das macht, erfahrt Ihr im nächsten Blog-Posting.

Montag, 20. Februar 2012

Statistiken im Oracle Text Umfeld

Spätestens seit Oracle 10g besitzt das Sammeln von Statistiken bzw. die Relevanz von korrekten Statistiken einen hohen Stellenwert und stellt einen Garant für gute Performance dar. Um das Management für das Sammeln von Statistiken zu vereinfachen, führte Oracle daher mit 10g die sogenannten Maintenance Jobs ein, die meist in einem nächtlichen Job diese Aufgabe übernehmen.

Was bedeutet dies nun für den Oracle Text Index?
Zieht man das Handbuch Oracle Text Application Developer's Guide 11g Release 2 (11.2) zu rate, findet man im Abschnitt "Tuning Oracle Text" auf die Frage, ob man Statistiken sammeln sollte oder nicht, folgende nicht eindeutige Antwort dazu:
"Yes. Collecting statistics on your tables enables Oracle Text to do cost-based analysis. This helps Oracle Text choose the most efficient execution plan for your queries. If your queries are always pure text queries (no structured predicate and no joins), you should delete statistics on your Oracle Text index."

Noch einmal zur Erinnerung, wenn wir einen Text Index (z. B. TEXT_IDX) erzeugen, werden automatisch mindestens folgende Objekte erzeugt:
  • DR$TEXT_IDX$I: Token Tabelle
  • DR$TEXT_IDX$X: Index
  • DR$TEXT_IDX$K: DOCID (nach ROWID) Mapping Tabelle
  • DR$TEXT_IDX$N: Tabelle für Negativliste für alle gelöschten DOCIDs
  • DR$TEXT_IDX$R: ROWID (nach DOCID) Mapping Tabelle
Was bedeutet dies für die einzelnen Objekte des Textindex?
  1. Sollten Statistiken auf den einzelnen DR$ Objekten existieren?
    Die Antwort darauf lautet nein! Alle internen rekursiven Abfragen haben Hints (wie zum Beispiel /*+DYNAMIC_SAMPLING(0) INDEX(T "DR$TEST_IDX$X")*/) um einen festgelegten Ausführungsplan zu verwenden. Dieser Plan hat sich als bester Plan erwiesen. Statistiken auf den Objekten könnten zu einem veränderten und somit schlechteren Plan führen.
  2. Werden Statistiken im Maintenance Job für Oracle Text mitgepflegt?
    Auch hier lautet die Antwort nein. DBMS_STATS.GATHER_SCHEMA_STATS oder DBMS_STATS.GATHER_DATABASE_STATS analysiert keine internen Texttabellen und Indizes.

Um zu verifizieren, ob Ihre Umgebung die Regel 1) erfüllt, führen Sie einfach folgenden Test durch:
SQL> select table_name,last_analyzed 
     from user_tables where table_name like '%TEST_IDX%';

TABLE_NAME                     LAST_ANAL
------------------------------ ---------
DR$TEST_IDX$I
DR$TEST_IDX$R
DR$TEST_IDX$K
DR$TEST_IDX$N
Für die Indizes ergibt sich folgende Abfrage:
SQL> select index_name, user_stats, global_stats, last_analyzed
     from user_indexes where table_name like '%TEST_IDX%';

INDEX_NAME                     USE GLO LAST_ANAL
------------------------------ --- --- ---------
SYS_IOT_TOP_223146             NO  NO
DR$TEST_IDX$X                  NO  NO  
SYS_IL0000223143C00002$$       NO  NO
SYS_IOT_TOP_223141             NO  NO
SYS_IL0000223138C00006$$       NO  NO
Haben die Einträge in der Spalte LAST_ANALYZED den Wert "null", müssen Sie nichts unternehmen. Finden Sie Statistiken auf einem dieser Objekte, löschen Sie diese wie folgendes Beispiel zeigt.
SQL> select index_name, user_stats, global_stats, last_analyzed
     from user_indexes where table_name like '%TEST_IDX%';

INDEX_NAME                     USE GLO LAST_ANAL
------------------------------ --- --- ---------
SYS_IOT_TOP_223146             NO  NO
DR$TEST_IDX$X                  NO  NO  20-FEB-12
SYS_IL0000223143C00002$$       NO  NO
SYS_IOT_TOP_223141             NO  NO
SYS_IL0000223138C00006$$       NO  NO

SQL> execute dbms_stats.delete_index_stats('US','DR$TEST_IDX$X');

--- zur Kontrolle

SQL> select index_name, user_stats, global_stats, last_analyzed
     from user_indexes where table_name like '%TEST_IDX%';

INDEX_NAME                     USE GLO LAST_ANAL
------------------------------ --- --- ---------
SYS_IOT_TOP_223146             NO  NO
DR$TEST_IDX$X                  NO  NO
SYS_IL0000223143C00002$$       NO  NO
SYS_IOT_TOP_223141             NO  NO
SYS_IL0000223138C00006$$       NO  NO
Weitere Informationen zu dem Thema finden Sie auch in der öffentlichen My Oracle Support Note DOC ID 139979.1.

Dienstag, 24. Januar 2012

Neue Oracle Text Features

Häufig werden wir nach den neuen Features im Bereich Oracle Text gefragt. Besonders wichtig wird dies, wenn eine Migration ansteht. Wichtig zu wissen ist, dass in der Regel keine Veränderungen an den Applikationen vorgenommen werden müssen, sondern die Anwendungen ohne weitere Bearbeitung übernommen werden können. Einzige Ausnahme bildet vielleicht der Wechsel in der Filternutzung. Ab 11g Release 1 ist der INSO_FILTER Typ durch den AUTO_FILTER Typ ersetzt worden. Dies wird für die meisten Text Nutzer keinen großen Unterschied darstellen. Wer sicher gehen möchte, welches die genauen Unterschiede zwischen den beiden Filtern sind, kann im Oracle Text Application Developer's Guide 11g Release 2 (11.2) Handbuch unter New Filter (INSO_FILTER versus AUTO_FILTER) nachsehen. Ausserdem kann aus Kompatibilitätsgrunden in 11g immer noch den INSO_FILTER Typ verwendet werden.
Bei der Migration möchte man allerdings nicht nur die Funktionalität und Performance der Anwendungen aufrecht erhalten, sondern auch von Vereinfachungen bzw. Erweiterungen im neuen Release profitieren. Wo findet man diese Informationen? Die beste Quelle ist das Oracle Text Reference 11g Release 2 (11.2) Handbuch. Hier findet man folgende Link-Einträge zum Thema 11gR1 und 11gR2: Im folgenden Abschnitt wollen wir die wichtigsten Features noch einmal auflisten und die entsprechenden Blog-Einträge - falls vorhanden - dazu angeben.
In 11g Release 1 sind folgende Features eingeführt worden:
  1. Zero Downtime für Textanwendungen mit neuem inkrementellen Index-Feature und Online Index Creation bzw. Re-creation
  2. NOPOPULATE Option für ALTER INDEX Kommando zur Unterstützung der inkrementellen Indizierung
  3. Neue Oracle Text Manager Features im Oracle Enterprise Manager
  4. Support für Composite Domain Indextyp um die mixed-query Performance zu erhöhen
  5. SDATA Section Typ und SDATA Operator für die Bereichssuche von Metadata
  6. User definiertes Scoring Feature mit DEFINESCORE und DEFINEMERGE Operatoren
  7. Detailliertes Database Usage Tracking Feature für Oracle Text
In 11g Release 2 finden sich folgende Erweiterungen:
  1. Name Searching: Diese neue Funktion in Oracle TEXT ist speziell für die Suche nach Namen vorgesehen. Namen werden ja, insbesondere wenn Sie aus einem anderen Sprachraum kommen, recht häufig falsch geschrieben oder falsch verstanden. Eine exakte Suche hilft oft nicht weiter. Mit der FUZZY-Suche kann man zwar schon nach ähnlich geschriebenen Begriffen suchen, für eine umfassende Suche nach Namen reicht das jedoch vielfach nicht aus. Hier könnte das Name earching Feature weiterhelfen. Die Blogeinträge zu dem Thema finden sich unter
  2. Result set interface: Die Idee ist, vielfältige komplexe Abfragen für Ergebnismengen im Textumfeld, auf einen einzigen SQL Call zu reduzieren und damit Ressourcen zu sparen und die Performance zu erhöhen.

Montag, 21. November 2011

Tablespace und mehr: Storage-Klausel für den Oracle-TEXT Index

Nachdem uns jüngst die Frage erreichte, wie man den Tablespace für einen Oracle TEXT-Index festlegen
kann ... machen wir dazu gleich ein Posting: Im Gegensatz zu einem normalen Textindex kann man nicht
einfach eine TABLESPACE-Klausel an das CREATE INDEX-Kommando anhängen. Das folgende funktioniert also
nicht.
create index ft_dokument on dokumente_tab(dokument)
indextype is mdsys.context
tablespace TS_IDXDATA
/
Bei Oracle TEXT ist hier etwas mehr zu tun. Oracle TEXT erlaubt unglaublich viel Flexibilität, was
auf der anderen Seite natürlich immer Konfigurationsaufwand bedeutet. Aber zum Thema: Ein Oracle TEXT Index
besteht intern stets aus mindestens vier Tabellen: $I, $R, $K und $N. Je nachdem, welche zusätzlichen
Features aktiviert wurden, kommen noch Tabellen dazu. Und jede dieser Tabellen kann ihre eigene
Storage-Klausel bekommen. Wie immer bei Oracle TEXT wird auch hier mit den Preference-Objekten
gearbeitet - diese physikalischen Details werden in eine Storage Preference gespeichert (nicht zu
verwechseln mit der Datastore Preference, die beschreibt, wo die zu indizierenden Daten herkommen). Wir
wollen also unseren TEXT Index in den Tablespace TS_IDXDATA legen. Also brauchen alle Tabellen, die
Oracle TEXT unter der Haube erzeugt, eine entsprechende Storage-Klausel.
begin
  ctx_ddl.create_preference('mystore', 'BASIC_STORAGE');

  ctx_ddl.set_attribute('mystore', 'I_TABLE_CLAUSE', 'tablespace ts_idxdata '); 
  ctx_ddl.set_attribute('mystore', 'K_TABLE_CLAUSE', 'tablespace ts_idxdata '); 
  ctx_ddl.set_attribute('mystore', 'R_TABLE_CLAUSE', 'tablespace ts_idxdata LOB(DATA) STORE AS (CACHE)');
  ctx_ddl.set_attribute('mystore', 'N_TABLE_CLAUSE', 'tablespace ts_idxdata '); 
  ctx_ddl.set_attribute('mystore', 'I_INDEX_CLAUSE', 'tablespace ts_idxdata compress 2');
  ctx_ddl.set_attribute('mystore', 'P_TABLE_CLAUSE', 'tablespace ts_idxdata '); 
  ctx_ddl.set_attribute('mystore', 'S_TABLE_CLAUSE', 'tablespace ts_idxdata ');
end;
/
Wie man am Skript sehr schön sehen kann, werden hier die einzelnen Storage Klauseln für alle
an einem Textindex beteiligten Tabellen hinterlegt. Hier kann alles eingetragen werden, was auch
in die Storage-Klausel einer "normalen" Tabelle hineinkann. Wichtig sind allerdings die Einstellungen
LOB (DATA) STORE AS (CACHE) bei der R_TABLE_CLAUSE und COMPRESS 2 bei der I_INDEX_CLAUSE. Diese
Defaulteinstellungen sind sehr wichtig und es ist für die Performance ganz entscheidend, diese
bei eigenen Storage-Klauseln ebenfalls zu übernehmen. Hieran sollte man immer denken!
Die Nutzung der Storage Preference ist nun wieder einfach:
create index ft_dokument on dokumente_tab(dokument)
indextype is mdsys.context
parameters ('storage mystore')
/
Ansonsten gilt, wie gesagt: Es werden Standard-Storage-Klauseln für Tabellen hinterlegt. Neben dem
Tablespace selbst können also alle möglichen Details eingetragen werden. Am wichtigsten sind jedoch
wahrscheinlich in allen Fällen die Einstellungen zum Tablespace, zur Extent-Verwaltung oder zum Cache.
Weitere Details lassen sich in der Dokumentation nachlesen.

Montag, 10. Oktober 2011

Einzelne Zeilen vom Index ausnehmen - Parameter "FORMAT COLUMN"

Heute geht es um einen eher unscheinbaren Parameter beim Erstellen eines Oracle TEXT Index:
Mit FORMAT COLUMN kann zum einen der Filter für Binärdokumente gesteuert werden - darüber hinaus
kann man damit aber auch festlegen, ob das Dokument überhaupt indiziert werden soll. Und das geht so - wir fangen mit einer einfachen Tabelle an:
create table test(
  id     number(10) not null,
  text   varchar2(4000) not null,
  format varchar2(10) not null,
  constraint pk_test primary key (id),
  constraint ck_format check (format in ('TEXT','BINARY','IGNORE'))
)
/

insert into test values (1, 'Dieses Dokument wird indiziert.', 'TEXT')
/
insert into test values (2, 'Hierfür stehen keine Einträge im Textindex', 'IGNORE')
/
Die Indexerstellung funktioniert wie immer - nur, dass der Parameter FORMAT_COLUMN mitgegeben wird.
create index ft_test on test (text)
indextype is ctxsys.context
parameters ('format column format')
/
Danach schauen wir in die $I-Tabelle - dort stehen in der Tat nur Tokens für das erste
Dokument - die Spalte FORMAT enthält hier "TEXT".
SQL> select token_text from dr$ft_test$i;

TOKEN_TEXT
------------------------------------------
DIESES
DOKUMENT
INDIZIERT
WIRD

4 Zeilen ausgewählt.
Aber was passiert, wenn man die Spalte FORMAT ändert - angenommen, wir ändern den Inhalt
für das zweite Dokument von IGNORE in TEXT ...
SQL> update test set format = 'TEXT' where id = 2
/

1 Zeile aktualisiert.
Wenn man nun in der Dictionary View CTX_USER_PENDING nachsieht, ändert sich diese
(obwohl mit der Spalte FORMAT eine "index-relevante" Spalte geändert wurde, nicht. Ein CTX_DDL.SYNC_INDEX
bewirkt also ebenfalls nichts. Man muss
also entweder im Update-Kommando selbst oder mit einem Trigger sicherstellen, dass bei einer
Änderung der Spalte FORMAT auch die Indexspalte selbst "angefasst" wird - das könnte so
aussehen ...
create or replace trigger tr_upd_format
before update of format on test
for each row
begin
  if :old.format != :new.format then 
    :new.text := :old.text;
  end if;
end;
/
Von nun an wirkt eine Änderung an der Spalte FORMAT wie eine Änderung am Dokument - nach der
Index-Synchronisierung ist das Dokument indiziert bzw. aus dem Index entfernt (je nachdem, wie
die Spalte FORMAT gesetzt wurde).
SQL> update test set format = 'IGNORE' where id = 1; 

1 Zeile wurde aktualisiert.

SQL> update test set format = 'TEXT' where id = 2; 

1 Zeile wurde aktualisiert.

SQL> select pnd_rowid from ctx_user_pending; 

PND_ROWID
------------------------------ 
AAAmVGAAFAAAMWTAAA
AAAmVGAAFAAAMWTAAB

2 Zeilen ausgewählt.

SQL> exec ctx_ddl.sync_index('FT_TEST'); 

PL/SQL-Prozedur erfolgreich abgeschlossen.

SQL> exec ctx_ddl.optimize_index('FT_TEST', ctx_ddl.optlevel_full); 

PL/SQL-Prozedur erfolgreich abgeschlossen.

SQL> select token_text from dr$ft_test$i; 

TOKEN_TEXT
----------------------------------------------------------------
EINTRÄGE
HIERFÜR
IM
KEINE
STEHEN
TEXTINDEX

6 Zeilen ausgewählt.

Freitag, 9. September 2011

Mehrere Sprachen in einer Tabelle unterstützen: MULTI_LEXER

Die Lexer Einstellungen beeinflussen die Art der Speicherung von Texten und den Zugriff auf den Textindex. Stand Datenbank Version 11g gibt es unterschiedliche Lexer-Typen wie zum Beispiel BASIC_LEXER, MULTI_LEXER und WORLD_LEXER. Die Definitionen zu den unterschiedlichen Lexer-Typen sind in Kurzfassung in der Tabelle zu finden.
Wir wollen uns im folgenden Blogeintrag mit dem MULTI_LEXER beschäftigen. Der MULTI_LEXER ist eine Art "Container" für verschiedene Sub-Lexer. Die Nutzung kann in multi-lingualen Umgebungen sinnvoll sein. Im Gegensatz zum WORLD_LEXER erfordert der MULTI_LEXER allerdings eine zusätzliche Language Spalte. Dies bedeutet die Tabellen müssen als zusätzliche Metadateninformation eine Spalte mit der Sprachzugehörigkeit beinhalten.

Nehmen wir als Beispiel eine Tabelle, die nicht nur deutsche sondern auch englische Dokumente abspeichert. Das unterschiedliche Vorkommen wird mit einem Eintrag in einer Language Spalte SPRACHE dokumentiert. Es sollen dabei unterschiedliche Spracheigenschaften wie zum Beispiel Groß-Kleinschreibung und unterschiedliche Stopplisten berücksichtigt werden.
Dazu definieren wir folgende Präferenzen für einen deutschen und einen englischen Lexer.
execute ctx_ddl.drop_preference('global_lexer'); 
execute ctx_ddl.drop_preference('english_lexer');
execute ctx_ddl.drop_preference('german_lexer');

begin
ctx_ddl.create_preference('english_lexer', 'basic_lexer'); 
ctx_ddl.set_attribute('english_lexer','SKIPJOINS','_');
ctx_ddl.set_attribute('english_lexer','mixed_case','no'); 

ctx_ddl.create_preference('german_lexer', 'basic_lexer');
ctx_ddl.set_attribute('german_lexer','mixed_case','yes'); 
ctx_ddl.set_attribute('german_lexer','composite','german'); 
ctx_ddl.set_attribute('german_lexer','alternate_spelling','german');
end;
/
Zusätzlich werden nun auch sprach-spezifische Stopplisten erzeugt. Beim Anlegen des Index, wird je nach Dokumentsprache, das entsprechende Stoppwort ausgesondert.
execute ctx_ddl.drop_stoplist('mymulti_stoplist');

begin
ctx_ddl.create_stoplist('mymulti_stoplist', 'MULTI_STOPLIST');
ctx_ddl.add_stopword('mymulti_stoplist', 'den','german');
ctx_ddl.add_stopword('mymulti_stoplist', 'this','english');
end;
/
Nun definieren wir die MULTI_LEXER Präferenz.
execute ctx_ddl.create_preference('global_lexer','multi_lexer');
Im nächsten Schritt müssen die sprach-spezifischen Lexer mit einem ADD_SUB_LEXER Aufruf hinzugefügt werden. Wichtig ist eine Default Sprache zu definieren - in unserem Fall Deutsch.
begin
ctx_ddl.add_sub_lexer('global_lexer', 'default', 'german_lexer');  
ctx_ddl.add_sub_lexer('global_lexer', 'english','english_lexer', 'eng'); 
end;
/
Nun erzeugen wir die Tabelle mit der zusätzlichen Language Spalte SPRACHE und fügen einige Einträge hinzu.
DROP TABLE globaldoc PURGE;

CREATE TABLE globaldoc ( 
   doc_id       NUMBER, 
   sprache      VARCHAR2(30), 
   text         CLOB); 

INSERT INTO globaldoc values (10,'english','this is not America');
INSERT INTO globaldoc values (12,'english','this is user_110');
INSERT INTO globaldoc values (13,'english','this or that');
INSERT INTO globaldoc values (1,'deutsch','Wer den Pfennig nicht ehrt');
INSERT INTO globaldoc values (2,'deutsch','das ist user_111');
INSERT INTO globaldoc values (5,'deutsch','München');
INSERT INTO globaldoc values (6,'deutsch','Muenchen');
COMMIT;
Nun wird der Index erzeugt.
DROP INDEX globalx FORCE;

CREATE INDEX globalx ON globaldoc (text) 
  indextype is ctxsys.context 
  parameters ('lexer global_lexer 
               language column sprache 
               stoplist mymulti_stoplist'); 
Um mehr über den Index zu erfahren, listen wir die Tokenliste auf. Der Eintrag USER110 ist zu finden, allerdings nicht USER111. Das ist ein Beispiel für die sprach-spezifische Präferenz SKIPJOINS.
SQL> SELECT token_text FROM dr$globalx$i;
TOKEN_TEXT
----------------------------------------------------------------
111
AMERICA
IS
Muenchen
München
NOT
OR
Pfennig
THAT
USER110
Wer
das
den
ehrt
ist
nicht
user
Zum Abfragezeitpunkt untersucht der MULTI_LEXER die Sessionsprache und nutzt die entsprechende Sub-Lexer Präferenz und die aktive stoplist zum Parsen der Abfrage. Falls die Sprache nicht gesetzt ist, wird der Default Lexer verwendet. Um die Abfrage auf eine bestimmte Sprache einzuschränken, kann eine zusätzliche Abfrage-Erweiterung auf die Language Spalte SPRACHE sinnvoll sein.
Folgende Abfragen dokumentieren das Verhalten.
ALTER SESSION SET NLS_LANGUAGE='GERMAN';

-- wegen MIXED_CASE
-- SElECT * FROM globaldoc WHERE contains(text, 'Pfennig')>0;
-- oder
SELECT * FROM globaldoc WHERE contains(text, 'Pfennig')>0 AND sprache='deutsch';
    DOC_ID SPRACHE         TEXT
---------- --------------- -----------------------------------
         1 deutsch         Wer den Pfennig nicht ehrt

SELECT * FROM globaldoc WHERE contains(text, 'PFENNIG')>0;
no rows selected

SELECT * FROM globaldoc WHERE contains(text, 'pfennig')>0;
no rows selected

-- wegen alternate_spelling
SELECT * FROM globaldoc WHERE contains(text, 'Muenchen')>0;

    DOC_ID SPRACHE         TEXT
---------- --------------- -----------------------------------
         5 deutsch         München
         6 deutsch         Muenchen

-- nun englische Dokumente
ALTER SESSION SET NLS_LANGUAGE=ENGLISH;

-- wegen MIXED_CASE
SELECT * FROM globaldoc WHERE contains(text, 'AMERICA')>0;
    DOC_ID SPRACHE         TEXT
---------- --------------- -----------------------------------
        10 english         this is not America

SELECT * FROM globaldoc WHERE contains(text, 'america')>0;
    DOC_ID SPRACHE         TEXT
---------- --------------- -----------------------------------
        10 english         this is not America

SELECT * FROM globaldoc WHERE contains(text, 'America')>0;
    DOC_ID SPRACHE         TEXT
---------- --------------- -----------------------------------
        10 english         this is not America

-- wegen SKIPJOINS und stoplist mit 'this'
SELECT * FROM globaldoc WHERE contains(text, 'user110 or this')>0 AND sprache='English';

    DOC_ID SPRACHE         TEXT
---------- --------------- -----------------------------------
        12 english         this is user_110


SELECT * FROM globaldoc WHERE contains(text, 'user110 or that')>0 AND sprache='English';

    DOC_ID SPRACHE         TEXT
---------- --------------- -----------------------------------
        12 english         this is user_110
        13 english         this or that

Dienstag, 26. Juli 2011

Webseminar: Oracle TEXT und APEX

Das Thema Suchen ist in Geschäftsanwendungen allgegenwärtig. Seien es Kunden, Produkte, Lieferanten oder Geschäftsstellen - immer wieder muss man "etwas finden". Jeder Leser dieses Blogs weiss, dass Oracle TEXT hierfür genau richtig ist.
Doch wie bringt man Oracle TEXT möglichst schnell und einfach zum Anwender? Schließlich arbeitet kein Endanwender direkt mit SQL*Plus. Hier bietet sich Application Express an - damit lassen sich schnell und einfach Webanwendungen bauen.
Wie man Oracle TEXT und APEX gemeinsam nutzt, dazu gibt es ein Webseminar am 11. August 2011 um 11:00 Uhr an. Alles, was man braucht, ist ein Browser, ein Telefon für die Audiokonferenz (Gebührenfrei) und eine Stunde Zeit. Die Teilnahme ist kostenlos - am besten gleich anmelden.

Dienstag, 31. Mai 2011

Unscharfe Namenssuche (Name Search) mit NDATA

Vor einiger Zeit als das Feature Name Search mit 11.2.0.2 zur Verfügung gestellt wurde, haben wir schon eine Einführung in das Thema gegeben. Der Blogeintrag dazu ist hier zu finden.
Da mittlerweile einige Migrationen nach 11.2 durchgeführt wurden, wollen wir das Thema noch einmal aufgreifen und einige zusätzliche Informationen dazu geben. Generell ist das Name Searching Feature mit dem neuen Operator NDATA zu verwenden und hilft eine unscharfe Suche durchzuführen. Ich verwende dazu in folgendem Beispiel eine Tabelle mit ca 200 000 Sätzen und ca 8200 verschiedenen Einträgen in einer Spalte ORT. Um Name Searching zu verwenden, benötigen wir einen XML Tag, den ich mit einem MULTICOLUMN DATASTORE wie folgt zur Verfügung stelle.
exec ctx_ddl.drop_preference('name_ds')
begin
  ctx_ddl.create_preference('name_ds', 'MULTI_COLUMN_DATASTORE');
  ctx_ddl.set_attribute('name_ds', 'COLUMNS', 'ort');
end;
/
begin
  ctx_ddl.drop_section_group('name_sg');
end;
/
begin
  ctx_ddl.create_section_group('name_sg', 'BASIC_SECTION_GROUP');
  ctx_ddl.add_ndata_section('name_sg', 'ort', 'ort');
end;
/
Zusätzlich kann auch ALTERNATE SPELLING und BASE LETTER mit NDATA verwendet werden. Hierzu erzeugen wir folgende BASIC_WORDLIST.
exec ctx_ddl.drop_preference('name_wl');
begin
ctx_ddl.create_preference('name_wl', 'BASIC_WORDLIST');
ctx_ddl.set_attribute('name_wl', 'NDATA_ALTERNATE_SPELLING', 'TRUE');
ctx_ddl.set_attribute('name_wl', 'NDATA_BASE_LETTER', 'TRUE');
end;
/
Nun kann der Index erzeugt werden.
CREATE INDEX ns_ort ON basic_lob (ort)
INDEXTYPE IS ctxsys.context
PARAMETERS ('sync (on commit) datastore name_ds 
section group name_sg wordlist name_wl');
SELECT * FROM ctx_index_errors;
Im ersten Versuch suchen wir den Ort "Vilshofen" in Niederbayern - die Schreibweise ist dabei etwas ungewöhnlich. Der Ort wird allerdings schon bei der ersten Abfrage gefunden.
 
SQL> SELECT distinct ort, score(1) FROM basic_lob
     WHERE contains(ort, 'NDATA(ort,Filschthofen)',1) > 0 
     AND score(1)>40;
ORT                                        SCORE(1)
---------------------------------------- ----------
Vilshofen                                        68
Osthofen                                         45
Nun suchen wir einen Ort in Kalifornien, der mit "Santa" beginnt - auch hier mit etwas unüblicher Schreibweise. Zusätzlich können weitere Argumente mitgegeben werden. Das Argument "Proximity" zum Beispiel gibt an, ob die Ähnlichkeit des Suchbegriffs zum tatsächlichen Namen den Score beeinflussen soll.
  
SQL> SELECT distinct ort, score(1) FROM basic_lob
     WHERE contains(ort, 'NDATA(ort,santa kallifornia,, proximity)',1) > 0 
     AND score(1)>40 ORDER BY 2;
ORT                                        SCORE(1)
---------------------------------------- ----------
Santa Clara/Kalifornien                          52
Staat Kalifornien                                52
Santa Maria                                      57
Santa Monica                                     57
santa clara1 california                          61
Santa California                                 83
Santa Kalifornia                                 96

SQL> SELECT distinct ort, score(1) FROM basic_lob 
     WHERE contains(ort, 'NDATA(ort,santa kallifornia)',1) > 0 
     AND score(1)>40 ORDER BY 2;
ORT                                        SCORE(1)
---------------------------------------- ----------
Santa Clara/Kalifornien                          52
Staat Kalifornien                                52
Santa Maria                                      57
Santa Monica                                     57
Santa California                                 83
santa clara1 california                          83
Santa Kalifornia                                 96
Was bedeutet das nun für unseren Index? Schauen wir uns noch kurz die Statistiken an. Klar wird dabei, dass diese umfangreiche Suche auch mit entsprechenden Ressourcen verbunden ist. Die Anzahl der Tokens ist beispielsweise höher als beim Index ohne NDATA. Der nächste Ausschnitt gibt Aufschluss über die Statistik der Tokens. Informationen über die Vorgehensweise findet sich auch in folgendem Blogeintrag.
DROP TABLE ausgabe;
CREATE TABLE ausgabe (resultat CLOB);
declare
    ergebnis clob := null;
  begin
    ctx_report.index_stats(index_name=>'NS_ORT',report=>ergebnis,stat_type=>null);
    insert into ausgabe values (ergebnis);
    commit;
    dbms_lob.freetemporary(ergebnis);
end;
/
set long 32000
set head off
set pagesize 10000
SELECT * FROM ausgabe;
Folgende Ausgabe liefert diese Abfrage:
                       STATISTICS FOR "US"."NS_ORT"
===========================================================================
indexed documents:                                                200,003
allocated docids:                                                 200,003
$I rows:                                                          170,909
---------------------------------------------------------------------------
                             TOKEN STATISTICS
---------------------------------------------------------------------------
unique tokens:                                                     66,955
average $I rows per token:                                           2.55
...
token statistics by type:
  token type:                                             200:NDATA "ORT"
    unique tokens:                                                 66,955
    total rows:                                                   170,909
    average rows:                                                    2.55
    total size:                                     26,653,305 (25.42 MB)
    average size:                                                     398
    average frequency:                                              61.15
Vergleicht man dies mit einer Context Index ohne die Verwendung von NDATA kommt man in unserem Beispiel nur auf ca 6100 eindeutige Tokens und eine Größe von 1,4 MB. Bevor man Name Search verwendet, kann man auch alternativ den FUZZY Operator ausprobieren. Die vollständige Syntax in folgendem Blogeintrag zeigt, wie umfangreich auch mit FUZZY gesucht werden kann.

Freitag, 25. März 2011

Mehr Performance durch Index-Preloading

Wie kann man die Performance von Zugriffen auf den Textindex erhöhen? Eine Möglichkeit besteht darin, Tabellen und Indizes im Cache zu halten, damit möglichst wenig I/O durchgeführt wird. Roger Ford, Development Manager für Oracle Text, hat zu diesem Thema schon vor längerer Zeit einen Artikel auf OTN verfasst. Da das Thema nicht an Relevanz verloren hat, wollen wir auch hier in unserem Blog das Thema besprechen.
Die Technik, die man zum optimierten Speichern von Segmenten im Cache verwendet, ist die Nutzung von unterschiedlichen Pools im Datenbank Cache. Es ist zum Beispiel sinnvoll, Objekte, auf die sehr häufig zugegriffen wird (z.B. Lookup-Tabellen), dem sogenannten KEEP Pool Cache zuzuordnen. Dabei ist der KEEP Cache ein eigener Bereich im Datenbank Cache, der mit dem Parameter DB_KEEP_CACHE_SIZE konfiguriert wird. Standardmässig ist der Wert auf 0 gesetzt.
Drei Schritte sind zur Nutzung des KEEP Pools nötig:
  • die Bestimmung der Größe des KEEP Pools und Setzen von DB_KEEP_CACHE_SIZE
  • das Setzen der KEEP Storage Option im Index- und Tabellen-Segment
  • das Laden der Objekte in den Cache mit den entsprechenden SQL-Kommandos

Um ein Gefühl für die Größeneinstellung des Pools zu erhalten, kann man sich zuerst die Größe des Index ansehen. Dazu eignet sich die Funktion CTX_REPORT, wie folgt:
SET long 10000
SELECT ctx_report.index_size('TXT_IDX') FROM dual;

TOTALS FOR INDEX US.TXT_IDX
--------------------------------------------------------------------------
CTX_REPORT.INDEX_SIZE('TXT_IDX')
--------------------------------------------------------------------------
TOTAL BLOCKS ALLOCATED:                                              51496
TOTAL BLOCKS USED:                                                   51167
TOTAL BYTES ALLOCATED:                             421,855,232 (402.31 MB)
TOTAL BYTES USED:                                  419,160,064 (399.74 MB)
In unserem Beispiel ist der Index insgesamt 400 MB groß. Nach einigen typischen Abfragen können wir überprüfen, welche Objekte mit wievielen Blöcken sich im Cache befinden. Eine Abfrage auf die V$BH und DBA_OBJECTS Tabelle listet die Objekte auf, die sich momentan im Cache befinden. Die Abfrage sieht dann folgendermassen aus:
COLUMN OWNER FORMAT A10
COLUMN OBJECT_NAME FORMAT A25
COLUMN NUMBER_OF_BLOCKS FORMAT 999,999,999,999

SELECT o.object_name, o.owner, o.object_type, COUNT(*) NUMBER_OF_BLOCKS
FROM dba_objects o, v$bh bh
WHERE o.data_object_id = bh.objd AND o.owner ='US'
GROUP BY o.object_name, o.owner, o.object_type
ORDER BY COUNT(*);

OBJECT_NAME               OWNER      OBJECT_TYPE         NUMBER_OF_BLOCKS
------------------------- ---------- ------------------- ----------------
DR$TXT_IDX$R              US         TABLE                              7
SYS_LOB0000143350C00002$$ US         LOB                              345
DR$TXT_IDX$X              US         INDEX                            440
DR$TXT_IDX$I              US         TABLE                            495
BASIC_LOB                 US         TABLE                          5,634
Die Basistabelle BASIC_LOB oder Teile davon befinden sich offensichtlich im Cache. Der Textindex besteht aus mehreren Komponenten, die ebenfalls in der Abfrage aufgelistet werden: die Token Tabelle $I, der Index $X der $I Tabelle, die ROWID Tabelle $R und ein zusätzliches Lobsegment SYS_LOB0000143350C00002$$ der $R-Tabelle. Folgende Abfrage zeigt die Tabellen und die zugehörigen LOB-Segmente:
SELECT table_name, segment_name, in_row, cache FROM user_lobs;

TABLE_NAME                     SEGMENT_NAME                   IN_ CACHE
------------------------------ ------------------------------ --- ----------
BASIC_LOB                      SYS_LOB0000094513C00010$$      YES NO
DR$TXT_IDX$I                   SYS_LOB0000143761C00006$$      YES NO
DR$TXT_IDX$R                   SYS_LOB0000143766C00002$$      YES YES
...
Die Texte (hier: Spalte TEXT) der Basistabelle liegen in einem LOB-Segment. Ob LOB-Segmente generell den Buffer Cache verwenden oder nicht, hängt von dem Storage-Parameter CACHE ab. Standardmässig werden LOB-Segmente nicht in den Buffer geladen. Um dies einzustellen, ist folgendes Kommando notwendig.
ALTER TABLE BASIC_LOB MODIFY LOB(text) CACHE;
Nun stellt sich die Frage, wie man die Textkomponenten wie $I usw. und die Tabelle in den Cache laden kann. Folgende Abfragen können dabei hilfreich sein:
SELECT /*+ FULL(ITAB) */ SUM(token_count), SUM(LENGTH(token_info))
                                             FROM dr$txt_idx$i ITAB;

SELECT /*+ INDEX(ITAB) */ SUM(LENGTH(token_text))
                                             FROM dr$txt_idx$i ITAB;
SELECT SUM(row_no) FROM dr$txt_idx$r;
SELECT /*+ FULL(BTAB) */ SUM(dok_id) FROM basic_lob BTAB;
Bei den Full Table Scans (FTS) muss allerdings berücksichtigt werden, dass Oracle ein optimiertes Verfahren verwendet, um Objekte in den Cache zu laden. Falls die Größe der Tabelle 2% des Buffer Cache übersteigt - was häufig der Fall sein kann - wird über direct load gelesen und nicht über den Buffer Cache. Möchte man das Laden über den Buffer Cache erzwingen, ist der Einsatz folgendes Parameters notwendig:
ALTER SESSION SET "_small_table_threshold"=zahl;
-- wobei Zahl die Blockgröße angibt, die grösser als das Segment ist.
-- danach kann der FTS ausgeführt werden
SELECT /*+ FULL(BTAB) */ SUM(dok_id) FROM basic_lob BTAB;
Nachlesen kann man diese Information auch in der Oracle Support Note Doc ID 787373.1.
Damit die Segmente auch im KEEP Pool Cache gespeichert werden, müssen nun noch die Buffer Pool Einstellungen der Objekte verändert werden. Folgende Kommandos passen die Storage-Klausel an:
ALTER TABLE dr$txt_idx$i STORAGE (buffer_pool keep);

ALTER INDEX dr$txt_idx$x STORAGE (buffer_pool keep);

ALTER TABLE dr$txt_idx$r STORAGE (buffer_pool keep);

ALTER TABLE basic_lob STORAGE (buffer_pool keep);
Allerdings ist ein weiterer Schritt notwendig, um die Lob-Segmente der $R Tabelle im KEEP Pool zu speichern. Die LOB-Segmente der $I und $R Tabellen werden standardmässig "in-row" gespeichert. Falls die Werte kleiner als 4K sind, wird der Inhalt in der Tabelle selbst gespeichert, wächst er darüber hinaus, wird ein separates Segment angelegt. Da die LOB-Länge der Token Tabellen $I auf 4K limitiert ist, werden die TOKEN-Informationen immer im Segment selbst abgespeichert.
Ganz im Gegensatz dazu kann die Liste der ROWIDs, die in der $R Tabelle gespeichert sind, größer als 4K sein. Dann kommt es zu einer Speicherung in einem separaten Segment. Um sicherzustellen, dass die Informationen der LOBs auch im KEEP Pool gespeichert werden, sollte folgende Einstellung durchgeführt werden.
ALTER TABLE dr$txt_idx$r MODIFY LOB (data) (STORAGE (buffer_pool keep));
Mit den oben aufgeführten Abfragen lassen sich allerdings die LOB-Segmente im separat gespeicherten Segment NICHT in den Cache laden. Folgende Prozedur erledigt diese Aufgabe. Die einzelnen ROWID-Segmente, die grösser als 4K sind, werden dabei ausgelesen.
create or replace procedure loadAllDollarR (idx_name varchar2) is
  v_idx_name varchar2(30) := upper(idx_name);
  type c_type is ref cursor;
  c2 c_type;
  s varchar2(2000);
  b blob;
  buff varchar2(100);
  siz number;
  off number;
  cntr number;
begin
-- wenn Index partitioniert, dann mehr als eine Tabelle
  for c1 in (select table_name t from user_tables
             where table_name like 'DR_'v_idx_name'%$R') loop
    dbms_output.put_line('loading from table 'c1.t);
    s := 'select data from 'c1.t;
    open c2 for s;
    loop
       fetch c2 into b;
       exit when c2%notfound;
       siz := 10;
       off := 1;
       cntr := 0;
-- falls ROWIDs gespeichert sind, dann ROWID (Laenge 10) lesen und OFFSET plus4096
   if dbms_lob.getlength(b) > 4096 then
         begin
           loop
             dbms_lob.read(b, siz, off, buff);
             cntr := cntr + 1;
             off := off + 4096;
           end loop;
         exception when no_data_found then
           if cntr > 0 then
             dbms_output.put_line('4K chunks fetched: 'cntr);
           end if;
         end;
       end if;
    end loop;
  end loop;
end;
/
--Ausführung
exec LoadAllDollarR('TXT_IDX')
Zum Laden der Daten sind insgesamt folgende Abfragen notwendig:
ALTER SESSION SET "_small_table_threshold"= wert;

SELECT /*+ FULL(ITAB) */ SUM(token_count), SUM(LENGTH(token_info))
                                           FROM dr$index_name$i ITAB
SELECT /*+ INDEX(ITAB) */ SUM(LENGTH(token_text))
                                           FROM dr$index_name$i ITAB
SELECT SUM(row_no) FROM dr$index_name$r;
SELECT /*+ FULL(BTAB) */ SUM(spalte_name) FROM table_name BTAB;
exec LoadAllDollarR('index_name')
Summiert man die Anzahl der Blöcke der V$BH Tabelle auf, erhält man den Wert für die Größe von DB_KEEP_CACHE_SIZE.
Folgende Kommandos zeigt die Einstellung dazu:
-- DB_CACHE_SIZE anpassen
ALTER SYSTEM SET DB_CACHE_SIZE=wert;
-- DB_KEEP_CACHE_SIZE setzen
ALTER SYSTEM SET DB_KEEP_CACHE_SIZE=wert;
Viel Spass beim Ausprobieren...

Beliebte Postings