Posts mit dem Label plsql werden angezeigt. Alle Posts anzeigen
Posts mit dem Label plsql werden angezeigt. Alle Posts anzeigen

Mittwoch, 12. Dezember 2012

Suche nach "Aktenzeichen" oder "Autonummer" mit Oracle TEXT: Sind Printjoins die Lösung?

Heute möchte ich mich dem Thema "Suche nach Sonderzeichen" und dem damit verbundenen Thema Printjoins in Oracle TEXT widmen. Vorab schon soviel: Dieses Posting wird eine Warnung vor Printjoins - mit diesem Feature sollte sehr vorsichtig umgegangen werden. Printjoins werden mitunter verwendet, wenn man "Strukturen" wie Aktenzeichen oder "Autonummern" in den Dokumenten hat. Das könnte in etwa so aussehen.
create table dokumente(
  id      number(10),
  doc     varchar2(4000)
)
/

insert into dokumente values (
  1, 'Aktenzeichen 67.MEIER.1455-2012: Steuersache Meier.Erklärung abgegeben'
); 
insert into dokumente values (
  2, 'Aktenzeichen 12.MUSTER.1455-2012: Steuersache Muster.Erklärung abgegeben.'
); 

commit
/
Immer wieder kommt die Anforderung, exakt nach dem Aktenzeichen suchen zu können. Oracle Text erkennt diese Struktur jedoch nicht und indiziert wie folgt:
create index ft_dokumente on dokumente(doc)
indextype is ctxsys.context
/

select token_text from dr$ft_dokumente$i
/

TOKEN_TEXT
----------------------
12
1455
2012
67
ABGEGEBEN
AKTENZEICHEN
ERKLÄRUNG
MEIER
MUSTER
STEUERSACHE
Wenn man nun nach dem Term 1455 sucht, werden beide Dokumente zurückgeliefert. Fachlich ist das eigentlich falsch, denn die 1455 kommt alleinstehend nirgends vor - sie ist überall Teil des Aktenzeichens.
SQL> select * from dokumente where contains(doc, '1455') > 0
/

        ID DOC
---------- --------------------------------------------------
         1 Aktenzeichen 67.MEIER.1455-2012: Steuersache Meier
           .Erklärung abgegeben

         2 Aktenzeichen 12.MUSTER.1455-2012: Steuersache Must
           er.Erklärung abgegeben.
Um diesen Effekt zu verhindern, werden dann gerne Printjoins eingesetzt. Zeichen, die als Printjoins deklariert werden, trennen Wörter nicht mehr voneinander - sie werden dann (nicht ganz) wie Buchstaben behandelt. Ist also das Zeichen "-" als Printjoin deklariert, dann wird der Willy-Brandt-Platz als ein Token "Willy-Brandt-Platz" indiziert und nicht als drei Tokens "Willy", "Brandt" und "Platz".
begin
  ctx_ddl.drop_preference('MY_PJ_PREF');
end;
/
sho err

begin
  ctx_ddl.create_preference('MY_PJ_PREF', 'BASIC_LEXER');
  ctx_ddl.set_attribute('MY_PJ_PREF', 'PRINTJOINS', '.-');
end;
/
sho err

create index ft_dokumente on dokumente(doc)
indextype is ctxsys.context
parameters  ('lexer MY_PJ_PREF')
/

select token_text from dr$ft_dokumente$i
/

TOKEN_TEXT
-----------------------------
12.MUSTER.1455-2012
67.MEIER.1455-2012
ABGEGEBEN
AKTENZEICHEN
MEIER.ERKLÄRUNG
MUSTER.ERKLÄRUNG
STEUERSACHE
Die Anforderung, dass Teile des Aktenzeichens nicht mehr das Aktenzeichen finden, ist erfüllt. Auf den ersten Blick ist das doch eine gute Lösung, oder ...?
  
select * from dokumente where contains(doc, '1455') > 0;

Es wurden keine Zeilen ausgewählt

select * from dokumente where contains(doc, '{67.MEIER.1455-2012}') > 0;

        ID DOC
