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);
| Replace | With |
|---|---|
YOUR_EMAIL | The email of your Opensolr account. |
YOUR_API_KEY | Your 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_INDEX | The name of your Opensolr Index. |
YOUR_INDEX_HOST | The 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.