SereneDB

Healthcare data solutions: clinical notes and cohorts in one SQL query

Healthcare data solutions are what hospitals, research groups and healthcare data companies build to turn health records into answers, and much of a record is text: admission notes, discharge summaries, ED notes. SereneDB searches that text with BM25, medical synonym groups and typo tolerance, next to the visits and diagnosis codes in the same SQL tables, so one query finds the patients the notes describe and counts them per unit.

curl https://install.serenedb.com | sh
synonyms · htn = hypertensionmatchquery
An animation in three scenes over 14 synthetic clinical notes. First, a search for hypertension: the clinical dictionary expands it to {htn,hypertension}, and 7 notes match, 4 of them written with the abbreviation HTN, each shown with the matched word in context. Second, those notes are joined to their 6 visits and counted per unit: cardiology 2 patients, 4 visits, 1 followed by another visit within 30 days; emergency 1 patients, 1 visits, 1 followed by another visit within 30 days; medicine 1 patients, 1 visits, 0 followed by another visit within 30 days. Third, the misspelled query metfromin matches within one edit: metformin in note 7, two neighboring letters swapped, and metfomin in note 11, one letter fewer.
What are healthcare data solutions

What do healthcare data solutions do with clinical text? Five questions over 14 notes

Healthcare data solutions collect patient records and make them answer questions, and many of those questions depend on what the notes say: who was short of breath, who takes a drug, whose chest pain was a finding and not a denial. Below are 14 synthetic notes from 9 patients and five questions. Pick one to run it; each is one SQL statement.

clinical text lab · synthetic notes
14 notes · 9 patients · 12 visitswritten for this page
dyspnea→ {dyspnea,sob}
3 notes, one of them says SOB
  1. 01patient 101 · cardiology · 2026-09-02 · Admission note72-year-old man with a history of HTN and DM presents with chest pain radiating to the left arm. Troponin elevated.
  2. 02patient 101 · cardiology · 2026-09-05 · Discharge summaryNSTEMI treated medically. Hypertension controlled on lisinopril. Discharged on aspirin, atorvastatin and metoprolol.
  3. 03patient 102 · cardiology · 2026-09-08 · Progress noteNew onset atrial fibrillation with rapid ventricular response. Started Eliquis. Denies chest pain.
  4. 04patient 102 · cardiology · 2026-09-10 · Discharge summaryAFib rate controlled on metoprolol. Continue apixaban. History of CVA in 2021.
  5. 05patient 103 · emergency · 2026-09-12 · ED noteHypertension poorly controlled, BP 182/110. Headache, no chest pain.
  6. 06patient 103 · medicine · 2026-09-20 · Admission noteAdmitted with SOB and bilateral leg edema. Known HTN. Started furosemide.match
  7. 07patient 104 · medicine · 2026-09-15 · Progress noteType 2 diabetes with A1c 9.1. Metformin increased to 1000 mg twice daily.
  8. 08patient 105 · emergency · 2026-09-18 · ED noteFall at home, distal radius fracture. No head injury.
  9. 09patient 106 · cardiology · 2026-09-22 · Admission noteDyspnea on exertion and orthopnea. HTN and heart failure with reduced ejection fraction.match
  10. 10patient 106 · cardiology · 2026-10-09 · Admission noteReadmitted with worsening dyspnea. Diuresed with IV furosemide. Hypertension stable.match
  11. 11patient 107 · medicine · 2026-10-01 · Progress noteDM on metfomin, glucose 210. Reports polyuria.
  12. 12patient 108 · emergency · 2026-10-05 · ED noteSudden left-sided weakness and slurred speech. Code stroke called, CT negative for bleed.
  13. 13patient 101 · cardiology · 2026-10-20 · Progress noteFollow-up after NSTEMI. Chest pain on exertion, relieved by rest. HTN well controlled.
  14. 14patient 109 · medicine · 2026-10-14 · Admission noteCommunity-acquired pneumonia, started ceftriaxone. Telemetry shows atrial fibrillation, rate controlled.