---------- ----------------------------------------------------------------------
         1 Aktenzeichen 67.MEIER.1455-2012: Steuersache Meier.Erklärung abgegeben
Wie man aber schon am Inhalt der Token-Tabelle erkennen kann, hat das ganze einige "Nebenwirkungen" ... die Suche nach dem Meier schlägt nun fehl.
SQL> select * from dokumente where contains(doc, 'Meier') > 0
/

Es wurden keine Zeilen ausgewählt
Das ist logisch, weil das Token Meier gar nicht indiziert wurde. In den Dokumenten fehlt dummerweise das Leerzeichen nach dem Punkt zwischen Meier und Erklärung. Da der Punkt selbst ein Printjoin ist, wurde Meier.Erklärung indiziert. Und eine Suche nach dem Meier schlägt nun fehl. Printjoins werden stets global für den ganzen Index definiert. Wenn also der Bindestrich eines Aktenzeichens als Printjoin deklariert wird, gilt das nicht nur für die Aktenzeichen, sondern für alle Bindestriche im gesamten Dokumentbestand:
Das Aufnehmen zusätzlicher Zeichen zu den Printjoins sollte also stets mit Vorsicht gemacht werden, es führt fast immer zu unerwünschten Nebenwirkungen, für die dann aufwändige Workarounds mit Wildcards ("Meier%") nötig werden.
Doch wie geht man mit dem Thema Aktenzeichen um? Eine denkbare Lösung könnte ein PROCEDURE_FILTER sein. Dieser sucht mit einem regulären Ausdruck nach dem Aktenzeichen und wandelt die Bindestriche und Punkte in ein Zeichen, welches definitiv keine Probleme macht, um - das könnte bspw. der Underscore ("_") sein. Zunächst erstellen wir also die Prozedur für den PROCEDURE_FILTER.
create or replace function escape_aktenzeichen(p_az in varchar2) return varchar2 deterministic is
begin
  return regexp_replace(p_az, '(\d\d)(.)([A-Z]*)(.)(\d*)(-)(\d*)', '\1_\3_\5_\7');
end escape_aktenzeichen;
/
sho err

create or replace procedure aktenzeichen_filter(
 p_src IN            VARCHAR2, 
 p_dst IN OUT NOCOPY VARCHAR2
) is begin
  p_dst := escape_aktenzeichen(p_src);
end aktenzeichen_filter;
/
sho err
Dass die eigentliche Funktionalität in eine separate Funktion gepackt wurde, hat einen Sinn - dazu weiter unten mehr. Dann erstellen wir die Filter Preference ...
begin
  ctx_ddl.create_preference('MY_AZ_FILTER', 'procedure_filter');
  ctx_ddl.set_attribute('MY_AZ_FILTER', 'procedure', 'aktenzeichen_filter');
  ctx_ddl.set_attribute('MY_AZ_FILTER', 'input_type', 'varchar2');
  ctx_ddl.set_attribute('MY_AZ_FILTER', 'output_type', 'varchar2');
  ctx_ddl.set_attribute('MY_AZ_FILTER', 'rowid_parameter', 'false');
  ctx_ddl.set_attribute('MY_AZ_FILTER', 'charset_parameter', 'false');
end;
/
sho err
... und nicht zu vergessen: Wir definieren die Lexer Preference neu, damit der Underscore (und nur der Underscore) das neue Printjoin wird.
begin
  ctx_ddl.drop_preference('MY_PJ_PREF');
end;
/
sho err

begin
  ctx_ddl.create_preference('MY_PJ_PREF', 'BASIC_LEXER');
  ctx_ddl.set_attribute('MY_PJ_PREF', 'PRINTJOINS', '_');
end;
/
sho err
Nun noch indizieren ...
create index ft_dokumente on dokumente(doc)
indextype is ctxsys.context
parameters  ('lexer MY_PJ_PREF filter MY_AZ_FILTER')
/
Und jetzt sieht die Token-Tabelle so aus:
TOKEN_TEXT
-------------------------
12_MUSTER_1455_2012
67_MEIER_1455_2012
ABGEGEBEN
AKTENZEICHEN
ERKLÄRUNG
MEIER
MUSTER
STEUERSACHE
Eine Suche nach 1455 schlägt nun fehl, so wie es sein soll.
select * from dokumente where contains(doc, '1455') > 0;

