分面搜索 #

什么是分面搜索? #

分面搜索(Faceted Search)允许用户通过多个维度(分面)来筛选搜索结果,常见于电商网站的筛选功能。

text
┌─────────────────────────────────────────────────────────────┐
│  搜索结果: iPhone                                            │
├─────────────────────────────────────────────────────────────┤
│  筛选条件:                                                   │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │ 品牌        │  │ 价格        │  │ 颜色        │         │
│  │ ☑ Apple(50) │  │ □ 0-500(20) │  │ ☑ 黑色(30)  │         │
│  │ □ Samsung   │  │ ☑ 500-1000  │  │ □ 白色(15)  │         │
│  │ □ Huawei    │  │ □ 1000+     │  │ □ 蓝色(5)   │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
├─────────────────────────────────────────────────────────────┤
│  搜索结果列表...                                             │
└─────────────────────────────────────────────────────────────┘

配置分面 #

设置分面属性 #

javascript
await index.setSettings({
  attributesForFaceting: [
    'brand',              // 品牌分面
    'category',           // 分类分面
    'color',              // 颜色分面
    'price',              // 价格分面
    'inStock',            // 库存分面
    'searchable(tags)'    // 可搜索的标签分面
  ]
});

分面类型 #

类型 配置 用途
普通分面 'brand' 筛选和统计
仅筛选 'filterOnly(brand)' 仅用于筛选,不统计
可搜索 'searchable(brand)' 支持搜索分面值

获取分面数据 #

基本分面查询 #

javascript
const results = await index.search('iphone', {
  facets: ['brand', 'category', 'color']
});

console.log(results.facets);

分面结果结构 #

javascript
{
  "facets": {
    "brand": {
      "Apple": 150,
      "Samsung": 80,
      "Huawei": 30
    },
    "category": {
      "Smartphones": 200,
      "Tablets": 50,
      "Accessories": 10
    },
    "color": {
      "Black": 100,
      "White": 80,
      "Blue": 50
    }
  },
  "facets_stats": {
    "price": {
      "min": 99,
      "max": 1499,
      "avg": 599.5,
      "sum": 599500
    }
  }
}

获取所有分面值 #

javascript
const results = await index.search('', {
  facets: ['brand'],
  maxValuesPerFacet: 100  // 每个分面最多返回100个值
});

分面过滤 #

单选分面 #

javascript
// 选择单个品牌
const results = await index.search('iphone', {
  facets: ['brand', 'category'],
  facetFilters: ['brand:Apple']
});

多选分面(AND关系) #

javascript
// 同时选择品牌和分类
const results = await index.search('iphone', {
  facets: ['brand', 'category'],
  facetFilters: [
    'brand:Apple',
    'category:Smartphones'
  ]
});

同一分面多选(OR关系) #

javascript
// 选择多个品牌
const results = await index.search('iphone', {
  facets: ['brand', 'category'],
  facetFilters: [
    ['brand:Apple', 'brand:Samsung']  // 数组内是OR关系
  ]
});

组合使用 #

javascript
// 复杂组合
const results = await index.search('phone', {
  facets: ['brand', 'category', 'color', 'price_range'],
  facetFilters: [
    ['brand:Apple', 'brand:Samsung'],  // Apple或Samsung
    'category:Smartphones',             // 且是手机
    'inStock:true'                      // 且有库存
  ]
});

数值分面 #

价格区间 #

javascript
// 数据准备
{
  "price": 999,
  "price_range": "500-1000"  // 预计算价格区间
}

// 搜索
const results = await index.search('', {
  facets: ['price_range'],
  facetFilters: ['price_range:500-1000']
});

数值统计 #

javascript
const results = await index.search('', {
  facets: ['price']
});

// 获取价格统计
const priceStats = results.facets_stats.price;
console.log(`最低价: $${priceStats.min}`);
console.log(`最高价: $${priceStats.max}`);
console.log(`平均价: $${priceStats.avg}`);

数值过滤 #

