在本指南中,您将了解如何在 Meilisearch 中实现筛选评分功能以增强您的搜索功能。

什么是过滤器提升?

过滤器提升,也称为过滤器评分,是一种高级搜索优化策略,旨在增强返回文档的相关性和准确性。该方法不只是简单地返回匹配单个过滤器的文档,而是对多个过滤器使用加权系统。与最多过滤器匹配的文档(或与权重最高的过滤器匹配的文档)将被优先考虑并在搜索结果的顶部返回。

生成过滤器提升查询

Meilisearch 允许用户通过添加 过滤器 来细化他们的搜索查询。传统上,只有完全匹配这些过滤器的文档才会在搜索结果中返回。

通过实现过滤器提升,您可以优化文档检索过程,方法是根据多个加权过滤器的相关性对文档进行排名。这确保了更个性化和有效的搜索体验。

这种实现背后的理念是为每个过滤器关联一个权重。权重值越高,过滤器应该越重要。在本节中,我们将演示如何实现利用这些加权过滤器的搜索算法。

步骤 1 - 设置和优先排序过滤器:权重分配

要利用筛选评分功能,您需要提供一个包含过滤器及其相应权重的列表。这有助于根据对您最重要的条件对搜索结果进行优先排序。

使用 JavaScript 的示例输入

const filtersWeights = [
    { filter: "genres = Animation", weight: 3 },
    { filter: "genres = Family", weight: 1 },
    { filter: "release_date > 1609510226", weight: 10 }
]

在上面的示例中

  • 最高权重分配给发行日期,表示偏好 2021 年之后发行的电影
  • “动画”类型的电影获得下一级优先权
  • “家庭”类型电影也获得轻微提升

步骤 2. 组合过滤器

目标是创建所有过滤器组合的列表,其中每个组合都与其总权重相关联。

以之前的示例作为参考,生成的查询及其总权重如下所示

("genres = Animation AND genres = Family AND release_date > 1609510226", 14)
("genres = Animation AND NOT(genres = Family) AND release_date > 1609510226", 13)
("NOT(genres = Animation) AND genres = Family AND release_date > 1609510226", 11)
("NOT(genres = Animation) AND NOT(genres = Family) AND release_date > 1609510226", 10)
("genres = Animation AND genres = Family AND NOT(release_date > 1609510226)", 4)
("genres = Animation AND NOT(genres = Family) AND NOT(release_date > 1609510226)", 3)
("NOT(genres = Animation) AND genres = Family AND NOT(release_date > 1609510226)", 1)
("NOT(genres = Animation) AND NOT(genres = Family) AND NOT(release_date > 1609510226)", 0)

我们可以看到,当过滤器匹配条件 1 + 条件 2 + 条件 3 时,总权重为 weight1 + weight2 + weight3 ( 3 + 1 + 10 = 14)。

下面我们将解释如何构建此列表。有关自动化此过程的详细信息,请参阅 过滤器组合算法 部分。

然后,您可以使用 Meilisearch 的 多搜索 API 根据这些过滤器执行查询,并根据其分配的权重以降序排列它们。

步骤 3. 使用 Meilisearch 的多搜索 API

👉
不要忘记首先安装 Meilisearch JavaScript 客户端

npm install meilisearch
\\ 或
yarn add meilisearch

const { MeiliSearch } = require('meilisearch')
// Or if you are in a ES environment
import { MeiliSearch } from 'meilisearch'

;(async () => {
    // Setup Meilisearch client
    const client = new MeiliSearch({
        host: 'https://:7700',
        apiKey: 'apiKey',
    })
    
    const INDEX = "movies"
    const limit = 20
    
    const queries = [
        { indexUid: INDEX, limit: limit, filter: 'genres = Animation AND genres = Family AND release_date > 1609510226' },
        { indexUid: INDEX, limit: limit, filter: 'genres = Animation AND NOT(genres = Family) AND release_date > 1609510226' },
        { indexUid: INDEX, limit: limit, filter: 'NOT(genres = Animation) AND genres = Family AND release_date > 1609510226' },
        { indexUid: INDEX, limit: limit, filter: 'NOT(genres = Animation) AND NOT(genres = Family) AND release_date > 1609510226' },
        { indexUid: INDEX, limit: limit, filter: 'genres = Animation AND genres = Family AND NOT(release_date > 1609510226)' },
        { indexUid: INDEX, limit: limit, filter: 'genres = Animation AND NOT(genres = Family) AND NOT(release_date > 1609510226)' },
        { indexUid: INDEX, limit: limit, filter: 'NOT(genres = Animation) AND genres = Family AND NOT(release_date > 1609510226)' },
        { indexUid: INDEX, limit: limit, filter: 'NOT(genres = Animation) AND NOT(genres = Family) AND NOT(release_date > 1609510226)' }
    ]
    
    try {
        const results = await client.multiSearch({ queries });
        displayResults(results);
    } catch (error) {
        console.error("Error while fetching search results:", error);
    }
    
    function displayResults(data) {
        let i = 0;
        console.log("=== best filter ===");
        
        for (const resultsPerIndex of data.results) {
            for (const result of resultsPerIndex.hits) {
                if (i >= limit) {
                    break;
                }
                console.log(`${i.toString().padStart(3, '0')}: ${result.title}`);
                i++;
            }
            console.log("=== changing filter ===");
        }
    }
    
})();

