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://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, "[&"-*;-`~!@#$%^*()_+-=\[\]\{\}\|';:/.,?(:]", "")
另一种过滤方法是只允许白名单中的字符
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://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, "[&"-*;-`~!@#$%^*()_+-=\[\]\{\}\|';:/.,?(:]", "")
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>