javascript
// 使用filters进行数值过滤
const results = await index.search('', {
  facets: ['brand', 'category'],
  filters: 'price >= 500 AND price <= 1000'
});

分面搜索 #

搜索分面值 #

javascript
// 搜索品牌分面中包含"Ap"的值
const result = await index.searchForFacetValues('brand', 'Ap');

console.log(result.facetHits);
// [
//   { value: "Apple", count: 150, highlighted: "<em>Ap</em>ple" },
//   { value: "Apex", count: 20, highlighted: "<em>Ap</em>ex" }
// ]

带过滤的分面搜索 #

javascript
// 在特定条件下搜索分面值
const result = await index.searchForFacetValues('brand', 's', {
  filters: 'category:Smartphones',
  maxFacetHits: 10
});

分面UI实现 #

分面组件 #

javascript
class FacetWidget {
  constructor(attribute, container) {
    this.attribute = attribute;
    this.container = container;
    this.selected = [];
  }
  
  render(facetData) {
    const html = Object.entries(facetData)
      .map(([value, count]) => `
        <label class="facet-item">
          <input type="checkbox" 
                 value="${value}"
                 ${this.selected.includes(value) ? 'checked' : ''}>
          <span>${value}</span>
          <span class="count">(${count})</span>
        </label>
      `)
      .join('');
    
    this.container.innerHTML = html;
    this.bindEvents();
  }
  
  bindEvents() {
    this.container.addEventListener('change', (e) => {
      const value = e.target.value;
      
      if (e.target.checked) {
        this.selected.push(value);
      } else {
        this.selected = this.selected.filter(v => v !== value);
      }
      
      this.onChange(this.selected);
    });
  }
  
  onChange(selected) {
    // 由外部实现
  }
  
  getFilters() {
    if (this.selected.length === 0) return null;
    return this.selected.map(v => `${this.attribute}:${v}`);
  }
}

分面管理器 #

javascript
class FacetManager {
  constructor(index) {
    this.index = index;
    this.facets = {};
    this.selectedFacets = {};
  }
  
  registerFacet(attribute, options = {}) {
    this.facets[attribute] = {
      attribute,
      type: options.type || 'disjunctive',  // conjunctive 或 disjunctive
      selected: []
    };
  }
  
  selectFacet(attribute, value) {
    const facet = this.facets[attribute];
    if (!facet) return;
    
    if (facet.type === 'conjunctive') {
      facet.selected = [value];
    } else {
      if (!facet.selected.includes(value)) {
        facet.selected.push(value);
      }
    }
  }
  
  deselectFacet(attribute, value) {
    const facet = this.facets[attribute];
    if (!facet) return;
    
    facet.selected = facet.selected.filter(v => v !== value);
  }
  
  buildFacetFilters() {
    const filters = [];
    
    for (const [attribute, facet] of Object.entries(this.facets)) {
      if (facet.selected.length === 0) continue;
      
      if (facet.type === 'conjunctive') {
        filters.push(`${attribute}:${facet.selected[0]}`);
      } else {
        filters.push(facet.selected.map(v => `${attribute}:${v}`));
      }
    }
    
    return filters;
  }
  
  async search(query) {
    const facetFilters = this.buildFacetFilters();
    const facetAttributes = Object.keys(this.facets);
    
    return await this.index.search(query, {
      facets: facetAttributes,
      facetFilters: facetFilters.length > 0 ? facetFilters : undefined
    });
  }
}

// 使用
const manager = new FacetManager(index);
manager.registerFacet('brand', { type: 'disjunctive' });
manager.registerFacet('category', { type: 'conjunctive' });
manager.registerFacet('price_range', { type: 'disjunctive' });

manager.selectFacet('brand', 'Apple');
const results = await manager.search('phone');

分面类型 #

合取分面(Conjunctive) #

AND关系,选择多个值时结果必须同时满足:

javascript
// 选择 "Smartphones" 和 "Tablets"
// 结果必须同时属于两个分类(通常无结果)
// 一般用于单选场景
facetFilters: ['category:Smartphones']