Synonyms
sql
SELECT note_id, person_id, note_text
FROM note_idx
WHERE note_text @@ 'dyspnea'
ORDER BY note_id;
result · 3 rows
note_idperson_idnote_text
6103Admitted with SOB and bilateral leg edema. Known HTN. Started furosemide.
9106Dyspnea on exertion and orthopnea. HTN and heart failure with reduced ejection fraction.
10106Readmitted with worsening dyspnea. Diuresed with IV furosemide. Hypertension stable.
Note 6 says SOB, not dyspnea. The dictionary's synonym line makes sob and dyspnea one term, in the indexed notes and in the query, so a search for either word finds all three notes.
Typos
sql
SELECT note_id, person_id, note_text
FROM note_idx
WHERE note_text @@ ts_levenshtein('metfromin', 1)
ORDER BY note_id;
result · 2 rows
note_idperson_idnote_text
7104Type 2 diabetes with A1c 9.1. Metformin increased to 1000 mg twice daily.
11107DM on metfomin, glucose 210. Reports polyuria.
The query swaps two letters of metformin, and note 11 drops one. ts_levenshtein matches every indexed word within one edit, a swap of neighbors included, so both notes come back: the correct spelling and the typo.
Phrases
sql
SELECT note_id, person_id, note_text
FROM note_idx
WHERE note_text @@ (
    ts_phrase('chest pain')
    && !!ts_any([ts_phrase('denies chest pain'),
                 ts_phrase('no chest pain')]))
ORDER BY note_id;
result · 2 rows
note_idperson_idnote_text
110172-year-old man with a history of HTN and DM presents with chest pain radiating to the left arm. Troponin elevated.
13101Follow-up after NSTEMI. Chest pain on exertion, relieved by rest. HTN well controlled.
Four notes contain the phrase chest pain, and two deny it: note 3 says denies chest pain, note 5 no chest pain. && !! removes every note that contains either denial, which leaves notes 1 and 13.
Cohort
sql
WITH cohort AS (
    SELECT DISTINCT visit_occurrence_id
    FROM note_idx
    WHERE note_text @@ 'hypertension'
), visits AS (
    SELECT *,
           visit_end_date - visit_start_date AS los,
           lead(visit_start_date) OVER (
               PARTITION BY person_id
               ORDER BY visit_start_date
           ) - visit_end_date AS days_to_next
    FROM visit_occurrence
)
SELECT v.care_site_name AS unit,
       count(DISTINCT v.person_id) AS patients,
       count(*) AS visits,
       round(avg(v.los)::numeric, 1) AS avg_los_days,
       count(*) FILTER (WHERE v.days_to_next <= 30)
           AS back_in_30d
FROM cohort
JOIN visits v USING (visit_occurrence_id)
GROUP BY v.care_site_name
ORDER BY patients DESC, unit;
result · 3 rows
unitpatientsvisitsavg_los_daysback_in_30d
cardiology243.01
emergency110.01
medicine114.00
Seven notes on six visits say hypertension or HTN. Joined to visit_occurrence, they count patients, visits and the average stay per unit, and lead() over each patient's visits finds the ones followed by another within 30 days: patient 106 back in cardiology after 13 days, patient 103 admitted to medicine 8 days after an emergency visit.
Highlight
sql
SELECT note_id,
       round(BM25(note_idx.tableoid)::numeric, 3) AS bm25,
       ts_highlight(note_idx.note_text) AS snippet
FROM note_idx
WHERE note_text @@ 'stroke'
ORDER BY bm25 DESC, note_id;
result · 2 rows
note_idbm25snippet
40.850History of <b>CVA</b> in 2021
120.799Code <b>stroke</b> called, CT negative for bleed
ts_highlight returns the best passage around each match with the matched word marked: CVA in note 4, which the synonym group files with stroke, and stroke in note 12. BM25 puts the shorter note first.

Checklist

What a healthcare data platform needs from its database, and how SereneDB does it in SQL

The database under a healthcare data platform has to search clinical text in the vocabulary clinicians write, survive misspellings, connect what a note says to visits and codes, search research by meaning, limit what each role reads and reach data where it already lives. Lists of healthcare data platforms compare products; these are the jobs, each with the SereneDB feature that does it and its docs.

NeedHow SereneDB does itLooks like
Search clinical textNotes are full-text indexed next to their ids and dates, and BM25 ranks the matches. Docs →BM25(note_idx.tableoid)
Abbreviations and synonymsA synonym group makes HTN and hypertension one term, and a one-way rule files a brand name under its generic. Docs →expand_solr_synonyms('htn, hypertension …')
MisspellingsEach query word matches the indexed words within an edit distance, swaps of neighboring letters included. Docs →note_text @@ ts_levenshtein('metfromin', 1)
Phrases and negationA phrase matches its words in order, and !! drops the notes that contain a denial of it. Docs →ts_phrase('chest pain') && !!ts_any([…])
CohortsThe index is queried like a table, so matching notes join to their visits and group by unit in one statement. Docs →FROM cohort JOIN visits v USING (visit_occurrence_id)
Codes beside the textDiagnosis codes stay in their own table, and a LIKE prefix follows the ICD-10 hierarchy. Docs →condition_source_value LIKE 'I48%'
Research search by meaningEmbeddings sit in the same inverted index as the text, and reciprocal rank fusion merges the two rankings. Docs →emb <-> [0.85, 0.05, 0.05, 0.85]::FLOAT[4]
Access by roleColumn grants and views decide what a role reads, and index scans answer within them. Docs →GRANT SELECT (note_id, note_text) ON note TO researcher
Data where it livesAn inverted index over a view of Parquet files, on disk or in S3, or of a PostgreSQL table reached with CREATE SERVER. Docs →read_parquet('/data/ehr/note/*.parquet')
The tools teams useSereneDB speaks the PostgreSQL wire protocol: psql, PostgreSQL drivers and tools such as DBeaver and Grafana connect to it. Docs →psql -h localhost -p 7890 -U postgres

