Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions tests/search/sorting/docs-rankfeatures.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{"id":"id:test:rankfeatures::0","fields":{"title":"alpha zero","year":2001,"price":10.0,"category":"red"}},
{"id":"id:test:rankfeatures::1","fields":{"title":"alpha alpha one","year":2002,"price":30.0,"category":"blue"}},
{"id":"id:test:rankfeatures::2","fields":{"title":"alpha alpha alpha two","year":2013,"price":20.0,"category":"red"}},
{"id":"id:test:rankfeatures::3","fields":{"title":"alpha alpha alpha alpha three","year":2014,"price":50.0,"category":"blue"}},
{"id":"id:test:rankfeatures::4","fields":{"title":"alpha alpha alpha alpha alpha four","year":2025,"price":40.0,"category":"red"}}
]
76 changes: 76 additions & 0 deletions tests/search/sorting/rankfeatures.sd
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright Vespa.ai. All rights reserved.
schema rankfeatures {

document rankfeatures {

# query field, repeated "alpha" gives each document a distinct bm25 score
field title type string {
indexing: summary | index
index: enable-bm25
}

field year type int {
indexing: summary | attribute
}

field price type double {
indexing: summary | attribute
}

field category type string {
indexing: summary | attribute
}
}

fieldset default {
fields: title
}

rank-profile default {
first-phase {
expression: attribute(year)
}
}

# The rank features listed in sort-features may be used as sorting keys
# from a query, as sortspec=feature(<name>)
rank-profile sorting inherits default {
function inv_price() {
expression: 1000.0 - attribute(price)
}
function decade() {
expression: floor(attribute(year) / 10)
}
function title_bm25() {
expression: bm25(title)
}
sort-features {
inv_price
decade
title_bm25
nativeRank
}
}

# sort-features are inherited
rank-profile sorting_child inherits sorting {
}

# ... but an empty block clears what was inherited
rank-profile sorting_cleared inherits sorting {
sort-features {}
}

rank-profile with_second_phase inherits sorting {
second-phase {
expression: firstPhase * 100
}
}

# same functions, but none of them declared sortable
rank-profile no_sort_features inherits default {
function inv_price() {
expression: 1000.0 - attribute(price)
}
}
}
203 changes: 203 additions & 0 deletions tests/search/sorting/sorting_rankfeatures.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Copyright Vespa.ai. All rights reserved.

require 'search_test'

class SortingRankFeatures < SearchTest

# Description: Sorting on rank features declared in a rank profile's sort-features block
# Component: Search
# Feature: Query functionality

QUERY = 'select * from sources * where title contains "alpha"'

def setup
set_owner('arnej')
set_description('Test sorting on rank features listed in sort-features')
end

# Documents (see docs-rankfeatures.json), all matching "alpha":
#
# id | title occurrences | year | price | category | inv_price | decade
# ---+-------------------+------+-------+----------+-----------+-------
# 0 | 1 | 2001 | 10.0 | red | 990.0 | 200
# 1 | 2 | 2002 | 30.0 | blue | 970.0 | 200
# 2 | 3 | 2013 | 20.0 | red | 980.0 | 201
# 3 | 4 | 2014 | 50.0 | blue | 950.0 | 201
# 4 | 5 | 2025 | 40.0 | red | 960.0 | 202
#
# bm25(title) grows with the number of occurrences, so it orders 0 < 1 < 2 < 3 < 4.
# The first-phase score is attribute(year), which orders 0 < 1 < 2 < 3 < 4 as well.

def test_sort_by_rank_feature
deploy_app(SearchApp.new.sd(selfdir + 'rankfeatures.sd'))
start
feed_and_wait_for_docs('rankfeatures', 5, :file => selfdir + 'docs-rankfeatures.json')

check_single_level_sorting
check_multi_level_sorting
check_ranking_is_not_activated
check_offset_and_hits
check_yql_ordering
check_inheritance
check_errors
end

# A rank feature can only be used for sorting when the schema is indexed
def test_sort_by_rank_feature_streaming
deploy_app(SearchApp.new.sd(selfdir + 'rankfeatures.sd').streaming)
start