Es wurden keine Zeilen ausgewählt
Wenn nun nach einem Aktenzeichen gesucht werden soll, muss man das Aktenzeichen in der Suchanfrage natürlich auch umwandeln - es darf also nicht mehr nach 12.MUSTER.1455-2012, vielmehr muss nach 12_MUSTER_1455_2012 gesucht werden. Und jetzt ist es sehr nützlich, dass wir vorhin die Funktion ESCAPE_AKTENZEICHEN gebaut haben ...
select * from dokumente where contains(doc, escape_aktenzeichen('12.MUSTER.1455-2012')) > 0;

        ID DOC
---------- --------------------------------------------------
         2 Aktenzeichen 12.MUSTER.1455-2012: Steuersache Must
           er, Erklärung abgegeben.
Voilá. Und das ganze lässt sich natürlich auch mit binären (PDF, Office)-Dokumenten kombinieren - in diesem Fall muss der PROCEDURE_FILTER vor dem Anwenden des regulären Ausdrucks mit CTX_DOC.POLICY_FILTER das eigentliche Umwandeln des Binärformats in ASCII-Text machen.
create or replace procedure aktenzeichen_filter(
 p_src IN            VARCHAR2, 
 p_dst IN OUT NOCOPY VARCHAR2
) is begin
  CTX_DOC.POLICY_FILTER( ... );
  p_dst := escape_aktenzeichen(p_src);
end aktenzeichen_filter;
/
sho err

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.

Mittwoch, 8. Dezember 2010

Mächtige Suchabfragen: PL/SQL-Funktionen innerhalb CONTAINS()

Wusstet Ihr schon, dass Ihr in einer CONTAINS-Abfrage auch SQL- und PL/SQL-Funktionen aufrufen könnt ...?
Das kann man nutzen, um Suchbegriffe durch eine Funktion aufzubereiten. Einfache Synonymbeziehungen lassen sich zwar auch mit einem Thesaurus abbilden, wenn die Beziehungen aber komplexerer Natur sind oder zwingend prozeduralen Code erfordern, ist die Nutzung einer PL/SQL-Funktion eine gute Alternative. Dazu ein Beispiel:
Zuerst Tabelle erstellen und einige "Dokumente" einfügen.
create table doc (
  id     number,
  doc    varchar2(4000)
);

insert into doc values (1, 'Oracle 11g');
insert into doc values (2, 'Ein Test');
insert into doc values (3, 'Oracle 9iR2');
insert into doc values (4, 'Oracle 11.2.0.1');
insert into doc values (5, 'Oracle 11gR2');
Dann indizieren ...
create index ft_doc on doc (doc)
indextype is ctxsys.context
/
Man sieht, dass die Datenbankversionen völlig unterschiedlich in der Tabelle auftauchen. Eine Variante wäre mit Sicherheit ein Thesaurus, aber in diesem Beispiel möchten wir das mit einer PL/SQL-Funktion erschlagen. Und die sähe wie folgt aus.
create or replace function format_release(
  p_release in varchar2
) return varchar2 is 
  v_tokens varchar2(4000);
begin
  if p_release like '11.2%' then
    v_tokens := '(11.2%) or {11gR2} or {11g} or {11g Release 2}';
  elsif p_release like '11.1%' then
    v_tokens := '(11.1%) or {11gR1} or {11g} or {11g Release 1}';
  elsif p_release like '10.2%' then
    v_tokens := '(10.2%) or {10gR1} or {10g} or {10g Release 2}';
  elsif p_release like '9.2%' then
    v_tokens := '(9.2%) or {9iR2} or {9i} or {9i Release 2}';
  else 
    v_tokens := p_release;
  end if;
  return v_tokens;