我们首先导入任务所需的库。然后我们初始化 Meilisearch 客户端,该客户端连接到我们的 Meilisearch 服务器,并定义我们将要搜索的电影索引。

接下来,我们将我们的搜索条件发送到 Meilisearch 服务器并检索结果。multiSearch 函数允许我们一次发送多个搜索查询,这比逐个发送效率更高。

最后,我们将结果以格式化的方式打印出来。外部循环遍历每个过滤器的结果。内部循环遍历给定过滤器的命中(实际搜索结果)。我们用数字前缀打印每部电影的标题。

我们得到以下输出

=== best filter ===
000: Blazing Samurai
001: Minions: The Rise of Gru
002: Sing 2
003: The Boss Baby: Family Business
=== changing filter ===
004: Evangelion: 3.0+1.0 Thrice Upon a Time
005: Vivo
=== changing filter ===
006: Space Jam: A New Legacy
007: Jungle Cruise
=== changing filter ===
008: Avatar 2
009: The Flash
010: Uncharted
...
=== changing filter ===

过滤器组合算法

虽然手动筛选方法可以提供准确的结果,但它并不是最有效的方法。自动化此过程将显着提高速度和效率。让我们创建一个函数,该函数将查询参数和加权过滤器列表作为输入,并输出一个搜索命中的列表。

实用程序函数:过滤器操作的基础

在深入核心函数之前,必须创建一些实用程序函数来处理过滤器操作。

否定过滤器

negateFilter 函数返回给定过滤器的反面。例如,如果提供 genres = Animation,它将返回 NOT(genres = Animation)

function negateFilter(filter) {
  return `NOT(${filter})`;
}

聚合过滤器

aggregateFilters 函数使用“AND”操作组合两个过滤器字符串。例如,如果给出 genres = Animationrelease_date > 1609510226,它将返回 (genres = Animation) AND (release_date > 1609510226)

function aggregateFilters(left, right) {
  if (left === "") {
    return right;
  }
  if (right === "") {
    return left;
  }
  return `(${left}) AND (${right})`;
}

生成组合

getCombinations 函数从输入数组生成指定大小的所有可能组合。这对于根据其分配的权重创建不同组的过滤器组合至关重要。

function getCombinations(array, size) {
    const result = [];
    
    function generateCombination(prefix, remaining, size) {
        if (size === 0) {
            result.push(prefix);
            return;
        }
        
        for (let i = 0; i < remaining.length; i++) {
            const newPrefix = prefix.concat([remaining[i]]);
            const newRemaining = remaining.slice(i + 1);
            generateCombination(newPrefix, newRemaining, size - 1);
        }
    }
    
    generateCombination([], array, size);
    return result;
}

核心函数:boostFilter

现在我们有了实用程序函数,现在我们可以根据其分配的权重以更动态的方式生成过滤器组合。这是通过 boostFilter 函数实现的,该函数根据其各自的权重组合和排序过滤器。

function boostFilter(filterWeights) {
    const totalWeight = filterWeights.reduce((sum, { weight }) => sum + weight, 0);
    const weightScores = {};
    
    const indexes = filterWeights.map((_, idx) => idx);
    
    for (let i = 1; i <= filterWeights.length; i++) {
        const combinations = getCombinations(indexes, i);
        
        for (const filterIndexes of combinations) {
            const combinationWeight = filterIndexes.reduce((sum, idx) => sum + filterWeights[idx].weight, 0);
            weightScores[filterIndexes] = combinationWeight / totalWeight;
        }
    }
    
    const filterScores = [];
    for (const [filterIndexes, score] of Object.entries(weightScores)) {
        let aggregatedFilter = "";
        const indexesArray = filterIndexes.split(",").map(idx => parseInt(idx));
        
        for (let i = 0; i < filterWeights.length; i++) {
            if (indexesArray.includes(i)) {
                aggregatedFilter = aggregateFilters(aggregatedFilter, filterWeights[i].filter);
            } else {
                aggregatedFilter = aggregateFilters(aggregatedFilter, negateFilter(filterWeights[i].filter));
            }
        }
        filterScores.push([aggregatedFilter, score]);
    }
    
    filterScores.sort((a, b) => b[1] - a[1]);
    return filterScores;
} 


