跳转到内容

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://wikibooks.cn/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://wikibooks.cn/wiki/XQuery/test">
    <name>Joe Doe</name>
    <role type="author"/>
    <contact type="e-mail">[email protected]</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://wikibooks.cn/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://wikibooks.cn/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>
华夏公益教科书