end;
/
Die Anwendung sieht dann so aus ...
SQL> select * from doc where contains(doc, format_release('11.2%')) > 0

        ID DOC
---------- ------------------------------
         1 Oracle 11g
         4 Oracle 11.2.0.1
         5 Oracle 11gR2

3 Zeilen ausgewählt.
In der Funktion lässt sich natürlich kodieren, was man möchte. So kann man auch Informationen aus einer Tabelle holen - damit könnte man ein Synonym wie "bester_kunde" definieren; diese Funktion holt den Namen des umsatzstärksten Kunden aus einer Tabelle und liefert ihn zurück. Man könnte damit also (lediglich anhand des Stichworts bester_kunde) nach allen Dokumenten suchen, in denen der Name des aktuell umsatzstärksten Kunden vorkommt. Eine andere Variante wäre die Kombination mit räumlichen Features der Datenbank. Dann könnte die Funktion in etwa so aussehen (Pseudocode) ...
create or replace function kunden_nahe(
  p_stadt in varchar2
) return varchar2 is 
  v_tokens varchar2(4000) := '';
begin
  -- Räumliche Abfrage: Hole alle Kundennamen, die sich
  -- innerhalb eines 10km-Radius um die gegebene Stadt 
  -- befinden
  for kd in (
    select k.name 
    from kunden k, staedte s
    where sdo_within_distance(k.position, s.position, 10, 'unit=km') = 'TRUE'
    and s.name = p_stadt
  ) loop
    v_tokens := v_tokens ||'(' || kd.name || ') or ';
  end loop;
  v_tokens := substr(v_tokens, 1, length(v_tokens) - 4);
  return v_tokens;
end;
/
Man sieht, dass diese "kleine Randnotiz" (man kann PL/SQL-Funktionen in CONTAINS verwenden), zu sehr mächtigen Suchanfragen führen kann. Die Praxis kennt die besten Beispiele ...

Montag, 8. Juni 2009

USER_DATASTORE ... indiziert wirklich alles