Architecture

Healthcare data platform architecture: records in, indexed tables, SQL out

Records arrive from an EHR export, an existing PostgreSQL database and a research library, and sit in tables, the clinical ones shaped like the OMOP CDM, with inverted indexes over the note text, the ids, the dates and the embeddings. Search, cohorts and hybrid ranking are SELECT statements, read over the PostgreSQL protocol by applications, SQL tools and a research role. Hover a stage, or a lane inside the index.

01 · sources
EHR export
Parquet files read in place
01 · sources
PostgreSQL
CREATE SERVER postgres_fdw
01 · sources
Research library
abstracts and protocols
02 · tables
note
one row per note
02 · tables
visit_occurrence
and condition_occurrence
named after the OMOP CDM
02 · tables
abstract
titles and embeddings
03 · inverted indexes
note_idx · abstract_idx
note_text clinical_entext
person_id, visit_occurrence_idexact
note_daterange
emb ivf (metric = 'l2')vector
04 · search
@@
synonyms · typos · phrases
04 · cohorts
JOIN
visits · codes · GROUP BY
04 · research
RRF
BM25 + vectors
05 · applications
Chart review
PostgreSQL driver :7890
05 · analysts
psql, SQL tools
PostgreSQL protocol
05 · research
GRANT
the columns a role may read
> sources · EHR export

Exports searched where they land

A view over read_parquet, on disk or in S3, gets its own inverted index: the rows stay in the files and only the search postings are built. The export of this page's notes matches HTN in place.

sql
CREATE VIEW note_export AS
SELECT * FROM read_parquet('/data/ehr/note/*.parquet');
CREATE INDEX note_export_idx ON note_export USING inverted (
note_id, note_date, note_text clinical_en);
SELECT note_id, note_date, note_title
FROM note_export_idx
WHERE note_text @@ 'htn'
ORDER BY note_id;
> sources · PostgreSQL

An existing PostgreSQL, indexed in place

CREATE SERVER connects to the database the records already live in, a view over its table gets an inverted index, and matched rows are read back from PostgreSQL. This one ran against PostgreSQL 17.

sql
CREATE SERVER ehr FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'ehr-db.internal', port '5432', database 'ehr',
user 'reader', password 'secret');
CREATE VIEW ehr_note AS
SELECT note_id, note_date, note_text FROM ehr.public.note;
CREATE INDEX ehr_note_idx ON ehr_note USING inverted (
note_id, note_date, note_text clinical_en);
SELECT note_id, note_date, note_text
FROM ehr_note_idx
WHERE note_text @@ 'hypertension'
ORDER BY note_id;
> sources · research library

Abstracts, each with an embedding

Titles or abstracts load like any rows, with an embedding per row. In production ai_embed fills it from an embedding model; the example's four dimensions keep the distances readable.

sql
INSERT INTO abstract VALUES
(1, 'Home blood pressure monitoring after hospital discharge',
[0.90, 0.05, 0.05, 0.20]::FLOAT[4]),
(2, 'Intensive versus standard blood pressure targets in adults over 75',
[0.85, 0.05, 0.05, 0.90]::FLOAT[4]),
(3, 'Deprescribing antihypertensives in frail older patients',
[0.70, 0.05, 0.05, 0.85]::FLOAT[4]),
(4, 'Anticoagulation for atrial fibrillation in elderly patients',
[0.10, 0.05, 0.90, 0.80]::FLOAT[4]),
(5, 'Metformin and HbA1c control in type 2 diabetes',
[0.05, 0.95, 0.00, 0.10]::FLOAT[4]),
(6, 'Screening for atrial fibrillation with wearable ECG',
[0.05, 0.00, 0.95, 0.20]::FLOAT[4]),
(7, 'Salt reduction and blood pressure in community programs',
[0.80, 0.10, 0.00, 0.10]::FLOAT[4]),
(8, 'Falls and fractures in older adults on antihypertensive therapy',
[0.55, 0.00, 0.00, 0.80]::FLOAT[4]);
> stage 02 · note