析取分面(Disjunctive) #

OR关系,选择多个值时结果满足任一即可:

javascript
// 选择 "Apple" 和 "Samsung"
// 结果可以是Apple或Samsung
facetFilters: [['brand:Apple', 'brand:Samsung']]

分面计数 #

独立计数 #

当选择一个分面时,其他分面的计数应该反映未选择状态:

javascript
async function getDisjunctiveFacets(query, selectedFacets) {
  const baseParams = {
    query,
    facets: ['brand', 'category', 'color']
  };
  
  // 主查询
  const mainResults = await index.search({
    ...baseParams,
    facetFilters: buildFacetFilters(selectedFacets)
  });
  
  // 为每个未选择的分面单独查询
  const disjunctiveQueries = Object.keys(baseParams.facets)
    .filter(facet => !selectedFacets[facet])
    .map(facet => ({
      ...baseParams,
      facetFilters: buildFacetFilters(selectedFacets, facet)
    }));
  
  const disjunctiveResults = await Promise.all(
    disjunctiveQueries.map(params => index.search(params))
  );
  
  // 合并结果
  return mergeFacetResults(mainResults, disjunctiveResults);
}

高级分面 #

层级分面 #

javascript
// 数据结构
{
  "category": "Electronics",
  "category.l1": "Electronics",
  "category.l2": "Electronics > Phones",
  "category.l3": "Electronics > Phones > Smartphones"
}

// 搜索
const results = await index.search('', {
  facets: ['category.l1', 'category.l2', 'category.l3'],
  facetFilters: ['category.l2:Electronics > Phones']
});

动态分面 #

javascript
// 根据搜索结果动态显示分面
async function getDynamicFacets(query) {
  const results = await index.search(query, {
    facets: ['*']  // 获取所有分面
  });
  
  // 只显示有数据的分面
  const activeFacets = Object.entries(results.facets)
    .filter(([_, values]) => Object.keys(values).length > 1)
    .map(([name]) => name);
  
  return activeFacets;
}

分面最佳实践 #

1. 预计算区间 #

javascript
// ❌ 不推荐:运行时计算
filters: 'price >= 500 AND price < 1000'

// ✅ 推荐:预计算区间
{
  "price": 750,
  "price_range": "500-1000"
}
facetFilters: ['price_range:500-1000']

2. 限制分面数量 #

javascript
await index.setSettings({
  maxValuesPerFacet: 50  // 每个分面最多50个值
});

3. 使用filterOnly #

javascript
// 不需要统计的分面使用filterOnly
attributesForFaceting: [
  'brand',
  'category',
  'filterOnly(internal_id)'  // 仅用于筛选
]

4. 分面排序 #

javascript
// 按计数排序(默认)
// 或按字母排序
await index.setSettings({
  sortFacetValuesBy: 'alpha'  // 或 'count'
});

分面示例 #

电商筛选 #

javascript
async function ecommerceSearch(query, filters = {}) {
  const { brands = [], categories = [], priceRanges = [], page = 0 } = filters;
  
  const facetFilters = [];
  
  if (brands.length > 0) {
    facetFilters.push(brands.map(b => `brand:${b}`));
  }
  
  if (categories.length > 0) {
    facetFilters.push(categories.map(c => `category:${c}`));
  }
  
  if (priceRanges.length > 0) {
    facetFilters.push(priceRanges.map(p => `price_range:${p}`));
  }
  
  return await index.search(query, {
    facets: ['brand', 'category', 'price_range', 'color', 'size'],
    facetFilters: facetFilters.length > 0 ? facetFilters : undefined,
    page,
    hitsPerPage: 20,
    maxValuesPerFacet: 100
  });
}

总结 #

分面搜索要点:

要点 说明
配置 attributesForFaceting
获取 facets参数
过滤 facetFilters参数
类型 合取(AND)、析取(OR)
数值 facets_stats统计
搜索 searchForFacetValues

接下来,让我们学习 过滤与筛选

最后更新:2026-03-28