Somebody's card gets declined. They open your help center and type "my money disappeared". Your article is called "Troubleshooting failed payments". Not one word in common, so search returns nothing and they write to support instead.
Keyword search only finds what you already named correctly. Semantic search fixes that, and then misses the other way: ask it for the error code NB-2001 and it hands you something thematically close instead of the page with that string in it. Hybrid runs both and merges them, so exact matches take the top and meaning fills in below.
The usual price for that is a vector database, an embedding pipeline and a sync job between your content and your index. Here is the version with none of those. It took an afternoon and cost nothing.
Quick disclosure: I built FoxNose, and Loquix is mine too. Everything below runs on a free account, and there is a short list at the end of cases where you should use something else.
The two pieces
FoxNose stores the content and does the search. Mark a field searchable and vectorizable and every record you publish is indexed for keywords and vectors. Hybrid search is on the free tier, and for a small product the free tier is enough to just run this.
Loquix is MIT web components, including a <loquix-search-dialog> you can drop in. Plain custom elements, so React, Vue or nothing at all.
The browser calls the search API directly. No backend of yours in the middle.
1. Describe the content
Sign up and create a project. FoxNose provisions one environment for it, Production, and you can add more later for staging or previews. An environment is a separate database with its own API host at https://{environment_key}.fxns.io. Note that key down, you need it in a minute.
Inside the environment, create a collection. Mine is called articles. Then give it a schema in the visual editor: fields, types, flags, publish. Schema versions are drafts until you publish them, so there is no way to half-apply a change.
I used a help center for a fictional product, 24 articles:
| Field | Type | Flags |
|---|---|---|
title |
text | searchable, vectorizable |
slug |
string | - |
summary |
text | searchable, vectorizable |
body |
text | searchable, vectorizable |
category |
string | searchable |
That is the whole search setup. searchable indexes the field for querying, vectorizable means embeddings get generated on every publish. No embedding code, nothing to keep in sync, no index to create.
Two things worth knowing before you load anything. Full-text search lives on text fields, not string, so anything people will actually search inside wants to be text. And a field's type cannot be edited later, only recreated: you remove the field and add a new one in its place, then load the content again. Cheap now, annoying after you have data.
2. Load the content
npm install @foxnose/sdk
Three values go into your .env here: the environment key from a minute ago, the collection key, and a management API key you create in the environment settings.
// seed.js
import fs from 'node:fs';
import { ManagementClient, SimpleKeyAuth } from '@foxnose/sdk';
const articles = JSON.parse(
fs.readFileSync(new URL('./articles.json', import.meta.url), 'utf8'),
);
const client = new ManagementClient({
environmentKey: process.env.FOXNOSE_ENV_KEY,
auth: new SimpleKeyAuth(
process.env.FOXNOSE_MGMT_PUBLIC_KEY,
process.env.FOXNOSE_MGMT_SECRET_KEY,
),
});
await client.batchUpsertResources(
process.env.FOXNOSE_COLLECTION_KEY,
articles.map((article) => ({
external_id: article.slug,
payload: { data: article, name: article.title },
})),
);
client.close();
Run it with node --env-file=.env seed.js on Node 20.6 or newer, so you do not need dotenv for this.
external_id makes it an upsert, so you can re-run this as often as you like. Each write validates the record, generates the embeddings, indexes it and publishes it.
You can also just type content into the visual editor, which is the point for a real product: support people edit help articles without a deploy.
3. Put the search online
Content is served through what FoxNose calls a Flux API. Create one, give it a prefix, connect the collection. Three settings matter for calling it from a browser:
- connect the collection with both
get_manyandget_one, or results come back without content - make the API public, meaning anonymous reads (writes still need a key)
- turn on Allow all origins so the API accepts calls from a browser at all (fine here, these articles are public anyway)
Now you have an endpoint:
https://{environment_key}.fxns.io/help-center/articles/_search
No key in your frontend, no proxy to hide that key.
4. Search
// flux.js
const BASE = 'https://YOUR_ENV_KEY.fxns.io/help-center';
export async function hybridSearch(query) {
const response = await fetch(`${BASE}/articles/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000),
body: JSON.stringify({
search_mode: 'hybrid',
find_text: {
query,
fields: ['title', 'summary', 'body'],
threshold: 0.85,
},
vector_search: {
query,
fields: ['title', 'body'],
top_k: 25,
similarity_threshold: 0.6,
},
hybrid_config: {
vector_weight: 0.4,
text_weight: 0.6,
rerank_results: true,
},
limit: 25,
}),
});
if (!response.ok) {
throw new Error(`Search failed: ${response.status} ${await response.text()}`);
}
return (await response.json()).results;
}
One request, two ranked lists, one answer. NB-2001 returns the error codes page. "my money disappeared" returns the payments article. search_mode also takes text or vector if you ever want one side on its own.
threshold: 0.85 is your typo tolerance: at 1.0 matching is exact, lower is fuzzier, so "pyament declined" still lands.
Then there are two numbers that decide how good this feels, and neither has a universally right value:
similarity_threshold is the floor for "close enough" on the semantic side. Every document is a little bit similar to every query, so this is where you cut. Too low and a question about baking bread returns your billing articles. Too high and paraphrases stop working. 0.6 fits these 24 articles.
The weights settle words against meaning. Text-heavy works for a help center, where people search for strings that literally exist: error codes, plan names, button labels. For a product catalog, where nobody knows your wording, push the vector side up.
There is no magic here, which I think is the good news. It is arithmetic over two ranked lists. Load your own content, throw your real queries at it, move the two numbers, look at what comes back. Twenty minutes of that beats any default.
Filters live next to all this: where takes typed operators over any searchable field, combined with all_of and any_of.
5. The dialog
npm install @loquix/core lit
<loquix-search-dialog
id="search"
heading="Search help center"
placeholder="Search help articles..."
kbd="⌘K"
mode="plain"
hide-answer
></loquix-search-dialog>
mode="plain" keeps it a search box and hide-answer drops the LLM answer block, which is a different article. You get a real modal <dialog> with a focus trap, keyboard navigation, loading states and a chip row, none of which you write.
It knows nothing about any backend. It fires events and takes plain arrays:
import '@loquix/core/tokens/variables.css';
import '@loquix/core/define/define-search-dialog';
import { hybridSearch } from './flux.js';
const dialog = document.getElementById('search');
const CATEGORIES = [
{ id: 'getting-started', name: 'Getting started' },
{ id: 'billing', name: 'Billing' },
{ id: 'account', name: 'Account' },
{ id: 'api', name: 'API' },
];
let hits = [];
let category = 'all';
function render() {
const shown = category === 'all'
? hits
: hits.filter((hit) => hit.data.category === category);
dialog.results = shown.map((hit) => ({
id: hit._sys.key,
title: hit.data.title,
snippet: hit.data.summary,
url: `/help/${hit.data.slug}`,
source: CATEGORIES.find((c) => c.id === hit.data.category),
}));
dialog.sources = CATEGORIES
.map((c) => ({ ...c, count: hits.filter((h) => h.data.category === c.id).length }))
.filter((c) => c.count > 0);
dialog.activeSource = category;
}
async function runSearch(query) {
if (query.trim().length < 2) {
hits = [];
dialog.results = [];
dialog.sources = [];
return;
}
dialog.state = 'searching';
try {
hits = await hybridSearch(query);
category = 'all'; // a new query starts unfiltered
render();
} catch (error) {
console.error(error);
hits = [];
dialog.sources = [];
dialog.results = [{ id: 'notice', title: 'Service is too busy. Try again.' }];
} finally {
dialog.state = 'idle';
}
}
let timer;
dialog.addEventListener('loquix-change', (e) => {
clearTimeout(timer);
timer = setTimeout(() => runSearch(e.detail.value), 250);
});
// Enter fires submit, Cmd+Enter fires ask even in plain mode, so wire both
dialog.addEventListener('loquix-search-submit', (e) => runSearch(e.detail.query));
dialog.addEventListener('loquix-search-ask', (e) => runSearch(e.detail.query));
dialog.addEventListener('loquix-search-source-select', (e) => {
category = e.detail.sourceId;
render();
});
window.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
dialog.show();
}
});
Three notes on that, because they are easy to miss:
.results and .sources are JavaScript properties, not attributes. Setting them in HTML does nothing.
Debounce is yours to add. Every keystroke would otherwise be its own request, and one request counts the same whether it returns one result or twenty five.
The chips filter what you already fetched, so their counts and their clicks describe the same set of results. No extra request, instant. The same strip is built for a bigger job: give each kind of content its own collection, fire one query per collection in parallel, and each chip becomes a source with its own count as the answers arrive.
React users: @loquix/react wraps every component, events arrive as props like onSearchSubmit. Both packages ship types.
What this is, and where it stops
Worth being straight about the shape of it: FoxNose is not a search engine. It is a typed content database built for LLM agents and RAG, with hybrid search included, which is why one API holds your records and retrieves them and there is nothing to sync in between.
A product that does search and only search goes further in specific places: merchandising rules, search analytics, highlighted snippets, facet counts across a whole corpus, US data residency, edge latency at very large scale. All real features, and if your product lives on one of them, use Algolia. For most teams shipping a help center or in-app search, none of them come up in a normal month.
If you would rather run infrastructure, Typesense and Meilisearch are excellent and free at any volume.
Take it
The whole thing is in one repo: https://github.com/loookashow/hybrid-search-help-center. Copy .env.example, add your keys, then npm install, npm run seed, npm run dev.
Two flags on a schema, one fetch, one component, two numbers to tune. Next time I will put a language model on top of the same API so the box can answer in sentences and cite the articles it used.
Go type "my money disappeared" into your own product's search. Curious what comes back.
Top comments (0)