A note is a row

The columns follow the OMOP CDM's note table: the person, the visit, the date, the title and the text. The text is searched through the index, everything else is plain SQL.

sql
CREATE TABLE note (
note_id INTEGER PRIMARY KEY,
person_id INTEGER NOT NULL,
visit_occurrence_id INTEGER NOT NULL,
note_date DATE NOT NULL,
note_title VARCHAR NOT NULL,
note_text VARCHAR NOT NULL
);
> stage 02 · visits and codes

Visits and diagnosis codes beside the notes

visit_occurrence holds each stay with its unit and dates, and condition_occurrence the ICD-10-CM codes recorded on it. Both join to the notes on visit_occurrence_id.

sql
CREATE TABLE visit_occurrence (
visit_occurrence_id INTEGER PRIMARY KEY,
person_id INTEGER NOT NULL,
care_site_name VARCHAR NOT NULL,
visit_start_date DATE NOT NULL,
visit_end_date DATE NOT NULL
);
CREATE TABLE condition_occurrence (
condition_occurrence_id INTEGER PRIMARY KEY,
person_id INTEGER NOT NULL,
visit_occurrence_id INTEGER NOT NULL,
condition_source_value VARCHAR NOT NULL
);
> stage 02 · abstract

Text and vectors in one index

The title is analyzed with the same clinical dictionary, and emb is a FLOAT[4] column with an IVF vector index beside it, so one index answers both halves of a hybrid search.

sql
CREATE TABLE abstract (
abstract_id INTEGER PRIMARY KEY,
title VARCHAR NOT NULL,
emb FLOAT[4] NOT NULL
);
CREATE INDEX abstract_idx ON abstract USING inverted (
abstract_id, title clinical_en, emb ivf (metric = 'l2'));
> stage 03 · inverted indexes

The clinical vocabulary lives in the dictionary

The dictionary lowercases, splits words and expands synonym groups at index time and at query time. One inverted index covers the text and what queries filter on: the ids exactly, the date as a range.

sql · hover a lane
CREATE TEXT SEARCH DICTIONARY clinical_en AS
split_text(case := 'lower')
| expand_solr_synonyms('htn, hypertension
sob, dyspnea
dm, diabetes
cva, stroke
eliquis => apixaban')
WITH (frequency, position, norm, offset);
CREATE INDEX note_idx ON note USING inverted (
note_id, person_id, visit_occurrence_id, note_date,
note_text clinical_en);
> stage 04 · search

Synonyms, typos and phrases, ranked and marked

Each is a query on the right of @@; BM25 ranks the matches and ts_highlight marks the word that matched, even when it is the brand name the dictionary rewrote. This is step 4.

sql
SELECT note_id,
round(BM25(note_idx.tableoid)::numeric, 3) AS bm25,
ts_highlight(note_idx.note_text) AS snippet
FROM note_idx
WHERE note_text @@ 'apixaban'
ORDER BY bm25 DESC, note_id;
> stage 04 · cohorts

From what the notes say to counts per unit

The matching notes join to their visits, and lead() over each patient's visits finds the ones followed by another within 30 days, in one statement. This is step 5.

sql
WITH cohort AS (
SELECT DISTINCT visit_occurrence_id
FROM note_idx
WHERE note_text @@ 'hypertension'
), visits AS (
SELECT *,
visit_end_date - visit_start_date AS los,
lead(visit_start_date) OVER (
PARTITION BY person_id
ORDER BY visit_start_date
) - visit_end_date AS days_to_next
FROM visit_occurrence
)
SELECT v.care_site_name AS unit,
count(DISTINCT v.person_id) AS patients,
count(*) AS visits,
round(avg(v.los)::numeric, 1) AS avg_los_days,
count(*) FILTER (WHERE v.days_to_next <= 30)
AS back_in_30d
FROM cohort
JOIN visits v USING (visit_occurrence_id)
GROUP BY v.care_site_name
ORDER BY patients DESC, unit;
> stage 04 · research

Keywords and meaning, fused by rank

BM25 over the titles and distance over the embeddings rank independently, and reciprocal rank fusion adds 1 / (60 + rank) from each list.

sql
WITH fused AS (
SELECT abstract_id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
SELECT abstract_id, BM25(abstract_idx.tableoid) AS s
FROM abstract_idx
WHERE title @@ ts_phrase('blood pressure')
ORDER BY s DESC LIMIT 10) lex
UNION ALL
SELECT abstract_id, RANK() OVER (ORDER BY d) AS rank FROM (
SELECT abstract_id,
emb <-> [0.85, 0.05, 0.05, 0.85]::FLOAT[4] AS d
FROM abstract_idx
ORDER BY d LIMIT 10) vec
)
SELECT f.abstract_id, a.title,
sum(1.0 / (60 + f.rank))::DECIMAL(5, 4) AS rrf
FROM fused f
JOIN abstract a USING (abstract_id)
GROUP BY f.abstract_id, a.title
ORDER BY rrf DESC, f.abstract_id
LIMIT 4;
> stage 05 · applications

