Hybrid Search Example in Node.js

Code Examples

A small Node.js server with no packages to install. Start it with node server.mjs and call http://localhost:3000/search?q=solar+panels&page=1&lang=en. It runs the flow of Build your own search UI: the meaning vector of the search, one {!hybrid} request to Solr, and words alone when there is no vector. It answers the JSON of Solr with highlights (hl.encoder=html), language facets and Did you mean.

Keep the API key and the password of the index on your server. A web page shows its JavaScript to every visitor, and the password of a new index is your API key: the username and password of your index.
// server.mjs : node server.mjs, then http://localhost:3000/search?q=solar+panels&page=1&lang=en
// Needs Node.js 18 or newer (built-in fetch). No packages to install.
import http from 'node:http';

const CONFIG = {
  email: 'YOUR_EMAIL',
  apiKey: 'YOUR_API_KEY',
  indexName: 'YOUR_INDEX',
  indexUrl: 'https://YOUR_INDEX_HOST/solr/YOUR_INDEX/select',
  indexUser: 'opensolr',
  indexPassword: 'YOUR_API_KEY',
  rows: 10,
};
const QF = 'title^0.2 description^0.05 uri^0.01 text^0.01 text_t^0.01';
const MM = '2<65% 4<50% 8<40%';

// Step 1: the meaning vector of the search, or null when there is none
async function embedQuery(text) {
  if (text.length < 2) return null;
  const url = 'https://api.opensolr.com/solr_manager/api/embed?' + new URLSearchParams({
    email: CONFIG.email,
    api_key: CONFIG.apiKey,
    index_name: CONFIG.indexName,
    is_query: '1',
    payload: text,
  });
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
    if (!res.ok) return null;
    const vector = await res.json();
    return Array.isArray(vector) && vector.length === 1024 ? vector.map(Number) : null;
  } catch {
    return null;
  }
}

// Step 2: one Solr request, hybrid when there is a vector, words only when there is not
async function search(text, page, lang) {
  const lexical = '{!edismax qf="' + QF + '" mm="' + MM + '" v=$uq}';
  const p = new URLSearchParams();
  p.append('uq', text);
  p.append('fl', 'id,uri,title,description,og_image,creation_date,score');
  p.append('rows', String(CONFIG.rows));
  p.append('start', String((page - 1) * CONFIG.rows));
  p.append('fq', 'content_type:text*');
  if (lang) p.append('fq', '{!tag=lang}{!term f=meta_detected_language}' + lang);
  p.append('hl', 'true');
  p.append('hl.method', 'unified');
  p.append('hl.encoder', 'html');
  p.append('hl.q', '{!edismax qf="title description uri text" mm=0 v=$uq}');
  p.append('hl.fl', 'title,description,text');
  p.append('hl.fragsize', '300');
  p.append('hl.snippets', '1');
  p.append('hl.defaultSummary', 'true');
  p.append('f.title.hl.fragsize', '0');
  p.append('facet', 'true');
  p.append('facet.field', '{!ex=lang}meta_detected_language');
  p.append('facet.mincount', '1');
  p.append('spellcheck', 'true');
  p.append('spellcheck.q', text);
  p.append('spellcheck.count', '5');
  p.append('spellcheck.collate', 'true');
  p.append('spellcheck.maxCollations', '3');
  p.append('spellcheck.maxCollationTries', '15');
  p.append('echoParams', 'none');
  p.append('wt', 'json');

  const vector = await embedQuery(text);
  if (vector) {
    p.append('q', '{!hybrid lexical=$lexicalQuery vector=$vectorQuery mode=union alpha=0.85 topN=500}');
    p.append('lexicalQuery', lexical);
    p.append('vectorQuery', '{!knn f=embeddings topK=500}[' + vector.join(',') + ']');
  } else {
    p.append('q', lexical);
  }

  // POST, because the vector makes the request too long for a URL
  const res = await fetch(CONFIG.indexUrl, {
    method: 'POST',
    headers: {
      Authorization: 'Basic ' + Buffer.from(CONFIG.indexUser + ':' + CONFIG.indexPassword).toString('base64'),
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: p,
    signal: AbortSignal.timeout(10000),
  });
  if (!res.ok) throw new Error('Solr answered ' + res.status);
  return res.json();
}

http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  res.setHeader('Content-Type', 'application/json');
  if (url.pathname !== '/search') {
    res.writeHead(404).end(JSON.stringify({ error: 'Not found' }));
    return;
  }
  const text = (url.searchParams.get('q') || '').trim().slice(0, 200);
  const page = Math.min(100, Math.max(1, parseInt(url.searchParams.get('page'), 10) || 1));
  const langParam = url.searchParams.get('lang') || '';
  const lang = /^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i.test(langParam) ? langParam : '';
  if (!text) {
    res.writeHead(400).end(JSON.stringify({ error: 'Missing q' }));
    return;
  }
  try {
    res.end(JSON.stringify(await search(text, page, lang)));
  } catch {
    res.writeHead(502).end(JSON.stringify({ error: 'Search failed' }));
  }
}).listen(3000);
ReplaceWith
YOUR_EMAILThe email of your Opensolr account.
YOUR_API_KEYYour API key (Account › API Keys). The examples also use it as the password of the index, which is what a new index starts with; if you changed the username or password of the index, put yours: the username and password of your index.
YOUR_INDEXThe name of your Opensolr Index.
YOUR_INDEX_HOSTThe host shown in the Control Panel of the index.

The field weights, mm and alpha are the defaults; the Query Inspector of your hosted search page shows the values of your own index. The complete search page shows the results in a browser.