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 | shWhat 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.
- 0172-year-old man with a history of HTN and DM presents with chest pain radiating to the left arm. Troponin elevated.
- 02NSTEMI treated medically. Hypertension controlled on lisinopril. Discharged on aspirin, atorvastatin and metoprolol.
- 03New onset atrial fibrillation with rapid ventricular response. Started Eliquis. Denies chest pain.
- 04AFib rate controlled on metoprolol. Continue apixaban. History of CVA in 2021.
- 05Hypertension poorly controlled, BP 182/110. Headache, no chest pain.
- 06Admitted with SOB and bilateral leg edema. Known HTN. Started furosemide.match
- 07Type 2 diabetes with A1c 9.1. Metformin increased to 1000 mg twice daily.
- 08Fall at home, distal radius fracture. No head injury.
- 09Dyspnea on exertion and orthopnea. HTN and heart failure with reduced ejection fraction.match
- 10Readmitted with worsening dyspnea. Diuresed with IV furosemide. Hypertension stable.match
- 11DM on metfomin, glucose 210. Reports polyuria.
- 12Sudden left-sided weakness and slurred speech. Code stroke called, CT negative for bleed.
- 13Follow-up after NSTEMI. Chest pain on exertion, relieved by rest. HTN well controlled.
- 14Community-acquired pneumonia, started ceftriaxone. Telemetry shows atrial fibrillation, rate controlled.
SELECT note_id, person_id, note_text FROM note_idx WHERE note_text @@ 'dyspnea' ORDER BY note_id;
| note_id | person_id | note_text |
|---|---|---|
| 6 | 103 | Admitted with SOB and bilateral leg edema. Known HTN. Started furosemide. |
| 9 | 106 | Dyspnea on exertion and orthopnea. HTN and heart failure with reduced ejection fraction. |
| 10 | 106 | Readmitted with worsening dyspnea. Diuresed with IV furosemide. Hypertension stable. |
SELECT note_id, person_id, note_text FROM note_idx WHERE note_text @@ ts_levenshtein('metfromin', 1) ORDER BY note_id;
| note_id | person_id | note_text |
|---|---|---|
| 7 | 104 | Type 2 diabetes with A1c 9.1. Metformin increased to 1000 mg twice daily. |
| 11 | 107 | DM on metfomin, glucose 210. Reports polyuria. |
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.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;
| note_id | person_id | note_text |
|---|---|---|
| 1 | 101 | 72-year-old man with a history of HTN and DM presents with chest pain radiating to the left arm. Troponin elevated. |
| 13 | 101 | Follow-up after NSTEMI. Chest pain on exertion, relieved by rest. HTN well controlled. |
&& !! removes every note that contains either denial, which leaves notes 1 and 13.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;
| unit | patients | visits | avg_los_days | back_in_30d |
|---|---|---|---|---|
| cardiology | 2 | 4 | 3.0 | 1 |
| emergency | 1 | 1 | 0.0 | 1 |
| medicine | 1 | 1 | 4.0 | 0 |
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.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;
| note_id | bm25 | snippet |
|---|---|---|
| 4 | 0.850 | History of <b>CVA</b> in 2021 |
| 12 | 0.799 | Code <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.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.
| Need | How SereneDB does it | Looks like |
|---|---|---|
| Search clinical text | Notes are full-text indexed next to their ids and dates, and BM25 ranks the matches. Docs → | BM25(note_idx.tableoid) |
| Abbreviations and synonyms | A 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 …') |
| Misspellings | Each query word matches the indexed words within an edit distance, swaps of neighboring letters included. Docs → | note_text @@ ts_levenshtein('metfromin', 1) |
| Phrases and negation | A phrase matches its words in order, and !! drops the notes that contain a denial of it. Docs → | ts_phrase('chest pain') && !!ts_any([…]) |
| Cohorts | The 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 text | Diagnosis codes stay in their own table, and a LIKE prefix follows the ICD-10 hierarchy. Docs → | condition_source_value LIKE 'I48%' |
| Research search by meaning | Embeddings 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 role | Column 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 lives | An 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 use | SereneDB 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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
What clinical text needs from one engine
clinical_enSynonym groups and brand rules expand in the index and in every query, so either word finds the other.
expand_solr_synonymsMatches join to visits and codes like any table, so a cohort is one SELECT.
FROM note_idx JOIN …Column grants and views hold for index scans: a role searches only what it may read.
GRANT SELECT (…)SearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.
otel_logs_1b · 26.09.1Tutorial: 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.
docker run -d --name serenedb -p 7890:7890 \ -e POSTGRES_PASSWORD=<password> serenedb/serenedb:26.11.2 psql -h localhost -p 7890 -U postgres
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');
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;
-- 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;
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;
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 ].
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;
| note_date | note_title | snippet |
|---|---|---|
| 2026-10-09 | Admission note | Readmitted with worsening [dyspnea] |
| 2026-09-22 | Admission note | [Dyspnea] on exertion and orthopnea |
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.
OLAP database →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;
| visit | person_id | af_notes | i48_codes |
|---|---|---|---|
| 2 | 102 | 2 | I48.91 |
| 12 | 109 | 1 | none |
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.
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;
| abstract_id | title | rrf |
|---|---|---|
| 2 | Intensive versus standard blood pressure targets in adults over 75 | 0.0323 |
| 1 | Home blood pressure monitoring after hospital discharge | 0.0320 |
| 7 | Salt reduction and blood pressure in community programs | 0.0315 |
| 3 | Deprescribing antihypertensives in frail older patients | 0.0161 |
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 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.