搜索服务 #

搜索服务概述 #

为应用添加搜索功能可以极大提升用户体验。Heroku 提供了多种搜索服务 Add-ons。

服务对比 #

服务 特点 适用场景
Elasticsearch 功能强大,自定义程度高 复杂搜索需求
Algolia 快速部署,开箱即用 即时搜索
Bonsai Elasticsearch 托管 经济实惠

Elasticsearch #

安装配置 #

bash
# 安装 Bonsai Elasticsearch
heroku addons:create bonsai:sandbox

# 查看配置
heroku config:get BONSAI_URL

Node.js 集成 #

bash
npm install @elastic/elasticsearch
javascript
const { Client } = require('@elastic/elasticsearch');

const client = new Client({
  node: process.env.BONSAI_URL
});

// 创建索引
async function createIndex() {
  await client.indices.create({
    index: 'products',
    body: {
      mappings: {
        properties: {
          name: { type: 'text' },
          description: { type: 'text' },
          price: { type: 'float' },
          category: { type: 'keyword' },
          created_at: { type: 'date' }
        }
      }
    }
  });
}

// 索引文档
async function indexProduct(product) {
  await client.index({
    index: 'products',
    id: product.id,
    body: {
      name: product.name,
      description: product.description,
      price: product.price,
      category: product.category,
      created_at: new Date()
    }
  });
}

// 批量索引
async function bulkIndex(products) {
  const body = products.flatMap(product => [
    { index: { _index: 'products', _id: product.id } },
    product
  ]);
  
  await client.bulk({ body });
}

// 搜索
async function searchProducts(query) {
  const { body } = await client.search({
    index: 'products',
    body: {
      query: {
        multi_match: {
          query: query,
          fields: ['name', 'description'],
          fuzziness: 'AUTO'
        }
      },
      highlight: {
        fields: {
          name: {},
          description: {}
        }
      }
    }
  });
  
  return body.hits.hits.map(hit => ({
    ...hit._source,
    score: hit._score,
    highlight: hit.highlight
  }));
}

// 高级搜索
async function advancedSearch(params) {
  const { query, category, minPrice, maxPrice, sort } = params;
  
  const { body } = await client.search({
    index: 'products',
    body: {
      query: {
        bool: {
          must: [
            {
              multi_match: {
                query: query,
                fields: ['name^2', 'description']
              }
            }
          ],
          filter: [
            ...(category ? [{ term: { category } }] : []),
            ...(minPrice || maxPrice ? [{
              range: {
                price: {
                  ...(minPrice && { gte: minPrice }),
                  ...(maxPrice && { lte: maxPrice })
                }
              }
            }] : [])
          ]
        }
      },
      sort: sort ? [{ [sort.field]: sort.order }] : undefined
    }
  });
  
  return body.hits.hits.map(hit => hit._source);
}

Python 集成 #

bash
pip install elasticsearch
python
from elasticsearch import Elasticsearch

es = Elasticsearch([os.environ.get('BONSAI_URL')])

# 创建索引
es.indices.create(index='products', body={
    'mappings': {
        'properties': {
            'name': {'type': 'text'},
            'description': {'type': 'text'},
            'price': {'type': 'float'}
        }
    }
})

# 索引文档
es.index(index='products', id=1, body={
    'name': 'Product Name',
    'description': 'Product Description',
    'price': 99.99
})

# 搜索
results = es.search(index='products', body={
    'query': {
        'multi_match': {
            'query': 'search term',
            'fields': ['name', 'description']
        }
    }
})

同步数据库 #

javascript
// 同步 PostgreSQL 到 Elasticsearch
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function syncProducts() {
  const { rows: products } = await pool.query('SELECT * FROM products');
  
  const body = products.flatMap(product => [
    { index: { _index: 'products', _id: product.id } },
    {
      name: product.name,
      description: product.description,
      price: parseFloat(product.price),
      category: product.category
    }
  ]);
  
  if (body.length > 0) {
    await client.bulk({ body });
  }
  
  console.log(`Synced ${products.length} products`);
}

// 增量同步
async function syncProduct(productId) {
  const { rows: [product] } = await pool.query(
    'SELECT * FROM products WHERE id = $1',
    [productId]
  );
  
  if (product) {
    await client.index({
      index: 'products',
      id: product.id,
      body: product
    });
  }
}

Algolia #

安装配置 #

bash
# 安装 Algolia
heroku addons:create algolia:community