分解 boostFilter 函数

让我们剖析该函数,以便更好地理解其组成部分和操作。

1. 计算总权重

该函数首先计算 totalWeight,它只是 filterWeights 数组中所有权重的总和。

const totalWeight = filterWeights.reduce((sum, { weight }) => sum + weight, 0);
2. 创建权重和索引结构

这里初始化了两个基本结构

  • weightScores:保存过滤器组合及其关联的相对分数
  • indexes:一个数组,将每个过滤器映射到其在原始 filterWeights 数组中的位置
const weightScores = {};
    
const indexes = filterWeights.map((_, idx) => idx);
3. 加权过滤器组合的计算


对于每个组合,我们计算其权重并将它的相对分数存储在 weightScores 对象中。

for (let i = 1; i <= filterWeights.length; i++) {
    const combinations = getCombinations(indexes, i);
    
    for (const filterIndexes of combinations) {
        const combinationWeight = filterIndexes.reduce((sum, idx) => sum + filterWeights[idx].weight, 0);
        weightScores[filterIndexes] = combinationWeight / totalWeight;
    }
}

4. 聚合和否定过滤器

在这里,我们形成了聚合的过滤器字符串。weightScores 中的每个组合都被处理并填充到 filterScores 列表中,以及它的相对分数。

const filterScores = [];
for (const [filterIndexes, score] of Object.entries(weightScores)) {
    let aggregatedFilter = "";
    const indexesArray = filterIndexes.split(",").map(idx => parseInt(idx));
    
    for (let i = 0; i < filterWeights.length; i++) {
        if (indexesArray.includes(i)) {
            aggregatedFilter = aggregateFilters(aggregatedFilter, filterWeights[i].filter);
        } else {
            aggregatedFilter = aggregateFilters(aggregatedFilter, negateFilter(filterWeights[i].filter));
        }
    }
    filterScores.push([aggregatedFilter, score]);
}

5. 排序和返回过滤器分数

最后,filterScores 列表根据分数以降序排列。这确保了最重要的过滤器(由权重确定)位于开头。

filterScores.sort((a, b) => b[1] - a[1]);
return filterScores;

使用过滤器提升函数

现在我们有了 boostFilter 函数,我们可以用一个示例来证明它的有效性。此函数返回一个数组数组,其中每个内部数组包含

  • 基于输入条件的组合过滤器
  • 一个分数,表示过滤器的加权重要性

当我们将我们的函数应用于一个示例时

boostFilter([["genres = Animation", 3], ["genres = Family", 1], ["release_date > 1609510226", 10]])

我们收到以下输出

[
    [
      '((genres = Animation) AND (genres = Family)) AND (release_date > 1609510226)',
      1
    ],
    [
      '((genres = Animation) AND (NOT(genres = Family))) AND (release_date > 1609510226)',
      0.9285714285714286
    ],
    [
      '((NOT(genres = Animation)) AND (genres = Family)) AND (release_date > 1609510226)',
      0.7857142857142857
    ],
    [
      '((NOT(genres = Animation)) AND (NOT(genres = Family))) AND (release_date > 1609510226)',
      0.7142857142857143
    ],
    [
      '((genres = Animation) AND (genres = Family)) AND (NOT(release_date > 1609510226))',
      0.2857142857142857
    ],
    [
      '((genres = Animation) AND (NOT(genres = Family))) AND (NOT(release_date > 1609510226))',
      0.21428571428571427
    ],
    [
      '((NOT(genres = Animation)) AND (genres = Family)) AND (NOT(release_date > 1609510226))',
      0.07142857142857142
    ]
]

从提升的过滤器生成搜索查询


现在我们有了 boostFilter 函数提供的优先排序的过滤器列表,我们可以使用它来生成搜索查询。让我们创建一个 searchBoostFilter 函数来自动生成基于提升的过滤器的搜索查询,并使用提供的 Meilisearch 客户端执行搜索查询。

async function searchBoostFilter(client, filterScores, indexUid, q) {
    const searchQueries = filterScores.map(([filter, _]) => {
        const query = { ...q };
        query.indexUid = indexUid;
        query.filter = filter;
        return query;
    });
    
    const results = await client.multiSearch({ queries: searchQueries });
    return results;
}

该函数接受以下参数

  • client:Meilisearch 客户端实例。
  • filterScores:过滤器数组及其对应分数的数组。
  • indexUid:您要搜索的索引
  • q:基本查询参数

