DuckSemantics is a DuckDB extension for semantic computation over local files. It inspects GGUF model files, tokenizes text, runs supported CPU models, and scores vectors in ordinary SQL. The ducksemantics R package builds and loads the extension. It also imports ontology and knowledge-base records, with their release identities.
One rule runs through this walkthrough: a retrieval score is not a source fact. Imported records keep their provider, release, and receipt. Scores and model output go into their own relations, so they never overwrite a source record.
The walkthrough below is a single executed session. Every output comes from running it against a real EmbeddingGemma model and the bundled source fixtures.
| Step | What it shows |
|---|---|
| 1 | Read the model file’s metadata and tensor layout |
| 2 | Turn text into the model’s token ids |
| 3 | Produce vectors and check their type, width, and norm |
| 4 | Import Mondo and DisMech records with release receipts |
| 5 | Score DisMech mechanisms against a question in SQL |
| 6 | Join the top result back to citations and releases |
The R package lives in r/ducksemantics. It bundles the C sources and
builds the extension during installation. It does not download headers
or model weights. Linux needs a C11 compiler and GNU make. The Linux
x86-64 build is verified locally; the distribution pipeline tests Linux
x86-64 and ARM64. macOS, Windows, and webR await native portability
verification.
From a repository checkout, with the R dependencies in
r/ducksemantics/DESCRIPTION installed (including duckdb):
make r-bootstrap
R CMD INSTALL r/ducksemanticsFor an isolated development library, use make r-install and start R
with R_LIBS=build/r/library. To build only the standalone extension,
run make and load build/ducksemantics.duckdb_extension in a DuckDB
CLI started with -unsigned. The extension uses DuckDB’s stable C
extension ABI 1.2.0.
library(ducksemantics)
conn <- ducksemantics_connect()ducksemantics_connect() opens an isolated DuckDB database, allows the
locally built unsigned extension, and loads it. These settings apply
only to that connection. The R and SQL chunks below share it. A SQL
chunk refers to an R variable as ?name, and the value is quoted
safely.
SELECT extension_name, loaded
FROM duckdb_extensions()
WHERE extension_name = 'ducksemantics';| extension_name | loaded |
|---|---|
| ducksemantics | TRUE |
Package installation never bundles or downloads model weights. You
supply a local file through DUCKSEMANTICS_EMBED_MODEL. This README
needs EmbeddingGemma-300M in GGUF format (for example
embeddinggemma-300M-Q8_0.gguf). If the variable is not set, the render
stops here.
embed_model <- Sys.getenv("DUCKSEMANTICS_EMBED_MODEL")
stopifnot(
"Set DUCKSEMANTICS_EMBED_MODEL to a local EmbeddingGemma-300M GGUF file." = nzchar(embed_model),
"DUCKSEMANTICS_EMBED_MODEL must name an existing local file." = file.exists(embed_model)
)
embed_model_sha256 <- digest::digest(file = embed_model, algo = "sha256")
basename(embed_model)
#> [1] "embeddinggemma-300M-Q8_0.gguf"
embed_model_sha256
#> [1] "b5ce9d77a3fc4b3b39ccb5643c36777911cc4eb46a66962eadfa3f5f60490d63"A GGUF file holds metadata, a vocabulary, and tensor payloads. You can read all three without running the model. The architecture key tells you which kernel can run it.
SELECT key, value
FROM gguf_metadata(?embed_model)
WHERE key IN ('general.architecture', 'general.name',
'gemma-embedding.embedding_length',
'gemma-embedding.block_count', 'tokenizer.ggml.model');| key | value |
|---|---|
| general.architecture | gemma-embedding |
| general.name | Embeddinggemma 300m |
| gemma-embedding.block_count | 24 |
| gemma-embedding.embedding_length | 768 |
| tokenizer.ggml.model | llama |
SELECT ggml_type, count(*) AS tensors,
round(sum(n_bytes) / 2^20, 1) AS mebibytes
FROM gguf_tensors(?embed_model)
GROUP BY ggml_type
ORDER BY mebibytes DESC;| ggml_type | tensors | mebibytes |
|---|---|---|
| q8_0 | 171 | 311.6 |
| f32 | 145 | 0.3 |
The package also includes small synthetic GGUF files. The offline tests use them to exercise the parser and tokenizers. They are not trained models. The header shows the difference:
parser_fixture <- system.file("extdata", "valid.gguf",
package = "ducksemantics", mustWork = TRUE)SELECT 'caller model' AS file, tensor_count, metadata_count, file_bytes
FROM gguf_header(?embed_model)
UNION ALL
SELECT 'synthetic fixture', tensor_count, metadata_count, file_bytes
FROM gguf_header(?parser_fixture);| file | tensor_count | metadata_count | file_bytes |
|---|---|---|---|
| caller model | 316 | 41 | 333590944 |
| synthetic fixture | 3 | 18 | 960 |
Tokenization uses only the vocabulary and tokenizer metadata, not the
weights. With add_special = true, the Gemma profile adds BOS and EOS.
Joining each id to tokenizer.ggml.tokens shows the stored piece; ▁
marks a word boundary.
WITH encoded AS (
SELECT semantic_tokenize(?embed_model, 'Marfan syndrome aortic dilation', true) AS ids
),
pieces AS (
SELECT generate_subscripts(ids, 1) AS position, unnest(ids) AS id
FROM encoded
)
SELECT position, id, vocab.value AS piece
FROM pieces
JOIN gguf_metadata_array(?embed_model, 'tokenizer.ggml.tokens') AS vocab
ON vocab.array_index = pieces.id
ORDER BY position;| position | id | piece |
|---|---|---|
| 1 | 2 | |
| 2 | 9585 | Mar |
| 3 | 23935 | fan |
| 4 | 22595 | ▁syndrome |
| 5 | 80838 | ▁aortic |
| 6 | 58692 | ▁dilation |
| 7 | 1 |
SELECT semantic_detokenize(
?embed_model,
semantic_tokenize(?embed_model, 'Marfan syndrome aortic dilation', true)
) AS round_trip;| round_trip |
|---|
| Marfan syndrome aortic dilation |
The decoder omits control tokens, so the text round-trips.
semantic_embed adds the task prefix and tokenizes internally. You do
not pass these ids to it.
semantic_embed(path, text, task, dimensions, normalize) runs the
EmbeddingGemma forward pass. The task selects the input prefix. Here,
retrieval_query means task: search result | query:. The model
returns 768 values. A smaller dimensions keeps the leading values, and
normalize then scales the subvector to unit length.
embed_task <- "retrieval_query"
embed_dimensions <- 256LCREATE TABLE short_texts AS
SELECT text_id, text,
semantic_embed(?embed_model, text, ?embed_task, ?embed_dimensions, true) AS embedding
FROM (VALUES
('marfan', 'Marfan syndrome'),
('fibrillin', 'defective fibrillin-1 microfibrils'),
('diabetes', 'insulin resistance in type 2 diabetes')
) AS t(text_id, text);Each result is a FLOAT[] list. Check its type, width, and norm before
using it:
SELECT text_id,
typeof(embedding) AS type,
len(embedding) AS dimensions,
sqrt(list_dot_product(embedding, embedding)) AS norm,
list_transform(embedding[1:4], x -> round(x, 4))::VARCHAR AS first_values
FROM short_texts
ORDER BY text_id;vector_shape
#> text_id type dimensions norm first_values
#> 1 diabetes FLOAT[] 256 0.9999999 [-0.1815, 0.0743, -0.0354, 0.0624]
#> 2 fibrillin FLOAT[] 256 1.0000000 [-0.178, -0.0132, -0.0081, -0.0038]
#> 3 marfan FLOAT[] 256 1.0000001 [-0.233, -0.0015, 0.0897, -0.1025]
stopifnot(vector_shape$type == "FLOAT[]",
vector_shape$dimensions == embed_dimensions,
abs(vector_shape$norm - 1) < 1e-5)For unit vectors, the dot product is the cosine similarity. These are raw similarities between the three texts, not calibrated probabilities:
SELECT a.text_id AS text_a, b.text_id AS text_b,
round(list_dot_product(a.embedding, b.embedding), 4) AS cosine
FROM short_texts a
JOIN short_texts b ON a.text_id < b.text_id
ORDER BY cosine DESC;| text_a | text_b | cosine |
|---|---|---|
| fibrillin | marfan | 0.3841 |
| diabetes | marfan | 0.2886 |
| diabetes | fibrillin | 0.2468 |
Vectors need the same model, width, and normalization, with the model’s paired query/document task conventions. The tables below record those settings next to each vector.
Disease labels change, and terms become obsolete. To answer “what did the source say at a given date?”, the source catalog chooses a release by provider availability time. It does not sort version strings or commit dates. Each import checks the file’s bytes against a SHA-256 receipt before it reads them.
The bundled fixtures are real upstream content. The Mondo fixture holds
three complete term stanzas from release v2026-10-06. The DisMech
fixture is the verbatim Marfan syndrome record at commit fac6186. The
receipts record the upstream digest as well as the fixture digest.
fixture <- function(name) {
system.file("extdata", name, package = "ducksemantics", mustWork = TRUE)
}
receipts <- read.csv(fixture("source-releases.csv"), stringsAsFactors = FALSE)
receipts$available_at <- as.POSIXct(
receipts$available_at, format = "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"
)
catalog <- ducksemantics_source_release_catalog(receipts)
mondo_release <- ducksemantics_source_release_select(
catalog, "Mondo", as_of = as.POSIXct("2026-10-07", tz = "UTC")
)
mondo <- ducksemantics_mondo_import(
fixture("mondo-subset.obo"), mondo_release,
source_id = mondo_release$source_id,
source_uri = mondo_release$source_uri,
sha256 = mondo_release$sha256
)
dismech_release <- ducksemantics_source_release_select(
catalog, "DisMech", release_id = "fac61860115b1fdb194de4f4cacef3a8f6a398af"
)
dismech <- ducksemantics_dismech_import(
fixture("dismech-marfan.yaml"), dismech_release,
source_id = dismech_release$source_id,
source_uri = dismech_release$source_uri,
sha256 = dismech_release$sha256
)
DBI::dbWriteTable(conn, "mondo_terms", mondo$terms)
DBI::dbWriteTable(conn, "mondo_mappings", mondo$mappings)
DBI::dbWriteTable(conn, "mondo_obsolescence", mondo$obsolescence)
DBI::dbWriteTable(conn, "dismech_fields", dismech$fields)Every imported row carries its release and receipt:
SELECT provider_id, release_id, receipt_id, count(*) AS rows
FROM (
SELECT provider_id, release_id, receipt_id FROM mondo_terms
UNION ALL
SELECT provider_id, release_id, receipt_id FROM dismech_fields
)
GROUP BY ALL
ORDER BY provider_id;| provider_id | release_id | receipt_id | rows |
|---|---|---|---|
| DisMech | fac61860115b1fdb194de4f4cacef3a8f6a398af | dismech-fac61860-marfan | 1412 |
| Mondo | v2026-10-06 | mondo-v2026-10-06-subset | 3 |
The Mondo subset includes an obsolete term and its replacement:
SELECT t.node_id, t.label, t.is_obsolete, o.value AS replaced_by
FROM mondo_terms t
LEFT JOIN mondo_obsolescence o
ON o.node_id = t.node_id AND o.tag = 'replaced_by'
ORDER BY t.node_id;| node_id | label | is_obsolete | replaced_by |
|---|---|---|---|
| MONDO:0000002 | obsolete 46,XX sex reversal | TRUE | MONDO:0009299 |
| MONDO:0007947 | Marfan syndrome | FALSE | NA |
| MONDO:0009299 | 46 XX gonadal dysgenesis | FALSE | NA |
A Mondo xref alone does not mean exact equivalence. The importer keeps
each mapping assertion as written, so an explicit SKOS predicate stays
visible:
SELECT tag, value
FROM mondo_mappings
WHERE node_id = 'MONDO:0007947'
AND value = 'skos:exactMatch OMIM:154700';| tag | value |
|---|---|
| property_value | skos:exactMatch OMIM:154700 |
DisMech is imported as one row per scalar field. Each field has a JSON pointer with zero-based array indices. The pointer locates a field in the imported record. It is not a new upstream accession.
The Marfan record describes six pathophysiology mechanisms. This query gets each description and its name from the source fields:
CREATE TABLE mechanisms AS
SELECT regexp_extract(d.field_path, '^(/pathophysiology/[0-9]+)/', 1) AS field_prefix,
n.value AS mechanism,
d.value AS description,
d.provider_id, d.release_id, d.source_id, d.receipt_id, d.source_row_id
FROM dismech_fields d
JOIN dismech_fields n
ON n.provider_id = d.provider_id AND n.release_id = d.release_id
AND n.source_id = d.source_id
AND n.field_path = regexp_replace(d.field_path, '/description$', '/name')
WHERE regexp_matches(d.field_path, '^/pathophysiology/[0-9]+/description$');Documents use the retrieval_document task, and questions use
retrieval_query. Both use the same model, width, and normalization.
The vector table stores the model digest and settings. It refers to the
source rows by source_row_id and does not copy them.
CREATE TABLE mechanism_vectors AS
SELECT provider_id, release_id, source_id, source_row_id,
?embed_model_sha256 AS model_sha256,
'retrieval_document' AS task,
?embed_dimensions AS dimensions, true AS normalized,
semantic_embed(?embed_model, description, 'retrieval_document',
?embed_dimensions, true) AS embedding
FROM mechanisms;question <- "Why does the aortic wall weaken and dilate?"Scores go into their own relation, with the question that produced them:
CREATE TABLE mechanism_scores AS
WITH query AS (
SELECT semantic_embed(?embed_model, ?question, ?embed_task,
?embed_dimensions, true) AS vector
)
SELECT ?question AS question, ?embed_task AS query_task,
v.provider_id, v.release_id, v.source_id, v.source_row_id,
v.model_sha256, v.dimensions, v.normalized,
list_dot_product(query.vector, v.embedding) AS cosine
FROM mechanism_vectors v, query;SELECT m.mechanism, round(s.cosine, 4) AS cosine
FROM mechanism_scores s
JOIN mechanisms m USING (provider_id, release_id, source_id, source_row_id)
ORDER BY s.cosine DESC;| mechanism | cosine |
|---|---|
| Extracellular Matrix Remodeling | 0.7187 |
| Vascular Inflammation | 0.4436 |
| Impaired Mechanotransduction | 0.4220 |
| Mitochondrial Dysfunction | 0.4121 |
| Dysregulated TGF-beta Signaling | 0.3600 |
| FBN1 Gene Mutation | 0.2979 |
The ranking tells you which source passage to read first. It does not show that the mechanism is correct. Those claims come from the record and its citations.
A score says nothing about evidence. Put the source’s own citation count next to each score:
CREATE TABLE mechanism_evidence AS
SELECT m.provider_id, m.release_id, m.source_id, m.source_row_id,
m.mechanism, m.field_prefix, e.field_path, e.value AS reference
FROM mechanisms m
JOIN dismech_fields e
ON e.provider_id = m.provider_id AND e.release_id = m.release_id
AND e.source_id = m.source_id
AND regexp_matches(e.field_path,
'^' || m.field_prefix || '/evidence/[0-9]+/reference$');SELECT m.mechanism, round(s.cosine, 4) AS cosine,
count(e.reference) AS cited_references
FROM mechanism_scores s
JOIN mechanisms m USING (provider_id, release_id, source_id, source_row_id)
LEFT JOIN mechanism_evidence e USING (provider_id, release_id, source_id, source_row_id)
GROUP BY m.mechanism, s.cosine
ORDER BY s.cosine DESC;| mechanism | cosine | cited_references |
|---|---|---|
| Extracellular Matrix Remodeling | 0.7187 | 0 |
| Vascular Inflammation | 0.4436 | 0 |
| Impaired Mechanotransduction | 0.4220 | 0 |
| Mitochondrial Dysfunction | 0.4121 | 0 |
| Dysregulated TGF-beta Signaling | 0.3600 | 0 |
| FBN1 Gene Mutation | 0.2979 | 8 |
In this record, only one mechanism has its own evidence entries. The
closest passage has none. Retrieval found relevant text, but it cannot
add a citation the source does not contain. The best-scoring mechanism
that does cite evidence has these references:
WITH best AS (
SELECT m.provider_id, m.release_id, m.source_id, m.source_row_id
FROM mechanism_scores s
JOIN mechanisms m USING (provider_id, release_id, source_id, source_row_id)
WHERE EXISTS (
SELECT 1 FROM mechanism_evidence e
WHERE e.provider_id = m.provider_id AND e.release_id = m.release_id
AND e.source_id = m.source_id AND e.source_row_id = m.source_row_id
)
ORDER BY s.cosine DESC
LIMIT 1
)
SELECT e.mechanism, e.reference, s.value AS supports, e.field_path
FROM mechanism_evidence e
JOIN best USING (provider_id, release_id, source_id, source_row_id)
LEFT JOIN dismech_fields s
ON s.provider_id = e.provider_id AND s.release_id = e.release_id
AND s.source_id = e.source_id
AND s.field_path = regexp_replace(e.field_path, '/reference$', '/supports')
ORDER BY e.field_path;| mechanism | reference | supports | field_path |
|---|---|---|---|
| FBN1 Gene Mutation | PMID:8180508 | SUPPORT | /pathophysiology/0/evidence/0/reference |
| FBN1 Gene Mutation | PMID:39096853 | SUPPORT | /pathophysiology/0/evidence/1/reference |
| FBN1 Gene Mutation | PMID:17657824 | SUPPORT | /pathophysiology/0/evidence/2/reference |
| FBN1 Gene Mutation | PMID:12938084 | SUPPORT | /pathophysiology/0/evidence/3/reference |
| FBN1 Gene Mutation | PMID:24555249 | SUPPORT | /pathophysiology/0/evidence/4/reference |
| FBN1 Gene Mutation | PMID:36004597 | SUPPORT | /pathophysiology/0/evidence/5/reference |
| FBN1 Gene Mutation | PMID:25729264 | SUPPORT | /pathophysiology/0/evidence/6/reference |
| FBN1 Gene Mutation | PMID:42350581 | SUPPORT | /pathophysiology/0/evidence/7/reference |
The DisMech record names its disease with a Mondo identifier. Joining that identifier to the Mondo import connects two separately versioned sources. Each side keeps its own release and receipt:
SELECT d.value AS disease_id,
t.label AS mondo_label,
d.release_id AS dismech_release,
t.release_id AS mondo_release,
d.receipt_id AS dismech_receipt,
t.receipt_id AS mondo_receipt
FROM dismech_fields d
JOIN mondo_terms t ON t.node_id = d.value
WHERE d.field_path = '/disease_term/term/id';| disease_id | mondo_label | dismech_release | mondo_release | dismech_receipt | mondo_receipt |
|---|---|---|---|---|---|
| MONDO:0007947 | Marfan syndrome | fac61860115b1fdb194de4f4cacef3a8f6a398af | v2026-10-06 | dismech-fac61860-marfan | mondo-v2026-10-06-subset |
Source facts, retrieval scores, and model-generated proposals stay in separate relations. The DisMech importer does not validate a DisMech contribution, and it does not check the cited papers. Model-generated hypotheses and reviewed contribution export are open work. They need their own relations and an executable Bench consumer before evaluation (issue #1).
semantic_maxsim(query, document) scores ColBERT-style token vectors.
For each query token it takes the best dot product with any document
token, then adds these maxima:
MaxSim(Q, D) = sum over query tokens i of max over document tokens j of dot(q_i, d_j)
This is a raw dot-product sum. Normalized inputs make each dot product a
cosine similarity; the sum is neither a single cosine nor a calibrated
probability. Both inputs must have the same vector width. The toy
vectors below check the arithmetic only. They are not a biomedical
result. Two unit query vectors that match exactly score 2. A document
that matches only one of them scores 1.
WITH documents(document_id, vectors) AS (
VALUES
('both', [[1.0, 0.0], [0.0, 1.0]]::FLOAT[][]),
('first', [[1.0, 0.0]]::FLOAT[][]),
('away', [[-1.0, 0.0], [0.0, -1.0]]::FLOAT[][])
)
SELECT document_id,
semantic_maxsim([[1.0, 0.0], [0.0, 1.0]], vectors) AS score
FROM documents
ORDER BY score DESC, document_id;| document_id | score |
|---|---|
| both | 2 |
| first | 1 |
| away | 0 |
DBI::dbDisconnect(conn, shutdown = TRUE)- Native kernel reference: SQL signatures, the EmbeddingGemma, LFM2.5-ColBERT-350M, and LFM2-MoE profiles, tokenizer limits, numerical tolerances, and oracle commands.
- Semantic relation contracts: HPO validation, dated Monarch pack projections, and append-only literature snapshots.
Model support depends on the architecture. The extension does not run arbitrary GGUF files. Unsupported architectures or tensor layouts raise errors.
Rendering needs knitr, rmarkdown, Pandoc, and a local EmbeddingGemma
GGUF file. The standalone SQL suite also needs the DuckDB CLI on PATH.
make test # Native C and standalone DuckDB SQL tests
make r-test # Package build, install, and tinytest tests
make r-check # R CMD check of the source tarball
make r-sync-check # Check that bundled C sources match the canonical tree
git submodule update --init extension-ci-tools
make configure release test_release # Distribution artifact and C/CLI contracts
DUCKSEMANTICS_EMBED_MODEL=/path/to/embeddinggemma-300M-Q8_0.gguf make readmemake readme runs every chunk in this document and stops on the first
error. It writes README.md. It then builds a static HTML copy of
README.md and docs/ in build/site, without running the chunks
again. The offline test suites use only the synthetic fixtures and need
no model weights. R-package CI stages a separate, revision-pinned and
SHA-256-verified reference model for this executed document; weights are
governed by the Gemma Terms of Use and are not included in published
package artifacts.
The distribution workflow uses the pinned extension-ci-tools submodule
and its reusable stable-C-ABI pipeline. Native Make and CMake share
src/sources.list. Release and debug test targets run the existing C
oracles and CLI SQL suite against the distribution artifact, with no
wheel-based test skip. R source-package installation remains independent
of the CI helpers.
Mondo’s fixture is distributed under CC BY 4.0. DisMech’s fixture keeps
its upstream BSD 3-Clause notice. Source URIs, release identities,
selection details, and SHA-256 receipts are bundled with the fixtures.
See r/ducksemantics/inst/LICENSE.note for attribution.