# 查看配置
heroku config:get ALGOLIA_APP_ID
heroku config:get ALGOLIA_API_KEY

Node.js 集成 #

bash
npm install algoliasearch
javascript
const algoliasearch = require('algoliasearch');

const client = algoliasearch(
  process.env.ALGOLIA_APP_ID,
  process.env.ALGOLIA_API_KEY
);

const index = client.initIndex('products');

// 配置索引
async function configureIndex() {
  await index.setSettings({
    searchableAttributes: ['name', 'description', 'category'],
    attributesForFaceting: ['category', 'price'],
    customRanking: ['desc(popularity)', 'desc(created_at)'],
    hitsPerPage: 20
  });
}

// 添加文档
async function addProduct(product) {
  await index.saveObject({
    objectID: product.id,
    name: product.name,
    description: product.description,
    price: product.price,
    category: product.category,
    popularity: product.popularity || 0,
    created_at: new Date().toISOString()
  });
}

// 批量添加
async function addProducts(products) {
  const objects = products.map(product => ({
    objectID: product.id,
    ...product
  }));
  
  await index.saveObjects(objects);
}

// 搜索
async function searchProducts(query, options = {}) {
  const { hits, nbHits, page, nbPages } = await index.search(query, {
    page: options.page || 0,
    hitsPerPage: options.hitsPerPage || 20,
    facets: options.facets || ['category'],
    facetFilters: options.facetFilters
  });
  
  return {
    results: hits,
    total: nbHits,
    page,
    totalPages: nbPages
  };
}

// 分面搜索
async function searchWithFacets(query, filters) {
  const { hits, facets } = await index.search(query, {
    facets: ['category', 'price'],
    facetFilters: filters
  });
  
  return {
    results: hits,
    facets
  };
}

// 删除文档
async function deleteProduct(productId) {
  await index.deleteObject(productId);
}

// 部分更新
async function updateProduct(productId, updates) {
  await index.partialUpdateObject({
    objectID: productId,
    ...updates
  });
}

Python 集成 #

bash
pip install algoliasearch
python
from algoliasearch.search_client import SearchClient

client = SearchClient.create(
    os.environ.get('ALGOLIA_APP_ID'),
    os.environ.get('ALGOLIA_API_KEY')
)

index = client.init_index('products')

# 添加文档
index.save_object({
    'objectID': 1,
    'name': 'Product Name',
    'description': 'Product Description',
    'price': 99.99
})

# 搜索
results = index.search('query', {
    'page': 0,
    'hitsPerPage': 20
})

前端集成 #

html
<!-- 即时搜索 UI -->
<script src="https://cdn.jsdelivr.net/npm/algoliasearch@4/dist/algoliasearch-lite.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4"></script>

<div id="searchbox"></div>
<div id="hits"></div>
<div id="pagination"></div>

<script>
const search = instantsearch({
  indexName: 'products',
  searchClient: algoliasearch('APP_ID', 'SEARCH_ONLY_API_KEY')
});

search.addWidgets([
  instantsearch.widgets.searchBox({
    container: '#searchbox'
  }),
  
  instantsearch.widgets.hits({
    container: '#hits',
    templates: {
      item: `
        <div>
          <h3>{{name}}</h3>
          <p>{{description}}</p>
          <span>${{price}}</span>
        </div>
      `
    }
  }),
  
  instantsearch.widgets.pagination({
    container: '#pagination'
  })
]);

search.start();
</script>

同步策略 #

javascript
// 实时同步
async function onProductChange(change) {
  const product = change.after.data();
  
  if (!change.after.exists) {
    await index.deleteObject(change.before.id);
  } else {
    await index.saveObject({
      objectID: change.after.id,
      ...product
    });
  }
}

// 定时全量同步
const cron = require('node-cron');

cron.schedule('0 3 * * *', async () => {
  console.log('Starting full sync...');
  await syncAllProducts();
});

搜索功能 #

自动补全 #

javascript
// Elasticsearch Suggest
async function getSuggestions(query) {
  const { body } = await client.search({
    index: 'products',
    body: {
      suggest: {
        product_suggest: {
          prefix: query,
          completion: {
            field: 'suggest',
            size: 10
          }
        }
      }
    }
  });
  
  return body.suggest.product_suggest[0].options;
}

// Algolia
const index = client.initIndex('products');
index.setSettings({
  searchableAttributes: ['name', 'category']
});