# The query is rejected in the container, so no documents are needed
check_error({ 'ranking' => 'sorting', 'sortspec' => '-feature(inv_price)',
'streaming.selection' => 'true' },
/cannot sort by feature\(inv_price\).*is not indexed/)
end

def check_single_level_sorting
# ascending inv_price is descending price
check_order({ 'ranking' => 'sorting', 'sortspec' => '+feature(inv_price)' }, [3, 4, 1, 2, 0])
check_order({ 'ranking' => 'sorting', 'sortspec' => '-feature(inv_price)' }, [0, 2, 1, 4, 3])
# unlike attribute sorting, feature sorting defaults to ascending
check_order({ 'ranking' => 'sorting', 'sortspec' => 'feature(inv_price)' }, [3, 4, 1, 2, 0])

# a feature using the query, not just attributes
check_order({ 'ranking' => 'sorting', 'sortspec' => '+feature(title_bm25)' }, [0, 1, 2, 3, 4])
check_order({ 'ranking' => 'sorting', 'sortspec' => '-feature(title_bm25)' }, [4, 3, 2, 1, 0])

# a built-in feature (not a function, so not renamed on the way to the backend).
# The scores depend on the nativeRank tuning, so instead of a fixed order we
# check that it gives a total order, descending being the reverse of ascending.
ascending = feature_sorted_ids({ 'ranking' => 'sorting', 'sortspec' => '+feature(nativeRank)' })
descending = feature_sorted_ids({ 'ranking' => 'sorting', 'sortspec' => '-feature(nativeRank)' })
assert_equal([0, 1, 2, 3, 4], ascending.sort)
assert_equal(ascending.reverse, descending)
end

def check_multi_level_sorting
# decade is a tie for {0,1} and {2,3}, broken by the second sort level
check_order({ 'ranking' => 'sorting', 'sortspec' => '+feature(decade) -[rank]' }, [1, 0, 3, 2, 4])
check_order({ 'ranking' => 'sorting', 'sortspec' => '+feature(decade) +[rank]' }, [0, 1, 2, 3, 4])
# feature followed by attribute
check_order({ 'ranking' => 'sorting', 'sortspec' => '+feature(decade) +price' }, [0, 1, 2, 3, 4])
check_order({ 'ranking' => 'sorting', 'sortspec' => '-feature(decade) -price' }, [4, 3, 2, 1, 0])
# feature followed by feature
check_order({ 'ranking' => 'sorting', 'sortspec' => '+feature(decade) -feature(inv_price)' }, [0, 1, 2, 3, 4])
# attribute followed by feature
check_order({ 'ranking' => 'sorting', 'sortspec' => '+category -feature(inv_price)' }, [1, 3, 0, 2, 4])
# the same feature used twice
check_order({ 'ranking' => 'sorting', 'sortspec' => '-feature(inv_price) +feature(inv_price)' }, [0, 2, 1, 4, 3])
end

# Sorting on a rank feature alone does not activate ranking, so all hits get relevance 0.
# Adding [rank] to the sort spec brings ranking back.
def check_ranking_is_not_activated
result = check_order({ 'ranking' => 'sorting', 'sortspec' => '-feature(inv_price)' }, [0, 2, 1, 4, 3])
assert_relevancies(result, [0.0, 0.0, 0.0, 0.0, 0.0])

result = check_order({ 'ranking' => 'sorting', 'sortspec' => '-feature(inv_price) -[rank]' }, [0, 2, 1, 4, 3])
assert_relevancies(result, [2001.0, 2013.0, 2002.0, 2025.0, 2014.0])

# second-phase ranking is not run either
result = check_order({ 'ranking' => 'with_second_phase', 'sortspec' => '-feature(inv_price)' }, [0, 2, 1, 4, 3])
assert_relevancies(result, [0.0, 0.0, 0.0, 0.0, 0.0])

