Revised FlexSearch worker
Revised FlexSearch worker
This document describes the current search implementation in src/scripts/search-worker.js.
The problem this revision fixes
The FlexSearch docs discuss a tag property that names a property that can later be used to constrain the search to that property.
As I understand it, the tag property in the FlexSearch index defintion below identifies the folder property as search constraint.
index = new Document({
document: {
id: "id",
store: ["id", "title", "url", "tags", "folder", "description"],
//store: true,
index: ["title", "url", "content", "tags", "description"],
tag: "folder",
},
tokenize: "forward",
});
At runtime, if the search options argument includes a tag property, that property should apply the constraint to the search results--searching only index objects where the folder property is post.
const rawSearchResult = index.search(searchTerm, {
tag: { folder: 'post' },
enrich: true,
limit: 20,
});
If it worked, this feature makes for a very clean and direct way to filter search results (within the search itself). Alas, I'm either doing something or this feature doesn't work, because it worked only intermittently for me.
The revised search function
The revised search function is at the end of this article.
This revision's swindle is that it searches unconditionally across all search objects and then filters the search results to include only those optional values specified.
Beware that the FlexSearch search limit should probably be a little higher than it needs to be if optional search values are used. Consider a search limit of 20. Whatever search results removed by the after-search filtering come of out that 20, so there may be way less than 20, even zero, results to show.
The other downside is that this version is just butt ugly. Its use of flatMap, reduce, and filter are challenging to quickly grasp.
The revised function supports three behaviors:
- Search across indexed content with FlexSearch
Document. - Optionally constrain matches to one indexed field (
title,content, ortags). - Optionally constrain results to a folder (
postorlink).
It also deduplicates results by document id and adds metadata (sourceFields) indicating which indexed field(s) matched.
Inputs
search(payload) expects:
payload.query: search text.payload.property_constraint: one ofall,title,content,tags.payload.folder_constraint: one ofall,post,posts,link,links.
Helper Functions
normalizeFolderConstraint(folder)
Normalizes folder values from UI controls:
all(or empty) ->null(no folder constraint)posts->postlinks->link- otherwise lowercased value
groupByDocId(results)
Takes FlexSearch enriched field-grouped results and returns a map of:
- key:
doc.id - value: array of field names that matched (
title,content,tags, etc.)
This is later written to each result as sourceFields.
Search Flow
1. Normalize constraints
The function extracts and normalizes constraints:
- folder ->
normalizedFolderConstraint - property ->
normalizedPropertyConstraint
2. Run one FlexSearch query
It runs:
index.search(searchTerm, { enrich: true, limit: 20 })
This returns grouped results by indexed field.
3. Apply property constraint (field scope)
If property_constraint is one of allowed fields (title, content, tags), it keeps only the matching field group from the FlexSearch response.
If property is all (or invalid), it keeps all field groups.
4. Build source-field map
groupByDocId(scopedSearchResult) builds a lookup of matching fields per doc id.
5. Flatten + dedupe
The function flattens all matches into one list and deduplicates by id.
For each unique doc:
- adds
sourceFields(from the lookup) - pushes the enriched
docinto final results
6. Apply folder constraint
Finally, it filters deduped docs by folder when requested:
- no folder constraint -> all docs pass
- folder constraint (
postorlink) -> only docs with matchingdoc.folder
Why Folder Filtering Is Post-Search
The current implementation intentionally applies folder filtering in JavaScript after enrichment/dedupe.
Reason: in this runtime, FlexSearch tag filtering did not return expected matches for this dataset/configuration. The post-filter path is deterministic and currently correct.
Output Shape
Returns an array of enriched doc objects from store:
idtitleurltagsfoldersourceFields(added by this function)
Example Behavior (query = "json")
With current data:
- folder
all+ propertyall-> 6 hits - folder
post+ propertyall-> 5 hits - folder
link+ propertyall-> 1 hit
And property constraints narrow within those folder results (for example, tags only).
The revised search function
function search(payload) {
const searchTerm = payload.query;
const constrainedProperty = payload.property_constraint;
const constrainedFolder = payload.folder_constraint;
const normalizedFolderConstraint =
normalizeFolderConstraint(constrainedFolder);
const normalizedPropertyConstraint = constrainedProperty
?.trim()
.toLowerCase();
const searchArgs = {
enrich: true,
limit: 20,
};
const rawSearchResult = index.search(searchTerm, searchArgs);
const scopedSearchResult =
normalizedPropertyConstraint &&
normalizedPropertyConstraint !== "all" &&
availableSearchConstraintFields.includes(normalizedPropertyConstraint)
? rawSearchResult.filter(
({ field }) => field === normalizedPropertyConstraint,
)
: rawSearchResult;
const uniqueIdCombos = groupByDocId(scopedSearchResult);
console.log(JSON.stringify(uniqueIdCombos, null, 4));
const searchResult = scopedSearchResult
.flatMap((item) => item.result)
.reduce((acc, current) => {
if (!acc.find((item) => item.id === current.id)) {
current.doc["sourceFields"] = uniqueIdCombos[current.id];
acc.push(current.doc);
}
return acc;
}, [])
.filter((doc) => {
if (!normalizedFolderConstraint) return true;
return doc.folder === normalizedFolderConstraint;
});
console.log("searchResult", JSON.stringify(searchResult, null, 4));
return searchResult;
}
Deciphering this code:
const searchResult = scopedSearchResult
.flatMap((item) => item.result)
.reduce((acc, current) => {
if (!acc.find((item) => item.id === current.id)) {
current.doc["sourceFields"] = uniqueIdCombos[current.id];
acc.push(current.doc);
}
return acc;
}, [])
.filter((doc) => {
if (!normalizedFolderConstraint) return true;
return doc.folder === normalizedFolderConstraint;
});
flatmap
Applied against the search objects, this code
const test = scopedSearchResult.flatMap((item) => item.result);
produces this:
[
{
"id": 1000,
"doc": {
"id": 1000,
"title": "https://github.com/wesbos/JSON-Alexander",
"url": "https://github.com/wesbos/JSON-Alexander",
"tags": "links,json,web-dev",
"folder": "link",
"description": "A really good JSON viewer browser Extension."
}
},
...
]
flatmap and reduce
Applied against the search objects, this code
const test = scopedSearchResult
.flatMap((item) => item.result)
.reduce((acc, current) => {
if (!acc.find((item) => item.id === current.id)) {
current.doc["sourceFields"] = uniqueIdCombos[current.id];
acc.push(current.doc);
}
return acc;
}, []);
produces this:
[
{
"id": 1000,
"title": "https://github.com/wesbos/JSON-Alexander",
"url": "https://github.com/wesbos/JSON-Alexander",
"tags": "links,json,web-dev",
"folder": "link",
"description": "A really good JSON viewer browser extension.",
"sourceFields": [
"title",
"url",
"content",
"tags",
"description"
]
},
...
]
See note on
sourceFieldsbelow.
flatMap, reduce, and filter
When normalizedFolderConstraint contains a folder name, the filter method limits the results to only search objects where the folder property is equal to the normalizedFolderConstraint variable.
const searchResult = scopedSearchResult
.flatMap((item) => item.result)
.reduce((acc, current) => {
if (!acc.find((item) => item.id === current.id)) {
current.doc["sourceFields"] = uniqueIdCombos[current.id];
acc.push(current.doc);
}
return acc;
}, [])
.filter((doc) => {
if (!normalizedFolderConstraint) return true;
return doc.folder === normalizedFolderConstraint;
});
Identifying where the search term was found
The search results includes a sourceFields property that shows the properties of the search object in which the search term was found. The original intent of this was be to be able to show the user where the search term was found. I'm not sure that's of use, but it remains for now.
This code:
function groupByDocId(results) {
return results.reduce((acc, { field, result }) => {
for (const { doc } of result) {
(acc[doc.id] ??= []).push(field);
}
return acc;
}, {});
}
...
const uniqueIdCombos = groupByDocId(scopedSearchResult);
produces this object which provides, by search object id, the properties of the search object in which the search term was found.
{
"2": [
"content"
],
"4": [
"content"
],
"5": [
"content"
],
"10": [
"content"
],
"12": [
"tags"
],
"16": [
"content"
],
"1000": [
"title",
"url",
"content",
"tags",
"description"
]
}
The full original worker
This original doesn't include the code to search specific properties, but it does include the abiility to constrain the search by the property specified with the tag option (which I couldn't make work!)
import { Document } from "/scripts/flexsearch.bundle.module.min.js";
const availableSearchConstraintFields = ["title", "content", "tags"];
let index = null;
self.addEventListener("message", async (event) => {
const { type, data } = event.data;
if (type === "init") {
/**
* The 'store' property defines the search result output properties.
* The 'index' property defines the properties that are indexed.
*/
index = new Document({
document: {
id: "id",
store: ["id", "title", "url", "tags", "folder"],
//store: true,
index: ["title", "url", "content", "tags"],
tag: "folder",
},
tokenize: "forward",
});
/**
* Search result properties are fetched from the index object.
*/
for (const post of data.posts) {
index.add({
id: post.id,
title: post.title,
content: post.content,
tags: (post.tags || []).join(","),
url: post.url,
folder: post.folder,
});
}
self.postMessage({ type: "ready" });
} else if (type === "search") {
const results = search(data.payload);
self.postMessage({ type: "results", data: results });
}
});
function groupByDocId(results) {
return results.reduce((acc, { field, result }) => {
for (const { doc } of result) {
(acc[doc.id] ??= []).push(field);
}
return acc;
}, {});
}
function search(payload) {
const searchTerm = payload.query;
const constrainedProperty = payload.property_constraint;
const constrainedFolder = payload.folder_constraint;
const searchArgs = {
enrich: true,
limit: 20,
};
if (constrainedFolder !== "all") {
searchArgs["tag"] = { folder: constrainedFolder };
}
const rawSearchResult = index.search(searchTerm, searchArgs);
const uniqueIdCombos = groupByDocId(rawSearchResult);
console.log(JSON.stringify(uniqueIdCombos, null, 4));
const searchResult = rawSearchResult
.flatMap((item) => item.result)
.reduce((acc, current) => {
if (!acc.find((item) => item.id === current.id)) {
current.doc["sourceFields"] = uniqueIdCombos[current.id];
acc.push(current.doc);
}
return acc;
}, []);
return searchResult;
}