Eine der interessanten Eigenschaften des Oracle TEXT-Index ist, dass die zu indizierenden Dokumente nicht nur einfach aus einer Tabellenspalte, sondern aus unterschiedlichen Data Stores kommen können.
  • DIRECT_DATASTORE: der "Normalfall"
  • MULTICOLUMN_DATASTORE: Mehrere Tabellenspalten (dazu hatten wir schon Blog-Postings
  • FILE_DATASTORE: Die Dokumente liegen im Dateisysten; die Tabelle enthält für jedes Dokument einen vollständigen, absoluten Pfad
  • URL_DATASTORE: Die Dokumente liegen im "Netzwerk"; die Tabelle enthält für jedes Dokument einen vollständigen, absoluten URL (FTP oder HTTP)
  • DETAIL_DATASTORE: Die Dokumente liegen in einer anderen Tabelle, welche mit der zu indizierenden in einer Master-Detail-Beziehung steht
  • NESTED_DATASTORE: Die Dokumente liegen in nicht direkt in der zu indizierenden Tabelle, sondern in einer Nested Table (dürfte selten vorkommen).
Und schließlich gibt es den USER_DATA_STORE, der quasi "alles kann". Und um den soll es in diesem Posting auch gehen. Als Ausgangspunkt haben wir eine Tabelle mit Dokumenten und diese sollen nicht direkt indiziert werden, sondern über einen USER_DATA_STORE. Warum? Weil wir über eine zusätzliche Spalte (INDEX_DOCUMENT) kontrollieren möchten, ob die Dokumente indiziert werden sollen oder nicht. Beginnen wir mit dem Tabellenaufbau ...
SQL> desc dokumente
 Name                                      Null?    Typ
 ----------------------------------------- -------- ---------------------

 ID                                                 NUMBER
 FILENAME                                           VARCHAR2(2000)
 DOCUMENT                                           BLOB
 INDEX_DOCUMENT                                     CHAR(1)
Nun geht es ans Erstellen des Textindex mit dem User Datastore. Ein User Datastore bedeutet nichts weiter als dass der Index seine Dokumente von einer PL/SQL-Prozedur zugewiesen bekommt und diese eben nicht direkt aus der Tabelle holt. Da man in der PL/SQL-Prozedur programmieren kann, was man möchte, ist das Verhalten des User Datastore auch sehr individuell. Dieses Beispiel soll (wie gesagt), das Dokument normal indizieren, wenn die Spalte INDEX_DOCUMENT auf "Y" steht und gar nicht indizieren, wenn die Spalte auf "N" steht. Beginnen wir mit der PL/SQL-Prozedur:
create or replace procedure dokument_uds_proc (
  rid  in              rowid,
  tlob in out NOCOPY   blob    
) is
begin
  begin
    select document into tlob
    from dokumente where rowid = rid and index_document = 'Y';
  exception
    when NO_DATA_FOUND then tlob := null;
  end;
end;
/
Wichtig an dieser Prozedur ist die Signatur, diese muss genau so aussehen, wie in diesem Beispiel vorgegeben. Die zu indizierende ROWID wird in die Prozedur hineingegeben, das zu indizierende Dokument kommt als OUT-Parameter wieder zurück. Man sieht, dass man das Dokument selbst nun beliebig zusammensetzen kann. Die Inhalte können sehr wohl aus verschiedensten Tabellen oder anderen Datenquellen kommen.
Aber bis jetzt ist die Prozedur nur eine normale PL/SQL-Prozedur. Damit sie für etwas ORACLE Text etwas bewirken kann, muss sie zunächst in Oracle TEXT bekannt gemacht werden; die folgenden PL/SQL-Blöcke richten den User Datastore im Dictionary von Oracle TEXT ein.
begin
  ctx_ddl.drop_preference('DOKUMENT_UDS');
end;
/
sho err

begin
  ctx_ddl.create_preference('DOKUMENT_UDS','user_datastore');
  ctx_ddl.set_attribute('DOKUMENT_UDS','procedure','dokument_uds_proc');
  ctx_ddl.set_attribute('DOKUMENT_UDS','output_type','blob_loc');
end;
/
sho err
Bevor der Index nun erzeugt wird, fehlt noch eine Kleinigkeit: Der Index muss in zwei Fällen aktualisiert werden: Erstens wenn sich das Dokument ändert und zweitens wenn die Spale INDEX_DOCUMENT verändert wird. Wenn der Index also auf die Spalte INDEX_DOCUMENT gelegt wird, benötigen wir noch einen Trigger, der UPDATE-Operationen in DOCUMENT auf die Spalte INDEX_DOCUMENT überträgt:
create or replace trigger trg_uds_document
before update on dokumente
for each row
begin
  :new.index_document := :new.index_document;
end;
/
Nun kann der Index erstellt werden. Wie schon beschrieben, wird der Index auf die Spalte INDEX_DOCUMENT gelegt:
create index idx_text_dokumente
on dokumente (index_document)
indextype is ctxsys.context
parameters ('filter ctxsys.auto_filter
             datastore dokument_uds
             memory 200M
             transactional')
/
Wenn alle Einträge ein "N" in der Spalte INDEX_DOCUMENT haben, ist der Index nach Erstellung immer noch leer. Setzt man eine oder mehrere Zeilen auf "Y", so können diese anschließend dank des TRANSACTIONAL-Parameters (Posting!) gefunden werden, das dauert aber sehr lange. Kein Wunder, denn der Index ist noch nicht synchronisiert; die Dokumente in der Pending-Tabelle werden also on-the-fly durchsucht und dabei muss natürlich auch die PL/SQL-Prozedur des User Datastore durchlaufen werden. Nach einem CTX_DDL.SYNC_INDEX('IDX_TEXT_DOKUMENTE') befinden sich die Einträge denn auch im Textindex.
Dieses einfache Beispiel zeigt, wie man einen User Datastore nutzen kann. Wichtig ist, dass hier keine Grenzen existieren; die PL/SQL-Prozedur des UDS ist für Oracle TEXT eine "Black Box"; man kann dort hineinprogrammieren, was man möchte ...

Beliebte Postings