result = check_order({ 'ranking' => 'with_second_phase', 'sortspec' => '-feature(inv_price) -[rank]' }, [0, 2, 1, 4, 3])
assert_relevancies(result, [200100.0, 201300.0, 200200.0, 202500.0, 201400.0])
end

def check_offset_and_hits
result = do_search({ 'ranking' => 'sorting', 'sortspec' => '+feature(inv_price)',
'hits' => '2', 'offset' => '1' })
assert_no_errors(result)
assert_hitcount(result, 5)
assert_equal([4, 1], ids_of(result))
end

def check_yql_ordering
yql = QUERY + ' order by [{"function": "feature"}]inv_price desc'
result = do_search({ 'yql' => yql, 'ranking' => 'sorting' })
assert_no_errors(result)
assert_hitcount(result, 5)
assert_equal([0, 2, 1, 4, 3], ids_of(result))

yql = QUERY + ' order by [{"function": "feature"}]inv_price asc'
result = do_search({ 'yql' => yql, 'ranking' => 'sorting' })
assert_no_errors(result)
assert_equal([3, 4, 1, 2, 0], ids_of(result))
end

def check_inheritance
check_order({ 'ranking' => 'sorting_child', 'sortspec' => '-feature(inv_price)' }, [0, 2, 1, 4, 3])
check_order({ 'ranking' => 'with_second_phase', 'sortspec' => '+feature(decade) +price' }, [0, 1, 2, 3, 4])
end

def check_errors
# not listed in sort-features
check_error({ 'ranking' => 'sorting', 'sortspec' => '-feature(no_such_feature)' },
/cannot sort by feature\(no_such_feature\).*does not allow it/)
# the function exists, but the rank profile has no sort-features at all
check_error({ 'ranking' => 'no_sort_features', 'sortspec' => '-feature(inv_price)' },
/cannot sort by feature\(inv_price\).*does not allow it/)
# inherited sort-features cleared by an empty block
check_error({ 'ranking' => 'sorting_cleared', 'sortspec' => '-feature(inv_price)' },
/cannot sort by feature\(inv_price\).*does not allow it/)
# the default rank profile does not allow it either
check_error({ 'sortspec' => '-feature(inv_price)' },
/cannot sort by feature\(inv_price\).*does not allow it/)
# only bare schema identifiers are legal feature names
check_error({ 'ranking' => 'sorting', 'sortspec' => '-feature(inv.price)' },
/Illegal feature name 'inv\.price' for sorting/)
# missing() does not apply to features, they always have a value
check_error({ 'ranking' => 'sorting', 'sortspec' => '-missing(feature(inv_price),first)' },
/Cannot use missing\(\) with feature\(\.\.\.\) sorting/)
end

def do_search(params)
form = { 'yql' => QUERY, 'hits' => '10' }.merge(params)
encoded_form = URI.encode_www_form(form.to_a)
puts "Query: #{encoded_form}"
search(encoded_form)
end

def ids_of(result)
(0...result.hit.size).map do |i|
result.hit[i].field['documentid'].sub(/id:test:rankfeatures::/, '').to_i
end
end

def assert_no_errors(result)
assert_nil(result.errorlist, "Unexpected error(s): #{result.errorlist}")
end

def check_order(params, exp_ids)
result = do_search(params)
assert_no_errors(result)
assert_hitcount(result, 5)
assert_equal(exp_ids, ids_of(result))
result
end

# Returns the hit ids of a query sorting on a rank feature only, where all
# hits are expected to be unranked
def feature_sorted_ids(params)
result = do_search(params)
assert_no_errors(result)
assert_hitcount(result, 5)
assert_relevancies(result, [0.0] * 5)
ids_of(result)
end

def assert_relevancies(result, exp_relevancies)
act_relevancies = (0...result.hit.size).map { |i| result.hit[i].field['relevancy'].to_f }
assert_equal(exp_relevancies, act_relevancies)
end

def check_error(params, exp_message)
result = do_search(params)
assert_not_nil(result.errorlist, "Expected an error, got #{result}")
message = result.errorlist[0]['message']
puts "Error is #{message}"
assert_match(exp_message, message)
end

end
Loading