Skip to content
52 changes: 15 additions & 37 deletions lib/controllers/v1/projects_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const Project = require( "../../models/project" );
const Site = require( "../../models/site" );
const User = require( "../../models/user" );
const ObservationsController = require( "./observations_controller" );
const ElasticQueryBuilder = require( "../../elastic_query_builder" );

const ProjectsController = class ProjectsController {
static async searchCriteria( req, options = { } ) {
Expand Down Expand Up @@ -176,48 +177,25 @@ const ProjectsController = class ProjectsController {

static async autocomplete( req ) {
InaturalistAPI.setPerPage( req, { default: 10, max: 300 } );
const { page, perPage } = InaturalistAPI.paginationData( req );
const searchCriteria = await ProjectsController.searchCriteria( req, { autocomplete: true } );
if ( !searchCriteria ) {
return InaturalistAPI.basicResponse( req );
}
const response = await esClient.search( "projects", {
body: {
query: {
function_score: {
query: {
bool: {
filter: searchCriteria.filters,
must_not: searchCriteria.inverse_filters,
should: [
{
constant_score: {
filter: {
multi_match: {
query: ( req.query && req.query.q ) || "",
fields: ["*_autocomplete", "description"],
type: "phrase"
}
},
boost: 1
}
}
]
}
},
field_value_factor: {
field: "universal_search_rank",
factor: 1,
missing: 3,
modifier: "log2p"
},
boost_mode: "sum"
}
},
_source: Project.returnFields,
size: req.query.per_page,
sort: searchCriteria.sort
}
const { q } = req.query;
const body = ElasticQueryBuilder.buildQuery( {
q,
sources: ["projects"],
page,
perPage,
req,
filters: searchCriteria.filters,
inverseFilters: searchCriteria.inverse_filters,
sort: searchCriteria.sort,
source: Project.returnFields,
useFunctionScore: true
} );
const response = await esClient.search( "projects", { body } );
return ProjectsController.esResponseToAPIResponse( req, response );
}

Expand Down
189 changes: 22 additions & 167 deletions lib/controllers/v1/search_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const User = require( "../../models/user" );
const util = require( "../../util" );
const InaturalistAPI = require( "../../inaturalist_api" );
const TaxaController = require( "./taxa_controller" );
const ElasticQueryBuilder = require( "../../elastic_query_builder" );

const SearchController = { };

Expand All @@ -25,22 +26,11 @@ SearchController.search = async req => {
}
}
const q = req.query ? req.query.q : "";
const isID = Number.isInteger( Number( q ) ) && Number( q ) > 0;

if ( _.isEmpty( q ) ) {
return InaturalistAPI.basicResponse( req );
}
const { page, perPage } = InaturalistAPI.paginationData( req );
// Things that absolutely must be included
const filter = [
// Sometimes cruft piles up. We don't want to return it
{ exists: { field: "id" } }
];
if ( req.query && req.query.place_id ) {
filter.push( {
terms: { associated_place_ids: [req.query.place_id] }
} );
}
// Things that absolutely must NOT be included
const mustNot = [
{
term: {
Expand All @@ -58,166 +48,31 @@ SearchController.search = async req => {
}
}
];
// The interesting stuff
const should = [
// match _autocomplete fields across all indices
{
constant_score: {
filter: {
multi_match: {
query: q,
fields: ["*_autocomplete", "name"],
fuzziness: "AUTO",
prefix_length: 5,
max_expansions: 2,
operator: "and"
}
},
boost: 1
}
},
// match the nested name_autocomplete field in the taxa index
{
constant_score: {
filter: {
nested: {
path: "names",
ignore_unmapped: true,
query: {
match: {
"names.name_autocomplete": {
fuzziness: "AUTO",
prefix_length: 5,
query: q,
operator: "and"
}
}
}
}
},
boost: 2
}
},
// boost exact matches in the taxa index
{
constant_score: {
filter: {
nested: {
path: "names",
ignore_unmapped: true,
query: {
match: {
"names.exact_ci": {
query: q
}
}
}
}
},
boost: 3
}
},
// boost exact matches in the users index
{
constant_score: {
filter: {
bool: {
should: [
{
match: {
login_exact: {
query: q
}
}
},
{
match: {
orcid: {
query: q
}
}
}
]
}
},
boost: 3
}
},
// boost exact matches across the rest of the indices Note: this
// isn't working perfectly. For one thing it matches more than
// exact matches, e.g. when you search for "lepidopt" you get a
// lot of projects about Lepidoptera. It also seems to score
// projects with multuple mentions of lepidoptera in the desc
// higher than the taxon Lepidoptera if you boost. Boost at 1 is
// ok, but a better solution would be to actually do exact
// matching and score docs equally regardless of term frequency.
{
constant_score: {
filter: {
multi_match: {
query: q,
fields: ["*_autocomplete", "description"],
type: "phrase"
}
},
boost: 1
}
}
const filters = [
{ exists: { field: "id" } }
];
if ( isID ) {
should.push( {
constant_score: {
filter: {
term: { id: Number( q ) }
},
boost: 3
}
if ( req.query && req.query.place_id ) {
filters.push( {
terms: { associated_place_ids: [req.query.place_id] }
} );
}
// Add the shoulds to the filter. Without this, the shoulds will operate only
// in the query context and won't filter out non-matching documents, e.g. if
// you search for "moth" you'll get back documents that do not contain the
// word moth, and if they get a higher score due to higher obs count, they can
// appear above more relevant matches
filter.push( {
bool: {
should
}
} );
const body = {
from: ( perPage * page ) - perPage,
size: perPage,
query: {
function_score: {
query: {
bool: {
filter,
must_not: mustNot,
should
}
},
field_value_factor: {
field: "universal_search_rank",
factor: 1,
missing: 3,
modifier: "log2p"
},
boost_mode: "sum"
const body = ElasticQueryBuilder.buildQuery( {
q,
sources,
page,
perPage,
req,
mustNot,
filters,
useFunctionScore: true,
highlight: {
fields: {
"names.exact_ci": { },
"*_autocomplete": { },
description: { }
}
},
_source: { excludes: User.elasticExcludeFields }
};
const highlight = {
fields: {
"names.exact_ci": { },
"*_autocomplete": { },
description: { }
}
};
if ( util.isJa( q ) ) {
highlight.fields["names.name_ja"] = { };
}
body.highlight = highlight;
} );
const response = await esClient.search( sources, { body } );
const localeOpts = util.localeOpts( req );
const results = _.compact( _.map( response.hits.hits, h => {
Expand Down
Loading