对于 filterScores 中的每个过滤器,我们

  • 使用扩展运算符创建基本查询参数 q 的副本
  • 更新当前搜索查询的 indexUidfilter
  • 将修改后的 query 添加到我们的 searchQueries 数组中

然后,该函数从多搜索路由返回原始结果。

示例:使用过滤器分数提取热门电影

让我们创建一个函数来显示符合我们定义的搜索限制并基于我们的优先排序过滤器条件的热门电影标题:bestMoviesFromFilters 函数。

async function bestMoviesFromFilters(client, filterWeights, indexUid, q) {
    
    const filterScores = boostFilter(filterWeights);
    const results = await searchBoostFilter(client, filterScores, indexUid, q);
    const limit = results.results[0].limit;
    let hitIndex = 0;
    let filterIndex = 0;
    
    for (const resultsPerIndex of results.results) {
        if (hitIndex >= limit) {
            break;
        }
        
        const [filter, score] = filterScores[filterIndex];
        console.log(`=== filter '${filter}' | score = ${score} ===`);
        
        for (const result of resultsPerIndex.hits) {
            if (hitIndex >= limit) {
                break;
            }
            
            console.log(`${String(hitIndex).padStart(3, '0')}: ${result.title}`);
            hitIndex++;
        }
        
        filterIndex++;
    }
} 

该函数使用 boostFilter 函数来获取过滤器组合及其分数的列表。

然后,searchBoostFilter 函数获取为提供的过滤器获得的结果。
它还根据我们在基本查询中设置的限制来确定我们希望显示的最大电影标题数量。

使用循环,该函数遍历结果

  • 如果当前显示的电影标题数量 (hitIndex) 达到指定的 limit,该函数将停止处理进一步的结果。
  • 对于多搜索查询的每组结果,该函数将显示应用的过滤器条件及其分数。
  • 然后,它遍历搜索结果(或命中)并显示电影标题,直到达到 limit 或当前过滤器的所有结果都显示出来。
  • 该过程针对下一组具有不同过滤器组合的结果继续进行,直到达到总 limit 或显示所有结果。

让我们在一个示例中使用我们的新函数

bestMoviesFromFilters(client, 
    [
        { filter: "genres = Animation", weight: 3 }, 
        { filter: "genres = Family", weight: 1 }, 
        { filter: "release_date > 1609510226", weight: 10 }
    ],
    "movies", 
    { q: "Samurai", limit: 100 }
)


我们得到以下输出

=== filter '((genres = Animation) AND (genres = Family)) AND (release_date > 1609510226)' | score = 1.0 ===
000: Blazing Samurai
=== filter '((genres = Animation) AND (NOT(genres = Family))) AND (release_date > 1609510226)' | score = 0.9285714285714286 ===
=== filter '((NOT(genres = Animation)) AND (genres = Family)) AND (release_date > 1609510226)' | score = 0.7857142857142857 ===
=== filter '((NOT(genres = Animation)) AND (NOT(genres = Family))) AND (release_date > 1609510226)' | score = 0.7142857142857143 ===
=== filter '((genres = Animation) AND (genres = Family)) AND (NOT(release_date > 1609510226))' | score = 0.2857142857142857 ===
001: Scooby-Doo! and the Samurai Sword
002: Kubo and the Two Strings
=== filter '((genres = Animation) AND (NOT(genres = Family))) AND (NOT(release_date > 1609510226))' | score = 0.21428571428571427 ===
003: Samurai Jack: The Premiere Movie
004: Afro Samurai: Resurrection
005: Program
006: Lupin the Third: Goemon's Blood Spray
007: Hellboy Animated: Sword of Storms
008: Gintama: The Movie
009: Heaven's Lost Property the Movie: The Angeloid of Clockwork
010: Heaven's Lost Property Final – The Movie: Eternally My Master
=== filter '((NOT(genres = Animation)) AND (genres = Family)) AND (NOT(release_date > 1609510226))' | score = 0.07142857142857142 ===
011: Teenage Mutant Ninja Turtles III

结论

在本指南中,我们介绍了实现评分筛选功能的过程。我们了解了如何设置加权过滤器并自动生成过滤器组合,然后根据其权重对其进行评分。随后,我们探讨了如何在 Meilisearch 的多搜索 API 的帮助下使用这些提升的过滤器创建搜索查询。

我们计划将 评分过滤器 集成到 Meilisearch 引擎中。在之前的链接中提供您的反馈,以帮助我们确定其优先级。

有关更多 Meilisearch 内容,您可以订阅我们的 新闻稿。您可以通过查看 路线图 并参与我们的 产品讨论 来了解更多有关我们产品的信息。

对于其他任何事项,请加入我们在 Discord 上的开发者社区。