A chart review screen

One patient's notes about one symptom, newest first, with the matched words marked: the query an application sends over its PostgreSQL driver.

sql
SELECT note_date, note_title,
ts_highlight(note_idx.note_text,
'StartSel=[, StopSel=]') AS snippet
FROM note_idx
WHERE person_id = 106 AND note_text @@ 'sob'
ORDER BY note_date DESC;
> stage 05 · analysts

Any PostgreSQL client

psql, the PostgreSQL drivers and SQL tools such as DBeaver, DataGrip and Grafana connect on port 7890 over the PostgreSQL wire protocol.

shell
psql -h localhost -p 7890 -U postgres -c "
SELECT care_site_name, count(*) AS visits
FROM visit_occurrence
GROUP BY care_site_name
ORDER BY visits DESC, care_site_name;"
> stage 05 · research

A role reads what it was granted

A research role gets SELECT on the columns it may read. It searches note_idx through them, and a query that reads person_id is refused.

sql
CREATE ROLE researcher;
GRANT SELECT (note_id, note_text) ON note TO researcher;
SET ROLE researcher;
SELECT note_id, note_text
FROM note_idx
WHERE note_text @@ 'afib'
ORDER BY note_id;
SELECT person_id
FROM note_idx
WHERE note_text @@ 'afib';
-- ERROR: permission denied for table note
A diagram of five numbered stages. Records come from an EHR export of Parquet files read in place, an existing PostgreSQL database reached with CREATE SERVER and a research library of abstracts. They sit in tables shaped like the OMOP CDM: note with one row per note, visit_occurrence with condition_occurrence for stays and ICD-10-CM codes, and abstract with titles and embeddings. Inverted indexes cover the note text through the clinical_en dictionary, the person and visit ids as exact values, the note date as a range and the abstract embeddings as vectors. Queries search the text with synonyms, typos and phrases, join the matches to visits and codes for cohorts, and fuse BM25 with vector distance for research search. A chart review application, psql and SQL tools, and a research role limited by GRANT read the results over the PostgreSQL protocol.

What clinical text needs from one engine

clinical_en
vocabulary
Synonyms in the dictionary

Synonym groups and brand rules expand in the index and in every query, so either word finds the other.

expand_solr_synonyms
join
The index is a table

Matches join to visits and codes like any table, so a cohort is one SELECT.

FROM note_idx JOIN …
roles
Grants apply to search

Column grants and views hold for index scans: a role searches only what it may read.

GRANT SELECT (…)
searchbench
A billion rows

SearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.

otel_logs_1b · 26.09.1
SearchBench on otel_logs_1b →

Build it

Tutorial: search clinical notes and count a cohort in SQL, in five steps

From an empty server to a cohort: start SereneDB, load 14 synthetic notes from 9 patients with their visits and codes, build the clinical dictionary and the index, search the notes, then count the patients behind them.

step 01 / 05 · shell
docker run -d --name serenedb -p 7890:7890 \
    -e POSTGRES_PASSWORD=<password> serenedb/serenedb:26.11.2
psql -h localhost -p 7890 -U postgres
SELECT version() answers PostgreSQL 18.6 (SereneDB 26.11.2).
step 02 / 05 · sql
CREATE TABLE visit_occurrence (
    visit_occurrence_id INTEGER PRIMARY KEY,
    person_id INTEGER NOT NULL,
    care_site_name VARCHAR NOT NULL,
    visit_start_date DATE NOT NULL,
    visit_end_date DATE NOT NULL
);

CREATE TABLE note (
    note_id INTEGER PRIMARY KEY,
    person_id INTEGER NOT NULL,
    visit_occurrence_id INTEGER NOT NULL,
    note_date DATE NOT NULL,
    note_title VARCHAR NOT NULL,
    note_text VARCHAR NOT NULL
);

CREATE TABLE condition_occurrence (
    condition_occurrence_id INTEGER PRIMARY KEY,
    person_id INTEGER NOT NULL,
    visit_occurrence_id INTEGER NOT NULL,
    condition_source_value VARCHAR NOT NULL
);

