跳轉到內容

XQuery/關鍵詞搜尋

來自 Wikibooks,開放的書籍,為開放的世界

您希望為 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, "[&amp;&quot;-*;-`~!@#$%^*()_+-=\[\]\{\}\|';:/.,?(:]", "")

另一種過濾方法是隻允許白名單中的字元

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', '[. &amp;= "', $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(), '&amp;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, '&amp;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, '&amp;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, '&amp;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, "[&amp;&quot;-*;-`~!@#$%^*()_+-=\[\]\{\}\|';:/.,?(:]", "")
let $scope := 
    ( 
        collection('/db/test/articles')/test:article/test:body,
        collection('/db/test/people')/test:person/test:biography
    )
let $search-string := concat('$scope', '[. &amp;= "', $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(), '&amp;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, '&amp;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, '&amp;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, '&amp;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 &quot;{$q}&quot;.  Displaying {$how-many-on-this-page}.</h2>,
        <div id="searchresults">{$results}</div>,
        <div id="search-pagination">{$pagination-links}</div>
        )
    }
</body>
</html>
華夏公益教科書