Using AI Search from server-side script: `sn_search.ScriptableSearchAPI` and (hidden) language param
Options
- Mark as New
- Bookmark
- Subscribe
- Mute
- Subscribe to RSS Feed
- Permalink
- Report Inappropriate Content
yesterday
If you are building anything that needs "find me the most relevant catalog items / knowledge articles for this free-text question" from a Script Include — a chatbot, a Virtual Agent topic, a custom portal widget, a suggestion engine — you have probably written your own `CONTAINS` query with some keyword scoring on top. We did too. Then we switched to the AI Search scripted API and removed a few hundred lines of homegrown ranking code. This post is about what we learned, and in particular about one parameter that is easy to miss and makes a big difference in a multilingual instance: **the search locale**.
We found this API by performing a deep-dive using the "AI Search (New)" feature in ServiceNow (https://xxx.service-now.com/now/ais-preview-utility/preview) which made us enthousiastic about the ability to set the preferred language which could be different than the Users preference. Especially interesting for public usage where Guest user is being used.
To share our source code, we regenerated our scripts in Claude Fable to depersonalize it so we can share it to the community.
## A word on documentation and support status
Let's be upfront: at the time of writing `sn_search.ScriptableSearchAPI` is **not in the official API reference** (neither on docs.servicenow.com nor on the developer portal). What exists is a Community thread in which a ServiceNow employee describes the 7 parameters, plus the fact that the platform itself uses it out of the box (the "Search KB" quick action in Agent Workspace, and the `AISASearchUtil` Script Include behind the Employee Center search bar delegates to it). So it is a stable internal API, not a formally supported one — plan a quick regression test after every upgrade.
For orientation, the other server-side entry points you will run into:
- **`AISASearchUtil` / `AISASearchUtilSNC`** (Global Script Include) — the route most Community articles recommend. It wraps `ScriptableSearchAPI`, additionally requires an AI Search Assist configuration (`aisa_rp_config`) and returns JSON. Fine if you want the exact Employee Center behaviour; one layer too many if you just need the ranked results.
- **`sn_ais.GeniusResultAnswer` / `GeniusResultContext`** — the only `sn_ais` classes that *are* documented. These are for scripted Genius Results; the direction is reversed (AI Search calls your script). Not usable for a chatbot or any flow without a search UI.
- **`sn_search.SearchPreviewAPI`** — the API behind the AI Search Preview utility; it has explicit `locale` and `searchAsUser` parameters, but it is a debug/preview API. Useful to *prove* a hypothesis, not something to build on.
- **REST** — there is no public search REST API for AI Search (ServiceNow's own FAQ: search and results APIs are not exposed externally); only the content Ingest API is official. Hence: server-side script it is.
## Why AI Search instead of your own GlideRecord query
`sn_search.ScriptableSearchAPI` lets you run a search against a published **Search Application Configuration** and get back the same ranked results the platform UI would give. Compared with a hand-rolled `addQuery('short_description', 'CONTAINS', word)` loop you get for free:
- typo tolerance, stemming and your synonym dictionaries;
- one combined, ranked result set across knowledge, catalog and any other indexed source;
- **ACL trimming under the calling user** — no more `canView()` loops, and no "fail-closed for guest" surprises;
- response times in the 50–100 ms range instead of scanning a candidate pool of thousands of records.
The basic call is small:
var api = new sn_search.ScriptableSearchAPI();
var response = api.search(searchContextConfigSysId, 'order laptop', '', false, [], [], {});
var results = response.getSearchResults();
for (var i = 0; i < results.length; i++) {
gs.info(results[i].getTable() + ' / ' + results[i].getSysId());
}
The first argument is the sys_id of a **Search Application Configuration** (table `sys_search_context_config`, menu *AI Search > Search Application Configurations*). That record points to a **Search Profile** (`ais_search_profile`), and the profile is what actually has to be **published**, with its search sources indexed. An unpublished profile or unindexed sources do not throw — you simply get an empty list, so check this first when "nothing comes back".
Not to be confused with `sn_ais.GeniusResultAnswer` / `sn_ais.GeniusResultContext`: those are for building scripted **Genius Results** (the answer card on top of the search results page). There AI Search calls *your* script; here *your* script calls AI Search.
## The part most people miss: language
AI Search filters knowledge content hard on language. The 7-parameter call (as shared by ServiceNow staff on the Community) inherits the **session language** of the calling user. That is fine until a user chats in a language other than their profile language — an English-profile user asking a Dutch question, which in any international organisation happens all the time.
In our tests, `'laptop bestellen'` (Dutch) in an English session returned **1** result with the default call and **11** with the locale explicitly set to `nl` — with the three actual laptop order forms at rank 1–3.
What we found (via reflection on the Java method, so treat this as "verify on your release"): `search()` accepts more arguments than the seven commonly shown, and the **8th positional argument is the locale** (`'en'`, `'nl'`, `'de'`, …). Passing `''` or `null` falls back to the session language and does not throw.
// cfgId term requestId noSpellCheck sources facets extra LOCALE
var resp = api.search(cfgId, searchTerm, '', false, [], [], {}, 'nl');Two practical consequences:
1. Do **not** rely on `gs.getSession().setLanguage()` to steer this. In our experience a `setLanguage()` call inside a running transaction does not reliably take effect for the rest of that call chain (`getDisplayValue()` on translated fields kept resolving against the language the transaction started with). Passing the locale explicitly to the search call is the robust option.
2. Detect the language of the *question*, not of the *user*. We do a cheap language detection on the incoming text and pass that through.
## Example 1 — catalog items
Returns a compact text block per catalog item. `seenSysIds` is an object shared between callers so the same item is never added twice; `lang` is the detected language of the question.
/**
* Retrieve relevant catalog items via AI Search.
* @Param {string} searchTerm free-text question or keywords
* @Param {Object} seenSysIds map of sys_ids already added (shared: dedupe + counter)
* @Param {string} lang detected language of the question: 'en' | 'nl' | ''
* @returns {string} one line per item, ready to feed to a prompt or a widget
*/
getCatalogItemsViaAISearch: function(searchTerm, seenSysIds, lang) {
var out = '';
var limit = 20;
// Use a different search configuration for anonymous vs. logged-in users.
var isGuest = (String(gs.getUserName() || '').toLowerCase() === 'guest');
var cfgId = isGuest
? gs.getProperty('my_app.ais_config_public')
: gs.getProperty('my_app.ais_config_loggedin');
if (!cfgId) { return out; }
// Locale: pass the language of the QUESTION. Empty string = let AI Search use the session language.
var locale = (lang === 'en' || lang === 'nl') ? lang : '';
var results;
try {
var api = new sn_search.ScriptableSearchAPI();
var resp = api.search(cfgId, searchTerm, '', false, [], [], {}, locale);
results = resp ? resp.getSearchResults() : [];
} catch (e) {
gs.error('AI Search catalog retrieval failed: ' + e);
return out; // or fall back to a classic GlideRecord query here
}
var added = 0;
for (var i = 0; i < results.length && added < limit; i++) {
// Only catalog items (incl. record producers / content items); KB articles are handled separately.
if (String(results[i].getTable() || '').indexOf('sc_cat_item') !== 0) { continue; }
var sysId = String(results[i].getSysId() || '');
if (!sysId || seenSysIds[sysId]) { continue; }
// No canView() here: AI Search already trimmed the results on ACL for the current user.
var item = new GlideRecord('sc_cat_item');
if (!item.get(sysId)) { continue; }
seenSysIds[sysId] = true;
added++;
var name = this._getTranslatedValue('sc_cat_item', sysId, 'name', locale) || item.getValue('name');
var desc = this._getTranslatedValue('sc_cat_item', sysId, 'short_description', locale) || (item.getValue('short_description') || '');
var url = gs.getProperty('glide.servlet.uri').replace(/\/$/, '') + '/sp?id=sc_cat_item&sys_id=' + sysId;
out += 'CATALOG ITEM: ' + name + ' | LINK: [' + name + '](' + url + ') | ' + desc + '\n';
}
gs.info('AI Search catalog: added ' + added + ' of ' + results.length + ' results');
return out;
},
## Example 2 — knowledge articles
Same call, different table filter. Note the `GlideRecordSecure` when reading the article body: the search result is ACL-trimmed, but reading the body with a plain `GlideRecord` in a Script Include runs with system rights, so keep the secure variant for the content itself.
/**
* Retrieve relevant knowledge articles via AI Search.
* @Param {string} searchTerm
* @Param {Object} seenKbSysIds shared dedupe map
* @Param {string} lang 'en' | 'nl' | ''
* @returns {string}
*/
getKnowledgeViaAISearch: function(searchTerm, seenKbSysIds, lang) {
var out = '';
var limit = 10;
var isGuest = (String(gs.getUserName() || '').toLowerCase() === 'guest');
var cfgId = isGuest
? gs.getProperty('my_app.ais_config_public')
: gs.getProperty('my_app.ais_config_loggedin');
if (!cfgId) { return out; }
var locale = (lang === 'en' || lang === 'nl') ? lang : '';
var results;
try {
var api = new sn_search.ScriptableSearchAPI();
var resp = api.search(cfgId, searchTerm, '', false, [], [], {}, locale);
results = resp ? resp.getSearchResults() : [];
} catch (e) {
gs.error('AI Search knowledge retrieval failed: ' + e);
return out;
}
var added = 0;
for (var i = 0; i < results.length && added < limit; i++) {
if (String(results[i].getTable() || '') !== 'kb_knowledge') { continue; }
var sysId = String(results[i].getSysId() || '');
if (!sysId || seenKbSysIds[sysId]) { continue; }
// GlideRecordSecure: enforce ACLs / user criteria when reading the article body.
var kb = new GlideRecordSecure('kb_knowledge');
if (!kb.get(sysId)) { continue; }
seenKbSysIds[sysId] = true;
added++;
var body = kb.text.getDisplayValue().replace(/<\/?[^>]+(>|$)/g, ' ').substring(0, 1500);
var url = gs.getProperty('glide.servlet.uri').replace(/\/$/, '') + '/sp?id=kb_article&sys_id=' + sysId;
out += 'KB ARTICLE [' + kb.getValue('number') + '] | LINK: [' + kb.getValue('number') + '](' + url + ') | ' + body + '\n';
}
gs.info('AI Search knowledge: added ' + added + ' of ' + results.length + ' results');
return out;
},
### Helper used above: reading a translated field without depending on the session language
Because `getDisplayValue()` follows the session language (and does not reliably follow a mid-transaction `setLanguage()`), we read the translation straight from `sys_translated_text`:
_getTranslatedValue: function(table, sysId, field, lang) {
var gr = new GlideRecord(table);
if (!gr.get(sysId)) { return ''; }
var baseValue = gr.getValue(field);
if (!lang) { return baseValue; }
var tt = new GlideRecord('sys_translated_text');
tt.addQuery('tablename', gr.getValue('sys_class_name') || table);
tt.addQuery('documentkey', sysId);
tt.addQuery('fieldname', field);
tt.addQuery('language', lang);
tt.query();
if (tt.next() && tt.getValue('value')) { return tt.getValue('value'); }
return baseValue; // never return empty just because a translation is missing
},
## Tips from production
- **One call, many consumers.** AI Search returns knowledge and catalog in one ranked list. If several parts of your code need it for the same question, cache the result set for a few seconds (key: `cfgId + term + locale`) instead of calling the API three or four times.
- **Separate configurations for public and logged-in users.** A public configuration with only public catalogs/knowledge bases, and a second one that adds the internal sources. Choose at runtime based on `gs.getUserName() === 'guest'`.
- **Keep a feature flag.** We drive the retrieval mode from a system property (`legacy | ais | hybrid`). Rolling back is a property change, not a code change. `hybrid` (AI Search first, classic query as a safety net) is a good intermediate step while you tune your indexes.
- **Measure per source.** In our case AI Search clearly beat our own ranking for catalog items, but for knowledge articles a targeted per-knowledge-base query was still competitive, because the combined ranking sometimes pushed the right article out of the top N. Don't assume — test with a golden set of real questions.
- **Trust the order.** Resist the urge to re-score the results. AI Search's ranking was better than anything we bolted on top.
## Caveats
- The 8th (locale) argument is **not documented** at the time of writing. It has worked reliably for us, but check it on your own instance — a quick background script with and without the locale on a known multilingual term will tell you within a minute.
- The configuration sys_id must be a Search Application Configuration whose Search Profile (`ais_search_profile`) is **published** and whose sources are indexed; otherwise the call silently returns zero results.
- AI Search is only as good as your indexes and synonym dictionaries (ais_dictionary_term) — budget time for those.
Hope this saves someone the detour we took. Happy to answer questions in the comments. Regards, Peter
0 REPLIES 0