INSERT INTO visit_occurrence VALUES
(1, 101, 'cardiology', '2026-09-02', '2026-09-05'),
(2, 102, 'cardiology', '2026-09-08', '2026-09-10'),
(3, 103, 'emergency', '2026-09-12', '2026-09-12'),
(4, 103, 'medicine', '2026-09-20', '2026-09-24'),
(5, 104, 'medicine', '2026-09-15', '2026-09-19'),
(6, 105, 'emergency', '2026-09-18', '2026-09-18'),
(7, 106, 'cardiology', '2026-09-22', '2026-09-26'),
(8, 106, 'cardiology', '2026-10-09', '2026-10-12'),
(9, 107, 'medicine', '2026-10-01', '2026-10-03'),
(10, 108, 'emergency', '2026-10-05', '2026-10-05'),
(11, 101, 'cardiology', '2026-10-20', '2026-10-22'),
(12, 109, 'medicine', '2026-10-14', '2026-10-17');

INSERT INTO note VALUES
(1, 101, 1, '2026-09-02', 'Admission note',
 '72-year-old man with a history of HTN and DM presents with chest pain radiating to the left arm. Troponin elevated.'),
(2, 101, 1, '2026-09-05', 'Discharge summary',
 'NSTEMI treated medically. Hypertension controlled on lisinopril. Discharged on aspirin, atorvastatin and metoprolol.'),
(3, 102, 2, '2026-09-08', 'Progress note',
 'New onset atrial fibrillation with rapid ventricular response. Started Eliquis. Denies chest pain.'),
(4, 102, 2, '2026-09-10', 'Discharge summary',
 'AFib rate controlled on metoprolol. Continue apixaban. History of CVA in 2021.'),
(5, 103, 3, '2026-09-12', 'ED note',
 'Hypertension poorly controlled, BP 182/110. Headache, no chest pain.'),
(6, 103, 4, '2026-09-20', 'Admission note',
 'Admitted with SOB and bilateral leg edema. Known HTN. Started furosemide.'),
(7, 104, 5, '2026-09-15', 'Progress note',
 'Type 2 diabetes with A1c 9.1. Metformin increased to 1000 mg twice daily.'),
(8, 105, 6, '2026-09-18', 'ED note',
 'Fall at home, distal radius fracture. No head injury.'),
(9, 106, 7, '2026-09-22', 'Admission note',
 'Dyspnea on exertion and orthopnea. HTN and heart failure with reduced ejection fraction.'),
(10, 106, 8, '2026-10-09', 'Admission note',
 'Readmitted with worsening dyspnea. Diuresed with IV furosemide. Hypertension stable.'),
(11, 107, 9, '2026-10-01', 'Progress note',
 'DM on metfomin, glucose 210. Reports polyuria.'),
(12, 108, 10, '2026-10-05', 'ED note',
 'Sudden left-sided weakness and slurred speech. Code stroke called, CT negative for bleed.'),
(13, 101, 11, '2026-10-20', 'Progress note',
 'Follow-up after NSTEMI. Chest pain on exertion, relieved by rest. HTN well controlled.'),
(14, 109, 12, '2026-10-14', 'Admission note',
 'Community-acquired pneumonia, started ceftriaxone. Telemetry shows atrial fibrillation, rate controlled.');

