XQuery/關鍵詞搜尋
您希望為 XML 資料庫建立一個類似 Google 的關鍵詞搜尋介面,其中包含對選定節點的基於相關性的全文字搜尋和搜尋結果,其中上下文中的關鍵字突出顯示,如下所示。
我們的搜尋引擎將從簡單的 HTML 表單接收關鍵詞,並將它們分配給變數 $q。然後它(1)解析關鍵詞,(2)構建查詢範圍,(3)執行查詢,(4)根據得分對命中結果進行評分和排序,(5)顯示帶有摘要的連結結果,其中包含在上下文中突出顯示的關鍵字,以及(6)對結果進行分頁。
注意: 本教程是在 eXist 1.3 的基礎上編寫的,eXist 1.3 是 eXist 的開發版本;此後 eXist 1.4 已釋出,它略微改變了 eXist 的幾個方面。本文尚未完全更新以反映這些變化。最顯著的變化是(1)此處引用的 kwic.xql 檔案現在是一個內建模組,以及(2)以前的預設全文字搜尋索引(其搜尋運算子在下面顯示為 &=)預設情況下被停用,轉而使用新的基於 Lucene 的全文字索引,這大大提高了搜尋和評分速度。使程式碼適用於 1.4 所需的更改將很廣泛,但儘管如此,本文以其當前形式仍然具有指導意義。最後,此示例在 1.3 之前的版本中無法執行。
假設您有三個集合
/db/test
/db/test/articles
/db/test/people
articles 和 people 集合包含具有不同模式的 XML 檔案: "articles" 包含結構化內容,而 "people" 包含文章中提到的有關人物的傳記資訊。我們希望使用全文字關鍵詞搜尋搜尋這兩個集合,並且我們希望搜尋每個集合的特定節點:文章正文和人物姓名。從根本上說,我們的搜尋字串是
for $hit in (collection('/db/test/articles')/article/body,
collection('/db/test/people')/person/biography)[. &= $q]
注意: "&=" 是 eXist 全文字搜尋運算子,它將返回與 $q 的標記化內容匹配的節點。有關更多資訊,請參見 [1]。
假設您有兩個集合
檔案 ='/db/test/articles/1.xml'
<article id="1" xmlns="https://wikibook.tw/wiki/XQuery/test">
<head>
<author id="2"/>
<posted when="2009-01-01"/>
</head>
<body>
<title>A Day at the Races</title>
<div>
<head>So much for taking me out to the ballgame</head>
<p>My dad, <person target="1">John</person>, was a great guy, but he sure was a bad
driver...</p>
<p>...</p>
</div>
</body>
</article>
檔案 ='/db/test/people/2.xml'
<person id="2" xmlns="https://wikibook.tw/wiki/XQuery/test">
<name>Joe Doe</name>
<role type="author"/>
<contact type="e-mail">joeschmoe@mail.net</contact>
<biography>Joe Doe was born in Brooklyn, New York, and he now lives in Boston, Massachusetts.</biography>
</person>
檔案 ='/db/test/search.xq'
xquery version "1.0";
declare namespace test="https://wikibook.tw/wiki/XQuery/test";
declare option exist:serialize "method=xhtml media-type=text/html";
<html>
<head><title>Keyword Search</title></head>
<body>
<h1>Keyword Search</h1>
<form method="GET">
<p>
<strong>Keyword Search:</strong>
<input name="q" type="text"/>
</p>
<p>
<input type="submit" value="Search"/>
</p>
</form>
</body>
</html>
請注意,form 元素還可以包含 action 屬性(例如 action="search.xq")以指定要使用的 XQuery 函式。
顯示接收到的結果在搜尋欄位中會很好,因此我們可以使用 request:get-parameter() 函式將搜尋提交捕獲到變數 $q 中。我們將 input 元素更改為,只要有值,它就包含 $q 的值。
let $q := xs:string(request:get-parameter("q", ""))
...
<input name="q" type="text" value="{$q}"/>
為了 防止 XQuery 注入 攻擊,建議將 $q 變數強制轉換為 xs:string 型別並從搜尋引數中過濾掉不需要的字元。
let $q := xs:string(request:get-parameter("q", ""))
let $filtered-q := replace($q, "[&"-*;-`~!@#$%^*()_+-=\[\]\{\}\|';:/.,?(:]", "")
另一種過濾方法是隻允許白名單中的字元
let $q := xs:string(request:get-parameter("q", ""))
let $filtered-q := replace($q, "[^0-9a-zA-ZäöüßÄÖÜ\-,. ]", "")
在原生 XML 資料庫的上下文中,搜尋範圍可以非常細粒度,使用 XPath 的全部表達能力。我們可以選擇定位特定的集合、文件和文件中的節點。我們還可以定位特定的元素名稱空間,並且可以使用謂詞將結果限制為具有特定屬性的元素。在我們的示例中,我們將定位兩個集合和每個情況下的特定 XPath。我們使用 XPath 表示式序列建立此搜尋範圍
let $scope :=
(
collection('/db/test/articles')/article/body,
collection('/db/test/people')/people/person/biography
)
雖然我們可以直接使用上面的示例(在“示例集合和資料”下)執行我們的搜尋,但如果我們首先將搜尋構建為字串,然後使用 util:eval() 函式執行它,我們會擁有更大的靈活性。
let $search-string := concat('$scope', '[. &= "', $filtered-q, '"]')
let $hits := util:eval($search-string)
如果我們不排序結果,結果將以“文件順序”返回——資料庫執行搜尋的順序。結果可以根據任何標準進行排序:字母順序、日期順序、關鍵字匹配次數等。我們將使用一個簡單的相關性演算法來對結果進行評分:關鍵字匹配次數除以匹配節點的字串長度。使用此演算法,長度為 10 個字元的 1 次匹配命中結果將比長度為 100 個字元的 2 次匹配命中結果得分更高。
let $sorted-hits :=
for $hit in $hits
let $keyword-matches := text:match-count($hit)
let $hit-node-length := string-length($hit)
let $score := $keyword-matches div $hit-node-length
order by $score descending
return $hit
我們希望將每個搜尋結果展示為一個 HTML div 元素,包含三個部分:匹配項的標題、包含關鍵詞高亮顯示的摘要以及指向完整匹配項的連結。根據不同的資料集合,這些部分的構造方式會有所不同。我們使用資料集合作為 “鉤子” 來控制每種結果型別的顯示。(注意:也可以使用其他 “鉤子”,比如名稱空間、節點名稱等。)
我們將會匯入一個名為 kwic.xql 的模組並使用其中的 kwic:summarize() 函式來建立高亮關鍵詞搜尋摘要。kwic:summarize() 函式會高亮匹配項中的第一個關鍵詞,並返回周圍的文字。kwic.xql 由 Wolfgang Meier 編寫,並隨 eXist 1.3b 版釋出。我們將把 kwic.xql 放置到 eXist 資料庫的 /db/test/ 資料集合中。
xquery version "1.0";
import module namespace kwic="http://exist-db.org/xquery/kwic" at "xmldb:exist:///db/test/kwic.xql";
...
let $results :=
for $hit in $sorted-hits[position() = ($start to $end)]
let $collection := util:collection-name($hit)
let $document := util:document-name($hit)
let $base-uri := replace(request:get-url(), 'search.xq$', '')
let $config := <config xmlns="" width="60"/>
return
if ($collection = '/db/test/articles') then
let $title := doc(concat($collection, '/', $document))//test:title/text()
let $summary := kwic:summarize($hit, $config)
let $url := concat('view-article.xq?article=', $document)
return
<div class="result">
<p>
<span class="title"><a href="{$url}">{$title}</a></span><br/>
{$summary/*}<br/>
<span class="url">{concat($base-uri, $url)}</span>
</p>
</div>
else if ($collection = '/db/test/people') then
let $title := doc(concat($collection, '/', $document))//test:name/text()
let $summary := kwic:summarize($hit, $config)
let $url := concat('view-person.xq?person=', $document)
return
<div class="result">
<p>
<span class="title"><a href="{$url}">{$title}</a></span><br/>
{$summary/*}<br/>
<span class="url">{concat($base-uri, $url)}</span>
</p>
</div>
else
let $title := concat('Unknown result. Collection: ', $collection, '. Document: ', $document, '.')
let $summary := kwic:summarize($hit, $config)
let $url := concat($collection, '/', $document)
return
<div class="result">
<p>
<span class="title"><a href="{$url}">{$title}</a></span><br/>
{$summary/*}<br/>
<span class="url">{concat($base-uri, $url)}</span>
</p>
</div>
為了將結果列表縮減到可管理的範圍,我們可以使用 URL 引數和 XPath 謂詞來每次僅返回 10 個結果。為此,我們需要定義兩個新變數:$perpage 和 $start。當用戶檢索每一頁結果時,$start 值將作為 URL 引數傳遞到伺服器,使用 XPath 謂詞驅動新的結果集。
let $perpage := xs:integer(request:get-parameter("perpage", "10"))
let $start := xs:integer(request:get-parameter("start", "0"))
let $end := $start + $perpage
let $results :=
for $hit in $sorted-hits[$start to $end]
...
我們還需要提供指向每一頁結果的連結。為此,我們將模仿 Google 的分頁連結,它們從每頁顯示 10 個結果開始,逐漸增加到每頁 20 個結果,並顯示上一頁和下一頁的結果。我們的分頁連結只有在結果超過 10 個時才會顯示,並將是一個簡單的 HTML 列表,可以使用 CSS 進行樣式設定。
let $perpage := xs:integer(request:get-parameter("perpage", "10"))
let $start := xs:integer(request:get-parameter("start", "0"))
let $total-result-count := count($hits)
let $end :=
if ($total-result-count lt $perpage) then
$total-result-count
else
$start + $perpage
let $number-of-pages :=
xs:integer(ceiling($total-result-count div $perpage))
let $current-page := xs:integer(($start + $perpage) div $perpage)
let $url-params-without-start := replace(request:get-query-string(), '&start=\d+', '')
let $pagination-links :=
if ($total-result-count = 0) then ()
else
<div id="search-pagination">
<ul>
{
(: Show 'Previous' for all but the 1st page of results :)
if ($current-page = 1) then ()
else
<li><a href="{concat('?', $url-params-without-start, '&start=', $perpage * ($current-page - 2)) }">Previous</a></li>
}
{
(: Show links to each page of results :)
let $max-pages-to-show := 20
let $padding := xs:integer(round($max-pages-to-show div 2))
let $start-page :=
if ($current-page le ($padding + 1)) then
1
else $current-page - $padding
let $end-page :=
if ($number-of-pages le ($current-page + $padding)) then
$number-of-pages
else $current-page + $padding - 1
for $page in ($start-page to $end-page)
let $newstart := $perpage * ($page - 1)
return
(
if ($newstart eq $start) then
(<li>{$page}</li>)
else
<li><a href="{concat('?', $url-params-without-start, '&start=', $newstart)}">{$page}</a></li>
)
}
{
(: Shows 'Next' for all but the last page of results :)
if ($start + $perpage ge $total-result-count) then ()
else
<li><a href="{concat('?', $url-params-without-start, '&start=', $start + $perpage)}">Next</a></li>
}
</ul>
</div>
我們還應該提供搜尋結果的簡明英文摘要,例如 “Showing all 5 of 5 results” 或 “Showing 10 of 1200 results”。
let $how-many-on-this-page :=
(: provides textual explanation about how many results are on this page,
: i.e. 'all n results', or '10 of n results' :)
if ($total-result-count lt $perpage) then
concat('all ', $total-result-count, ' results')
else
concat($start + 1, '-', $end, ' of ', $total-result-count, ' results')
以下是完整的 search.xq 檔案,其中包含一些 CSS 程式碼,使結果看起來更美觀。這個搜尋 XQuery 非常長,可以將程式碼部分移到單獨的函式中,以便進行重構。
檔案 ='/db/test/search.xq'
xquery version "1.0";
import module namespace kwic="http://exist-db.org/xquery/kwic" at "xmldb:exist:///db/test/kwic.xql";
declare namespace test="https://wikibook.tw/wiki/XQuery/test";
declare option exist:serialize "method=xhtml media-type=text/html";
let $q := xs:string(request:get-parameter("q", ""))
let $filtered-q := replace($q, "[&"-*;-`~!@#$%^*()_+-=\[\]\{\}\|';:/.,?(:]", "")
let $scope :=
(
collection('/db/test/articles')/test:article/test:body,
collection('/db/test/people')/test:person/test:biography
)
let $search-string := concat('$scope', '[. &= "', $filtered-q, '"]')
let $hits := util:eval($search-string)
let $sorted-hits :=
for $hit in $hits
let $keyword-matches := text:match-count($hit)
let $hit-node-length := string-length($hit)
let $score := $keyword-matches div $hit-node-length
order by $score descending
return $hit
let $perpage := xs:integer(request:get-parameter("perpage", "10"))
let $start := xs:integer(request:get-parameter("start", "0"))
let $total-result-count := count($hits)
let $end :=
if ($total-result-count lt $perpage) then
$total-result-count
else
$start + $perpage
let $results :=
for $hit in $sorted-hits[position() = ($start + 1 to $end)]
let $collection := util:collection-name($hit)
let $document := util:document-name($hit)
let $config := <config xmlns="" width="60"/>
let $base-uri := replace(request:get-url(), 'search.xq$', '')
return
if ($collection = '/db/test/articles') then
let $title := doc(concat($collection, '/', $document))//test:title/text()
let $summary := kwic:summarize($hit, $config)
let $url := concat('view-article.xq?article=', $document)
return
<div class="result">
<p>
<span class="title"><a href="{$url}">{$title}</a></span><br/>
{$summary/*}<br/>
<span class="url">{concat($base-uri, $url)}</span>
</p>
</div>
else if ($collection = '/db/test/people') then
let $title := doc(concat($collection, '/', $document))//test:name/text()
let $summary := kwic:summarize($hit, $config)
let $url := concat('view-person.xq?person=', $document)
return
<div class="result">
<p>
<span class="title"><a href="{$url}">{$title}</a></span><br/>
{$summary/*}<br/>
<span class="url">{concat($base-uri, $url)}</span>
</p>
</div>
else
let $title := concat('Unknown result. Collection: ', $collection, '. Document: ', $document, '.')
let $summary := kwic:summarize($hit, $config)
let $url := concat($collection, '/', $document)
return
<div class="result">
<p>
<span class="title"><a href="{$url}">{$title}</a></span><br/>
{$summary/*}<br/>
<span class="url">{concat($base-uri, $url)}</span>
</p>
</div>
let $number-of-pages :=
xs:integer(ceiling($total-result-count div $perpage))
let $current-page := xs:integer(($start + $perpage) div $perpage)
let $url-params-without-start := replace(request:get-query-string(), '&start=\d+', '')
let $pagination-links :=
if ($number-of-pages le 1) then ()
else
<ul>
{
(: Show 'Previous' for all but the 1st page of results :)
if ($current-page = 1) then ()
else
<li><a href="{concat('?', $url-params-without-start, '&start=', $perpage * ($current-page - 2)) }">Previous</a></li>
}
{
(: Show links to each page of results :)
let $max-pages-to-show := 20
let $padding := xs:integer(round($max-pages-to-show div 2))
let $start-page :=
if ($current-page le ($padding + 1)) then
1
else $current-page - $padding
let $end-page :=
if ($number-of-pages le ($current-page + $padding)) then
$number-of-pages
else $current-page + $padding - 1
for $page in ($start-page to $end-page)
let $newstart := $perpage * ($page - 1)
return
(
if ($newstart eq $start) then
(<li>{$page}</li>)
else
<li><a href="{concat('?', $url-params-without-start, '&start=', $newstart)}">{$page}</a></li>
)
}
{
(: Shows 'Next' for all but the last page of results :)
if ($start + $perpage ge $total-result-count) then ()
else
<li><a href="{concat('?', $url-params-without-start, '&start=', $start + $perpage)}">Next</a></li>
}
</ul>
let $how-many-on-this-page :=
(: provides textual explanation about how many results are on this page,
: i.e. 'all n results', or '10 of n results' :)
if ($total-result-count lt $perpage) then
concat('all ', $total-result-count, ' results')
else
concat($start + 1, '-', $end, ' of ', $total-result-count, ' results')
return
<html>
<head>
<title>Keyword Search</title>
<style>
body {{
font-family: arial, helvetica, sans-serif;
font-size: small
}}
div.result {{
margin-top: 1em;
margin-bottom: 1em;
border-top: 1px solid #dddde8;
border-bottom: 1px solid #dddde8;
background-color: #f6f6f8;
}}
#search-pagination {{
display: block;
float: left;
text-align: center;
width: 100%;
margin: 0 5px 20px 0;
padding: 0;
overflow: hidden;
}}
#search-pagination li {{
display: inline-block;
float: left;
list-style: none;
padding: 4px;
text-align: center;
background-color: #f6f6fa;
border: 1px solid #dddde8;
color: #181a31;
}}
span.hi {{
font-weight: bold;
}}
span.title {{ font-size: medium; }}
span.url {{ color: green; }}
</style>
</head>
<body>
<h1>Keyword Search</h1>
<div id="searchform">
<form method="GET">
<p>
<strong>Keyword Search:</strong>
<input name="q" type="text" value="{$q}"/>
</p>
<p>
<input type="submit" value="Search"/>
</p>
</form>
</div>
{
if (empty($hits)) then ()
else
(
<h2>Results for keyword search "{$q}". Displaying {$how-many-on-this-page}.</h2>,
<div id="searchresults">{$results}</div>,
<div id="search-pagination">{$pagination-links}</div>
)
}
</body>
</html>