// 前端自动补全
search.addWidgets([
  instantsearch.widgets.autocomplete({
    container: '#autocomplete',
    placeholder: 'Search products'
  })
]);

拼写纠错 #

javascript
// Elasticsearch
async function searchWithSpellCheck(query) {
  const { body } = await client.search({
    index: 'products',
    body: {
      query: {
        match: {
          name: {
            query: query,
            fuzziness: 'AUTO'
          }
        }
      },
      suggest: {
        text: query,
        simple_phrase: {
          phrase: {
            field: 'name.trigram',
            size: 1
          }
        }
      }
    }
  });
  
  return {
    results: body.hits.hits,
    suggestion: body.suggest.simple_phrase[0].options[0]?.text
  };
}

高亮显示 #

javascript
// Elasticsearch
async function searchWithHighlight(query) {
  const { body } = await client.search({
    index: 'products',
    body: {
      query: {
        multi_match: {
          query: query,
          fields: ['name', 'description']
        }
      },
      highlight: {
        pre_tags: ['<mark>'],
        post_tags: ['</mark>'],
        fields: {
          name: {},
          description: {}
        }
      }
    }
  });
  
  return body.hits.hits.map(hit => ({
    ...hit._source,
    highlight: hit.highlight
  }));
}

性能优化 #

索引优化 #

javascript
// Elasticsearch 索引设置
await client.indices.create({
  index: 'products',
  body: {
    settings: {
      number_of_shards: 3,
      number_of_replicas: 1,
      analysis: {
        analyzer: {
          product_analyzer: {
            type: 'custom',
            tokenizer: 'standard',
            filter: ['lowercase', 'stemmer']
          }
        }
      }
    }
  }
});

// Algolia 设置
await index.setSettings({
  searchableAttributes: ['name', 'description'],
  customRanking: ['desc(popularity)'],
  typoTolerance: true,
  ignorePlurals: true
});

缓存策略 #

javascript
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300 });

async function cachedSearch(query) {
  const cacheKey = `search:${query}`;
  const cached = cache.get(cacheKey);
  
  if (cached) {
    return cached;
  }
  
  const results = await searchProducts(query);
  cache.set(cacheKey, results);
  
  return results;
}

最佳实践 #

1. 索引设计 #

javascript
// 合理设计索引结构
const productMapping = {
  name: {
    type: 'text',
    analyzer: 'standard',
    fields: {
      keyword: { type: 'keyword' },
      suggest: { type: 'completion' }
    }
  },
  description: { type: 'text' },
  price: { type: 'float' },
  category: { type: 'keyword' },
  tags: { type: 'keyword' },
  created_at: { type: 'date' }
};

2. 搜索体验 #

javascript
// 提供多种搜索选项
async function enhancedSearch(params) {
  const { query, filters, sort, page } = params;
  
  return await index.search(query, {
    page,
    hitsPerPage: 20,
    facetFilters: filters,
    sort,
    attributesToHighlight: ['name', 'description'],
    attributesToRetrieve: ['name', 'description', 'price', 'image']
  });
}

3. 监控搜索 #

javascript
// 记录搜索日志
async function logSearch(query, results, userId) {
  await db.query(
    'INSERT INTO search_logs (query, results_count, user_id, created_at) VALUES ($1, $2, $3, $4)',
    [query, results.length, userId, new Date()]
  );
}

// 分析搜索数据
async function getSearchAnalytics() {
  const { rows } = await db.query(`
    SELECT query, COUNT(*) as count
    FROM search_logs
    WHERE created_at > NOW() - INTERVAL '7 days'
    GROUP BY query
    ORDER BY count DESC
    LIMIT 20
  `);
  
  return rows;
}

故障排查 #

搜索无结果 #

bash
# 检查索引状态
heroku run "curl $BONSAI_URL/_cat/indices?v"

# 检查文档数量
heroku run "curl $BONSAI_URL/products/_count"

# 检查映射
heroku run "curl $BONSAI_URL/products/_mapping"

性能问题 #

bash
# 检查索引大小
heroku run "curl $BONSAI_URL/_cat/indices?v&h=index,store.size,docs.count"

# 检查搜索性能
heroku run "curl -X GET '$BONSAI_URL/products/_search?pretty' -H 'Content-Type: application/json' -d '{\"query\":{\"match_all\":{}},\"profile\":true}'"

下一步 #

搜索服务掌握后,接下来学习 自定义域名 了解域名配置!

最后更新:2026-03-28