INSERT INTO condition_occurrence VALUES
(1, 101, 1, 'I21.4'), (2, 101, 1, 'I10'), (3, 101, 1, 'E11.9'),
(4, 102, 2, 'I48.91'),
(5, 103, 3, 'I10'),
(6, 103, 4, 'I50.9'), (7, 103, 4, 'I10'),
(8, 104, 5, 'E11.65'),
(9, 105, 6, 'S52.501A'), (10, 105, 6, 'W19.XXXA'),
(11, 106, 7, 'I50.9'), (12, 106, 7, 'I10'),
(13, 106, 8, 'I50.9'), (14, 106, 8, 'I10'),
(15, 107, 9, 'E11.65'),
(16, 108, 10, 'I63.9'),
(17, 101, 11, 'I10'),
(18, 109, 12, 'J18.9');
INSERT 0 12, INSERT 0 14 and INSERT 0 18.
step 03 / 05 · sql
CREATE TEXT SEARCH DICTIONARY clinical_en AS
    split_text(case := 'lower')
    | expand_solr_synonyms('htn, hypertension
sob, dyspnea
dm, diabetes
cva, stroke
eliquis => apixaban')
    WITH (frequency, position, norm, offset);

CREATE INDEX note_idx ON note USING inverted (
    note_id, person_id, visit_occurrence_id, note_date,
    note_text clinical_en);

VACUUM (REFRESH_TABLE) note;

SELECT ts_lexize('clinical_en', 'htn') AS htn,
       ts_lexize('clinical_en', 'eliquis') AS eliquis;
ts_lexize shows what the index and every query get: htn → {htn,hypertension}, eliquis → {apixaban}.
step 04 / 05 · sql
-- A brand name finds its generic, ranked and marked
SELECT note_id,
       round(BM25(note_idx.tableoid)::numeric, 3) AS bm25,
       ts_highlight(note_idx.note_text) AS snippet
FROM note_idx
WHERE note_text @@ 'apixaban'
ORDER BY bm25 DESC, note_id;

-- A typo in the query, and one in a note
SELECT note_id, person_id, note_text
FROM note_idx
WHERE note_text @@ ts_levenshtein('metfromin', 1)
ORDER BY note_id;

-- Chest pain, unless the note denies it
SELECT note_id, person_id, note_text
FROM note_idx
WHERE note_text @@ (
    ts_phrase('chest pain')
    && !!ts_any([ts_phrase('denies chest pain'),
                 ts_phrase('no chest pain')]))
ORDER BY note_id;
apixaban returns notes 4 and 3, note 3 marked at Eliquis; metfromin returns notes 7 and 11; chest pain returns notes 1 and 13.
step 05 / 05 · sql
WITH cohort AS (
    SELECT DISTINCT visit_occurrence_id
    FROM note_idx
    WHERE note_text @@ 'hypertension'
), visits AS (
    SELECT *,
           visit_end_date - visit_start_date AS los,
           lead(visit_start_date) OVER (
               PARTITION BY person_id
               ORDER BY visit_start_date
           ) - visit_end_date AS days_to_next
    FROM visit_occurrence
)
SELECT v.care_site_name AS unit,
       count(DISTINCT v.person_id) AS patients,
       count(*) AS visits,
       round(avg(v.los)::numeric, 1) AS avg_los_days,
       count(*) FILTER (WHERE v.days_to_next <= 30)
           AS back_in_30d
FROM cohort
JOIN visits v USING (visit_occurrence_id)
GROUP BY v.care_site_name
ORDER BY patients DESC, unit;
cardiology 2 patients, 4 visits, 3.0 days, 1 back within 30 days; emergency 1 patient, 1 visit, 0.0 days, 1 back within 30 days; medicine 1 patient, 1 visit, 4.0 days, 0 back within 30 days.

Use cases

What healthcare and life-sciences teams build on SereneDB

Chart review search across a patient's notes

A clinician or a coder looks for one symptom in one patient's record. The query filters on person_id and searches note_text in the same index scan, and ts_highlight returns the passage around each match with the word marked, here between [ and ].

built with
person_id =@@ts_highlight
Full-text search →
example · one patient, one symptom
SELECT note_date, note_title,
       ts_highlight(note_idx.note_text,
                    'StartSel=[, StopSel=]') AS snippet
FROM note_idx
WHERE person_id = 106 AND note_text @@ 'sob'
ORDER BY note_date DESC;
result
note_datenote_titlesnippet
2026-10-09Admission noteReadmitted with worsening [dyspnea]
2026-09-22Admission note[Dyspnea] on exertion and orthopnea
Patient 106's two admission notes, newest first. The query says sob; the notes say dyspnea, found through the synonym group.

Cohorts from notes and codes together

Registries and research cohorts often start from what the notes say and check it against the codes. These are the visits whose notes mention atrial fibrillation, as the phrase or as AFib, joined to any ICD-10-CM code under I48 recorded on the same visit.

built with
ts_phraseLEFT JOINLIKE 'I48%'
OLAP database →
example · AF in the notes, I48 in the codes
SELECT n.visit_occurrence_id AS visit, n.person_id,
       count(DISTINCT n.note_id) AS af_notes,
       coalesce(string_agg(DISTINCT c.condition_source_value,
                           ', '), 'none') AS i48_codes
FROM note_idx n
LEFT JOIN condition_occurrence c
       ON c.visit_occurrence_id = n.visit_occurrence_id
      AND c.condition_source_value LIKE 'I48%'
WHERE n.note_text @@ (ts_phrase('atrial fibrillation')
                      || 'afib')
GROUP BY n.visit_occurrence_id, n.person_id
ORDER BY n.visit_occurrence_id;
result
visitperson_idaf_notesi48_codes
21022I48.91
121091none
Visit 2 carries I48.91, unspecified atrial fibrillation. Visit 12, a pneumonia admission whose note records atrial fibrillation on telemetry, has no I48 code: the visit to review.

Literature search by keyword and by meaning

Research teams search abstracts and trial protocols by the words they contain and by what they mean. BM25 over the titles and vector distance over the embeddings rank independently, and reciprocal rank fusion merges the two lists. The eight titles are written for the page, with four-dimensional vectors; a team fills emb from an embedding model with ai_embed.

built with
BM25<->RANK()
Hybrid search →
example · blood pressure in older adults
WITH fused AS (
    SELECT abstract_id, RANK() OVER (ORDER BY s DESC) AS rank FROM (
        SELECT abstract_id, BM25(abstract_idx.tableoid) AS s
        FROM abstract_idx
        WHERE title @@ ts_phrase('blood pressure')
        ORDER BY s DESC LIMIT 10) lex
    UNION ALL
    SELECT abstract_id, RANK() OVER (ORDER BY d) AS rank FROM (
        SELECT abstract_id,
               emb <-> [0.85, 0.05, 0.05, 0.85]::FLOAT[4] AS d
        FROM abstract_idx
        ORDER BY d LIMIT 10) vec
)
SELECT f.abstract_id, a.title,
       sum(1.0 / (60 + f.rank))::DECIMAL(5, 4) AS rrf
FROM fused f
JOIN abstract a USING (abstract_id)
GROUP BY f.abstract_id, a.title
ORDER BY rrf DESC, f.abstract_id
LIMIT 4;
result
abstract_idtitlerrf
2Intensive versus standard blood pressure targets in adults over 750.0323
1Home blood pressure monitoring after hospital discharge0.0320
7Salt reduction and blood pressure in community programs0.0315
3Deprescribing antihypertensives in frail older patients0.0161
Abstract 2 leads, first by distance and third by BM25. Abstract 3 never says blood pressure and enters the top four on meaning alone, 0.150 from the question's vector.

FAQ

Frequently asked questions

Go to docs →

Healthcare data solutions are the software that hospitals, payers, research groups and healthcare data companies use to collect clinical and operational data, keep it governed and turn it into answers: dashboards, registries, cohorts and clinical decision support. Underneath each one is a database that holds structured records, such as visits and diagnosis codes, next to clinical text. SereneDB is that database layer, with full-text search and SQL analytics in one engine.

A healthcare data platform is the shared layer where data from EHRs, labs, claims and research lands, is organized into a common model, protected by role and queried by applications, analysts and researchers. Its database has to search clinical text, join it to structured records and enforce who reads what; the checklist above maps each of these jobs to SereneDB.

DBMS stands for database management system, in medicine as anywhere else: the software that stores, protects and queries data. Medical database management is running such a system for health data: modeling patients, visits, codes and notes, controlling who reads which columns, and keeping the records correct and searchable. SereneDB is a DBMS that speaks the PostgreSQL protocol, so PostgreSQL clients and drivers connect to it.

Descriptive analytics says what happened, diagnostic analytics why it happened, predictive analytics what is likely to happen, and prescriptive analytics what to do about it. Descriptive and diagnostic questions, such as how many patients with hypertension came back within 30 days, are SQL over the records and the notes, as in the walkthrough; predictive and prescriptive models are trained on the same tables.

Put them in the text search dictionary. A synonym line such as htn, hypertension makes the two words one term, and a one-way rule such as eliquis => apixaban files a brand name under its generic. The expansion runs on the indexed notes and on every query, so either word finds the other, and ts_lexize shows what a word expands to.

Yes. An inverted index on the note text turns WHERE note_text @@ … into a full-text match, BM25(note_idx.tableoid) in ORDER BY ranks it, and ts_highlight returns the matched passage, in a SELECT that joins and groups like any other. For teams on PostgreSQL's tsvector search, the Postgres full-text search page maps tsvector and ts_rank to an inverted index and BM25.

Give the research role a column grant for only what it may read: GRANT SELECT (note_id, note_text) ON note. The role searches note_idx through those columns, and a query that reads person_id answers permission denied. A view that leaves out the identifier columns works too: index the view and grant SELECT on it alone, and the role searches the view's index while the table stays closed to it.

Yes. An inverted index can be built over a view of read_parquet, on disk or in S3, and over a table in an existing PostgreSQL database reached with CREATE SERVER; the rows stay where they are and only the search postings are built. Such an index is a snapshot that REINDEX INDEX or the reindex_interval option refreshes.

Yes. SereneDB is open source under the Apache-2.0 license, with its source on GitHub, and its Docker image runs the walkthrough above with one command.

Yes, as ordinary tables. The walkthrough's note, visit_occurrence and condition_occurrence use the OMOP CDM's table and column names, with care_site_name, which the CDM keeps in its care_site table, on the visit to keep the example short. An inverted index on note_text makes the notes searchable, and joins to the other tables are plain SQL.

> get started

Get started with SereneDB

Start SereneDB, load your notes next to their visits and codes, and search them in your own clinical vocabulary with SQL from any PostgreSQL client.