KDB knowledge

Full-text search

Find rows that contain words, rank the best matches first, and mark the found words in bold.

Full-text search finds rows that contain some words, like a search box on a website. LIKE '%bank%' must read every row and check every letter; it also finds bank inside embankment. A FULLTEXT index keeps a list of words, and for each word the rows that have it. So a search in 200,000 news articles takes a few milliseconds.

Three functions work together:

The examples use a news table: id, symbol, published_at, title, body, with articles in English and Nepali.

Example: A news search page: the 3 best articles for 'bank' (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_text ON news (title, body);
SELECT id,
       highlight(title, 'bank') AS title,
       round(search_rank(title, body, 'bank'), 3) AS rank
FROM news
WHERE MATCH(title, body, 'bank')
ORDER BY search_rank(title, body, 'bank') DESC
LIMIT 3;

Result:

id (LONG)title (STRING)rank (DOUBLE)
5Coastal Bank merger talks0.845
1City Bank profit rises 18%0.816
4Market index closes at 2,8400.562

(3 rows)

CREATE FULLTEXT INDEX

Syntax:

CREATE FULLTEXT INDEX [IF NOT EXISTS] name ON table (column [, column ...]) [WITH (language = 'english')]

Makes a full-text index on one or more text columns. With several columns, they are searched together as one text.

ArgumentTypeMeaning
namenamethe index name, unique in the whole database
tablenamethe table
columnnames1 to 16 STRING, SYMBOL or JSON columns
languagetextoptional: 'english' cuts words to their stem and leaves out stop words (see Language); 'simple' is the default

Example: Search news in the title and the body (sample data: content-news)

CREATE FULLTEXT INDEX news_text ON news (title, body);
SELECT id, title FROM news WHERE MATCH(title, body, 'city bank') ORDER BY id;

Result:

CREATE FULLTEXT INDEX news_text ON news (title, body)

index 'news_text' created on table 'news' (5 rows)

SELECT id, title FROM news WHERE match(title, body, 'city bank') ORDER BY id

id (LONG)title (STRING)
1City Bank profit rises 18%
4Market index closes at 2,840

(2 rows)

Example: EXPLAIN shows the index (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_text ON news (title, body);
EXPLAIN SELECT id FROM news WHERE MATCH(body, title, 'bank');
EXPLAIN SELECT id FROM news WHERE MATCH(title, 'bank');

Result:

EXPLAIN SELECT id FROM news WHERE match(body, title, 'bank')

plan (STRING)
scan table news (1 of 1 partitions, about 5 rows)
use index news_text (full-text search)
build 1 of 5 stored column(s) per row: id
engine: column

(4 rows)

EXPLAIN SELECT id FROM news WHERE match(title, 'bank')

plan (STRING)
scan table news (1 of 1 partitions, about 5 rows)
build 2 of 5 stored column(s) per row: id, title
filter while scanning: 1 condition(s)
engine: row (a condition the column engine does not do)

(4 rows)

The first query uses news_text (same columns, other order). The second names only title, so it reads the table.

Example: Forum: search the posts (sample data: content-forum)

Setup:

CREATE FULLTEXT INDEX posts_body ON posts (body);
SELECT id, thread_id, author, body FROM posts WHERE MATCH(body, 'bonus') ORDER BY id;

Result:

id (LONG)thread_id (LONG)author (STRING)body (STRING)
41gitaWhat about the bonus shares?
51ramBonus is 10% this time.

(2 rows)

Example: Changes are in the index at once (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_text ON news (title, body);
UPDATE news SET body = 'Coastal Bank Bank and Prabhu Bank signed the merger.' WHERE id = 5;
SELECT id FROM news WHERE MATCH(title, body, 'prabhu');
DELETE FROM news WHERE id = 4;
SELECT id FROM news WHERE MATCH(title, body, 'gainers');

Result:

UPDATE news SET body = 'Coastal Bank Bank and Prabhu Bank signed the merger.' WHERE id = 5

1 row(s) updated

SELECT id FROM news WHERE match(title, body, 'prabhu')

id (LONG)
5

(1 row)

DELETE FROM news WHERE id = 4

1 row(s) deleted

SELECT id FROM news WHERE match(title, body, 'gainers')

id (LONG)

(0 rows)

Example: Only text columns (sample data: content-news; shows an error)

CREATE FULLTEXT INDEX news_time ON news (published_at);

Result:

Error: index 'news_time' on table 'news' is FULLTEXT but column 'published_at' is TIMESTAMP; expected STRING (text) columns

Language: stems and stop words

Syntax:

CREATE FULLTEXT INDEX name ON table (columns) WITH (language = 'english')

By default a search finds whole words only: planning does not find plans. An index made WITH (language = 'english') cuts each word to its stem first (the Porter2 English stemmer), so plans, planned and planning are all plan.

What 'english' changes:

RuleExample
English words become their stemrunning, runs, run are one word; ran stays ran
Stop words are left outthe, of, and, is, ... are not in the index; a search for the alone finds nothing
Upper and lower case match for Latin, Greek and Cyrillic lettersCAFÉ = café, МОСКВА = москва
Phrases skip stop words"talks about a merger" finds talks about the merger
A prefix is stemmed too, and is never a stop wordplanning* finds plans; the* finds theory

Example: 'planning' finds 'plans' and 'planned' only with the English analyzer (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_en ON news (title, body) WITH (language = 'english');
SELECT id, title FROM news WHERE MATCH(title, body, 'planning') ORDER BY id;
SELECT id, highlight(body, 'shares') AS body FROM news WHERE MATCH(title, body, 'share') ORDER BY id;
SELECT match('Coastal Bank plans', 'planning') AS without_index;

Result:

SELECT id, title FROM news WHERE match(title, body, 'planning') ORDER BY id

id (LONG)title (STRING)
3Hydropower company plans IPO
5Coastal Bank merger talks

(2 rows)

SELECT id, highlight(body, 'shares') AS body FROM news WHERE match(title, body, 'share') ORDER BY id

id (LONG)body (STRING)
3Volt River Hydropower will sell new shares. The IPO opens next month.
4Banks and hydropower shares moved up. City Bank led the gainers.

(2 rows)

SELECT match('Coastal Bank plans', 'planning') AS without_index

without_index (BOOL)
false

(1 row)

Example: Stop words do not count in a phrase (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_en ON news (title, body) WITH (language = 'english');
SELECT id FROM news WHERE MATCH(title, body, '"talks about the merger"');
SELECT count(*) AS found FROM news WHERE MATCH(title, body, 'the');

Result:

SELECT id FROM news WHERE match(title, body, '"talks about the merger"')

id (LONG)
5

(1 row)

SELECT count(*) AS found FROM news WHERE match(title, body, 'the')

found (LONG)
0

(1 row)

Example: Only english and simple (sample data: content-news; shows an error)

CREATE FULLTEXT INDEX news_fr ON news (title, body) WITH (language = 'french');

Result:

Error: index 'news_fr' on table 'news' has language 'french'; expected 'english' or 'simple'

match

Syntax:

match(column [, column ...], 'words')
MATCH(column [, column ...], 'words')

true when the text of the columns has the words of the search text. The search text can have phrases, prefixes and words that must not be there (see Search text).

ArgumentTypeMeaning
columnSTRING, SYMBOL or JSON (other values are turned into text)the text to search; several columns are searched as one text
wordsSTRINGthe search text; always the last argument

Example: Every word must be there, in any order and any case (sample data: content-news)

SELECT id, title FROM news WHERE MATCH(title, body, 'BANK city') ORDER BY id;

Result:

id (LONG)title (STRING)
1City Bank profit rises 18%
4Market index closes at 2,840

(2 rows)

Example: Nepali words, and the same number in Devanagari digits (sample data: content-news)

SELECT id, title FROM news WHERE MATCH(title, body, 'नाफा') ORDER BY id;
SELECT id FROM news WHERE MATCH(title, body, '१२.५') ORDER BY id;

Result:

SELECT id, title FROM news WHERE match(title, body, 'नाफा') ORDER BY id

id (LONG)title (STRING)
2सिटी बैंकको नाफा बढ्यो

(1 row)

SELECT id FROM news WHERE match(title, body, '१२.५') ORDER BY id

id (LONG)
1
2

(2 rows)

Example: Search together with other conditions (sample data: content-news)

SELECT id, symbol, title
FROM news
WHERE MATCH(title, body, 'bank') AND published_at >= '2026-09-01'
ORDER BY id;

Result:

id (LONG)symbol (STRING)title (STRING)
4NULLMarket index closes at 2,840
5COASTCoastal Bank merger talks

(2 rows)

Example: match on plain text values (sample data: content-news)

SELECT match('City Bank profit rises', 'BANK') AS a,
       match('City Bank profit rises', 'banks') AS b,
       match('City Bank profit rises', 'bank*') AS c,
       match('City Bank', NULL) AS d;

Result:

a (BOOL)b (BOOL)c (BOOL)d (STRING)
truefalsetrueNULL

(1 row)

Example: The search text is missing (sample data: content-news; shows an error)

SELECT id FROM news WHERE MATCH(title);

Result:

Error: line 1, column 27: function 'match' takes 2 to 64 argument(s), got 1

How the index is used

Syntax:

use index name (full-text search)
use index other (...) (r row(s), the text match is checked on them)

With a FULLTEXT index on the same columns as match, KDB always uses it. It never goes back to reading the text of every row, even for a word that is in most rows.

QueryWhat KDB does
WHERE MATCH(...)finds the rows in the index, then reads only those rows
SELECT count(*) ... WHERE MATCH(...) and nothing else in WHEREcounts in the index; no row is read
a phrase, "hydro power"answered from the word positions in the index
ORDER BY search_rank(...) DESC LIMIT ntakes the n best rows from the index (see search_rank)
ORDER BY id LIMIT n, or no ORDER BY, on a table with id as its PRIMARY KEYreads only the first n matching rows
MATCH(...) AND symbol = 'COAST', with an index on symbol toouses the index that finds fewer rows; the other condition is checked on those rows only
MATCH(...) OR MATCH(...)no index: every row is read

Example: A symbol finds fewer rows than the word: its index goes first (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_text ON news (title, body);
CREATE INDEX news_symbol ON news (symbol);
EXPLAIN SELECT id FROM news WHERE MATCH(title, body, 'bank');
EXPLAIN SELECT id FROM news WHERE MATCH(title, body, 'bank') AND symbol = 'COAST';
SELECT id, title FROM news WHERE MATCH(title, body, 'bank') AND symbol = 'COAST';

Result:

EXPLAIN SELECT id FROM news WHERE match(title, body, 'bank')

plan (STRING)
scan table news (1 of 1 partitions, about 5 rows)
use index news_text (full-text search)
build 1 of 5 stored column(s) per row: id
engine: column

(4 rows)

EXPLAIN SELECT id FROM news WHERE match(title, body, 'bank') AND symbol = 'COAST'

plan (STRING)
scan table news (1 of 1 partitions, about 5 rows)
use index news_symbol (symbol = value) (1 row(s), the text match is checked on them)
build 4 of 5 stored column(s) per row: id, symbol, title, body
filter while scanning: 2 condition(s)
engine: row (a condition the column engine does not do)

(5 rows)

SELECT id, title FROM news WHERE match(title, body, 'bank') AND symbol = 'COAST'

id (LONG)title (STRING)
5Coastal Bank merger talks

(1 row)

Example: Count the articles with a phrase: from the index (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_text ON news (title, body);
SELECT count(*) AS articles FROM news WHERE MATCH(title, body, '"city bank"');

Result:

articles (LONG)
2

(1 row)

Segments on disk

Splitting text into words is the slow part of building a full-text index. Since 2026-10-02 KDB saves the words of each partition in a file, a segment, so the next open builds the index from those files and reads no row.

WhenWhat happens
the database closes, or checkpoints while it is idlea segment is written for each partition whose files changed since its last segment
the index is built (at open, or by the first query)a partition with a segment that fits takes its words from it; the others are read and split into words as before
a segment is damagedit is ignored, the server log says warning: damaged full-text segment ..., and that partition is read; the next close writes it again
the partition changed after its segment was writtenthe segment is ignored (it names the exact files it was made for)
DROP INDEX, DROP TABLE, a partition droppedits segments go to _trash at the next close or idle checkpoint
a partition only grows (new rows after its last one, tail files)only the new rows are written, in a small extra segment next to the old one (see below)

Growing partitions. A table that gets new rows all day (a news feed, a log) would write the segment of today's partition again at every quiet moment, for a few new rows. Instead, KDB writes only the new rows in an extra segment (fts/<name>.1.kfs, .2.kfs, ...) that names the segment before it. Small extra segments are merged into one as they pile up, and into a new whole segment when they reach half of it, so a partition never has more than 5 files. Old ones go to _trash.

WhatWhy it is safe
a power cut while segments are written or mergedeach file is written whole and then renamed; the next open uses the old segments or the new one, and when they do not fit together it reads that partition's rows
a partition rewritten whole, or changed by UPDATE / DELETEits next segment is a new whole one; the extra ones go to _trash

/api/stats search also shows segment_extensions (extra segments written) and segments_merged (older segments a merge replaced). The result is faster restarts: after a clean shutdown no row is read to build a full-text index, even on a table that grows all day.

search_rank

Syntax:

search_rank(column [, column ...], 'words')

A number that says how well a row matches: higher is better, 0 when the row does not match. It uses the BM25 formula (the formula of most search engines):

ArgumentTypeMeaning
columnSTRING, SYMBOL or JSONthe text; use the same columns as in match
wordsSTRINGthe search text; use the same words as in match

Example: The best matches first (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_text ON news (title, body);
SELECT id, title, round(search_rank(title, body, 'bank'), 3) AS rank
FROM news
WHERE MATCH(title, body, 'bank')
ORDER BY rank DESC;

Result:

id (LONG)title (STRING)rank (DOUBLE)
5Coastal Bank merger talks0.845
1City Bank profit rises 18%0.816
4Market index closes at 2,8400.562

(3 rows)

Example: Rows that do not match get 0 (sample data: content-news)

SELECT id, round(search_rank(title, body, 'city bank'), 3) AS rank FROM news ORDER BY id;

Result:

id (LONG)rank (DOUBLE)
11.961
20
30
41.476
50

(5 rows)

Example: The fast top-20 form: EXPLAIN says 'best 20 row(s) by search_rank' (sample data: content-news)

Setup:

CREATE FULLTEXT INDEX news_text ON news (title, body);
EXPLAIN SELECT id, title FROM news
WHERE MATCH(title, body, 'bank')
ORDER BY search_rank(title, body, 'bank') DESC
LIMIT 20;

Result:

plan (STRING)
scan table news (1 of 1 partitions, about 5 rows)
use index news_text (full-text search, best 20 row(s) by search_rank, top-k by rank (WAND))
build 3 of 5 stored column(s) per row: id, title, body
filter while scanning: 1 condition(s)
sort by 1 key(s)
limit 20
engine: row (a condition the column engine does not do)

(7 rows)

Example: Forum: the best posts for 'bonus' (sample data: content-forum)

Setup:

CREATE FULLTEXT INDEX posts_body ON posts (body);
SELECT id, author, body, round(search_rank(body, 'bonus'), 3) AS rank
FROM posts
WHERE MATCH(body, 'bonus')
ORDER BY search_rank(body, 'bonus') DESC
LIMIT 5;

Result:

id (LONG)author (STRING)body (STRING)rank (DOUBLE)
4gitaWhat about the bonus shares?1.319
5ramBonus is 10% this time.1.319

(2 rows)

highlight

Syntax:

highlight(column [, column ...], 'words')

Returns the text with every found word inside <b> and </b>, for a search result page.

ArgumentTypeMeaning
columnSTRING, SYMBOL or JSONthe text to show; several columns are joined with one space into one text
wordsSTRINGthe search text (the same as in match)

Example: Mark the search word in the titles (sample data: content-news)

SELECT id, highlight(title, 'bank') AS title
FROM news
WHERE MATCH(title, body, 'bank')
ORDER BY id;

Result:

id (LONG)title (STRING)
1City Bank profit rises 18%
4Market index closes at 2,840
5Coastal Bank merger talks

(3 rows)

Example: Nepali text, a phrase and a prefix (sample data: content-news)

SELECT id, highlight(body, 'नाफा १२.५') AS body FROM news WHERE id = 2;
SELECT id, highlight(title, body, '"net profit" bank*') AS text FROM news WHERE id = 1;

Result:

SELECT id, highlight(body, 'नाफा १२.५') AS body FROM news WHERE id = 2

id (LONG)body (STRING)
2सिटी बैंकले चौथो त्रैमासिकमा ३ अर्ब २७ करोड नाफा कमाएको छ। लाभांश १२.५ प्रतिशत।

(1 row)

SELECT id, highlight(title, body, '"net profit" bank*') AS text FROM news WHERE id = 1

id (LONG)text (STRING)
1City Bank profit rises 18% City Bank reported a net profit of $3.27 billion. The bank will pay a 12.5% dividend.

(1 row)

Example: Forum: escape what users typed, then highlight (safe for a web page) (sample data: content-forum)

Setup:

INSERT INTO posts VALUES (9, 2, 7, 'ram', 'Is <b>ACME</b> a hydro IPO & a good buy?', '2026-09-02T09:30:00+05:45');
SELECT id,
       highlight(replace(replace(replace(body, '&', '&amp;'), '<', '&lt;'), '>', '&gt;'), 'hydro ipo') AS safe_html
FROM posts
WHERE MATCH(body, 'hydro ipo')
ORDER BY id;

Result:

id (LONG)safe_html (STRING)
9Is &lt;b&gt;ACME&lt;/b&gt; a hydro IPO &amp; a good buy?

(1 row)

In the last example the <b> that the user typed became &lt;b&gt;, so only the search words are bold. (A search for the words amp, lt or gt would then also mark letters inside &amp;, &lt; and &gt;.)

Search text

Syntax:

word word       every word must be there, anywhere, in any order
"word word"     these words next to each other, in this order
word*           a word that starts with these letters
-word           this word must not be there
-"word word"    this phrase must not be there

How KDB cuts text (and the search text) into words:

RuleExample
Letters and digits of any language make wordsसिटी बैंकको नाफा is 3 words
English letters A-Z: case does not matter; other letters must be the sameBank = bank; Café is not CAFÉ
Everything else separates words: spaces, punctuation, ।, ॥, emojiछ। is the word छ; ram@example.com is ram, example, com
A number keeps its dot; a comma inside a number is dropped12.5 is one word; 2,840 is 2840
Devanagari digits are the same as 0-9१२.५ = 12.5
Numbers are words, not values1234.50 is not 1234.5
No stemmingbank does not find banks; बैंक does not find बैंकले (use बैंक*); an English index stems, see Language

Example: A phrase, a prefix, and a word that must not be there (sample data: content-news)

SELECT id FROM news WHERE MATCH(title, body, '"net profit"') ORDER BY id;
SELECT id FROM news WHERE MATCH(title, body, 'hydro*') ORDER BY id;
SELECT id FROM news WHERE MATCH(title, body, 'bank -merger') ORDER BY id;

Result:

SELECT id FROM news WHERE match(title, body, '"net profit"') ORDER BY id

id (LONG)
1

(1 row)

SELECT id FROM news WHERE match(title, body, 'hydro*') ORDER BY id

id (LONG)
3
4

(2 rows)

SELECT id FROM news WHERE match(title, body, 'bank -merger') ORDER BY id

id (LONG)
1
4

(2 rows)

Example: Nepali: a word ending needs a prefix search (sample data: content-news)

SELECT id FROM news WHERE MATCH(title, body, 'बैंक') ORDER BY id;
SELECT id FROM news WHERE MATCH(title, body, 'बैंक*') ORDER BY id;

Result:

SELECT id FROM news WHERE match(title, body, 'बैंक') ORDER BY id

id (LONG)

(0 rows)

SELECT id FROM news WHERE match(title, body, 'बैंक*') ORDER BY id

id (LONG)
2

(1 row)

Example: Numbers: 2,840 and 2840 are the same word (sample data: content-news)

SELECT id, title FROM news WHERE MATCH(title, body, '2840');
SELECT id, title FROM news WHERE MATCH(title, body, '2,840');

Result:

SELECT id, title FROM news WHERE match(title, body, '2840')

id (LONG)title (STRING)
4Market index closes at 2,840

(1 row)

SELECT id, title FROM news WHERE match(title, body, '2,840')

id (LONG)title (STRING)
4Market index closes at 2,840

(1 row)

Example: OR is not an operator, and a search needs a word to find (sample data: content-news)

SELECT id FROM news WHERE MATCH(title, body, 'bank OR merger') ORDER BY id;
SELECT id FROM news WHERE MATCH(title, body, '-merger') ORDER BY id;
SELECT id FROM news WHERE MATCH(title, body, 'bank') OR MATCH(title, body, 'hydropower') ORDER BY id;

Result:

SELECT id FROM news WHERE match(title, body, 'bank OR merger') ORDER BY id

id (LONG)

(0 rows)

SELECT id FROM news WHERE match(title, body, '-merger') ORDER BY id

id (LONG)

(0 rows)

SELECT id FROM news WHERE match(title, body, 'bank') OR match(title, body, 'hydropower') ORDER BY id

id (LONG)
1
3
4
5

(4 rows)