Semantic Search with Hugging Face Models and Datasets
Notebook
Note
This notebook can be run on a Free Starter Workspace. To create a Free Starter Workspace navigate to Start using the left nav. You can also use your existing Standard or Premium workspace with this Notebook.
In this notebook you will build a working semantic search engine over movie reviews: Hugging Face turns text into vectors, and SingleStore stores and searches them with a single line of SQL.
Keyword search matches the characters you typed. If a review says "hilarious" and you search for "funny", keyword search finds nothing. Semantic search matches meaning instead. An embedding model converts each piece of text into a list of numbers — a vector — positioned so that texts with similar meanings land near each other. Finding relevant reviews then becomes a geometry question: which stored vectors point in most nearly the same direction as the vector for your query?
SingleStoreDB holds those vectors in a native VECTOR column and compares them with vector functions such as DOT_PRODUCT, executed in parallel across the cluster using Intel SIMD instructions. The search is ordinary SQL, so it composes with joins, filters, and aggregates over the rest of your data, and there is no separate vector database to operate alongside it.
The path through this notebook:
create a database and install the embedding libraries
load a small multilingual embedding model
download 10,000 IMDB movie reviews and sample 100 of them
turn each review into a 384-number vector
store the reviews and their vectors in SingleStore
search them by meaning with one SQL query
1. Create a workspace in your workspace group
S-00 is sufficient.
Action Required
If you have a Free Starter Workspace deployed already, select the database from drop-down menu at the top of this notebook. It updates the connection_url to connect to that database.
2. Create a database named semantic_search
In [1]:
1shared_tier_check = %sql show variables like 'is_shared_tier'2if not shared_tier_check or shared_tier_check[0][1] == 'OFF':3 %sql DROP DATABASE IF EXISTS semantic_search;4 %sql CREATE DATABASE semantic_search;
Action Required
Make sure to select the semantic_search database from the drop-down menu at the top of this notebook. It updates the connection_url which is used by the %%sql magic command and SQLAlchemy to make connections to the selected database.
3. Install and import required libraries
The embedding model comes from Hugging Face and is loaded through the Sentence Transformers library, which wraps a model together with the post-processing needed to turn its raw token-by-token output into one vector per piece of text.
Installing sentence-transformers also brings in transformers (the runtime that executes
the model) and huggingface_hub (which downloads model weights and datasets from the Hub),
so neither needs its own install line.
The version bounds are matched to what the hosted notebook image ships, in particular its PyTorch build. The cell prints the versions it ended up with and then checks that the model runtime initialized, so any mismatch surfaces here rather than several cells later. Loosening these bounds is the most likely way to break the notebook, so change them only if you know the image has moved.
Action Required
If this cell fails, restart the kernel (Kernel > Restart Kernel) and run it again — pip cannot replace modules that the running kernel has already imported.
In [2]:
1%pip install --quiet "sentence-transformers>=5.0,<6" "transformers>=4.41,<5"2 3import json4 5import numpy as np6import pandas as pd7import pyarrow8import sqlalchemy as sa9import singlestoredb as s210import torch11import transformers12from huggingface_hub import hf_hub_download13from sentence_transformers import SentenceTransformer14 15# Fail loudly and legibly if the install moved something the image needs held down.16print(f'torch {torch.__version__} | transformers {transformers.__version__} | '17 f'sentence-transformers {__import__("sentence_transformers").__version__}')18print(f'numpy {np.__version__} | pyarrow {pyarrow.__version__} | pandas {pd.__version__}')19 20assert transformers.utils.is_torch_available(), (21 f'transformers {transformers.__version__} disabled its PyTorch backend on torch '22 f'{torch.__version__}. Every model call below would fail. Pin a transformers release '23 f'that supports this torch, or upgrade torch.'24)25print('PyTorch backend active')
4. Load the embedding model and create a get_embedding() function
We use sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2: a small, fast model
trained so that sentences which paraphrase one another sit close together in vector space,
across more than 50 languages. Every input, long or short, becomes a vector of 384 numbers.
A transformer emits one vector per token, so something has to condense those into a single
vector for the whole text. This model's answer is mean pooling — average the token vectors,
counting only real tokens and not padding. SentenceTransformer reads that choice, and the
model's 128-token input limit, from the configuration published alongside the weights, which
is why we load the model this way rather than assembling the pieces by hand.
In [3]:
1# Sentence Transformers reads the pooling and truncation settings that ship with the2# model, so we get the model's intended behaviour rather than library defaults.3model_name = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"4 5model = SentenceTransformer(model_name)6 7# Ask the model for its output size rather than hard-coding 384, so the CREATE TABLE in8# step 7 always matches whichever model is loaded here.9EMBEDDING_DIM = int(model.encode('dimension probe').shape[0])10 11print(f'{EMBEDDING_DIM} dimensions, up to {model.max_seq_length} tokens per input')
Now a small helper that embeds one string at a time. The result is a numpy array of
little-endian 32-bit floats, which is exactly the byte layout SingleStore's VECTOR(N, F32)
type expects, so it can be stored without any conversion.
normalize_embeddings=True scales every vector to length 1. This is what makes the search
in step 8 work the way you want: for unit-length vectors, the dot product equals the cosine
of the angle between them. Scores then fall in a -1 to 1 range and mean "how similar in
direction", instead of also reflecting how long each vector happens to be.
In [4]:
1def get_embedding(sentence: str) -> np.ndarray:2 """Retrieve a unit-length float32 embedding for the given sentence."""3 embedding = model.encode(sentence, normalize_embeddings=True)4 return embedding.astype('<f4')5 6 7# Sanity check: a normalized vector has length 1.08probe = get_embedding('a delightfully strange film')9print(probe.shape, probe.dtype, f'norm={np.linalg.norm(probe):.4f}')
5. Load the dataset of movie reviews from Hugging Face into a DataFrame
ajaykarthick/imdb-movie-reviews is a collection of IMDB reviews, each labelled positive
or negative. Its splits are stored as plain JSONL files, so hf_hub_download fetches
test.jsonl (10,000 rows) from the Hub and pandas reads it directly. The file is cached
locally, so re-running this cell does not download it again.
We then take a sample of 100 reviews to keep the embedding step quick. random_state fixes
which 100, so re-running the notebook gives you the same sample and therefore the same
search results.
Note
IMDB reviews are long — around 1,270 characters on average, well past this model's 128-token limit — so only the opening of each review is embedded. That is fine for a demo, but a production system would split long documents into chunks and embed each chunk separately.
In [5]:
1# Download the test split and read it with pandas2reviews_file = hf_hub_download(3 repo_id='ajaykarthick/imdb-movie-reviews',4 filename='test.jsonl',5 repo_type='dataset',6)7dataframe = pd.read_json(reviews_file, lines=True)8 9sample_size = 100 # Adjust the desired sample size10random_sample = dataframe.sample(n=sample_size, random_state=42).reset_index(drop=True)11 12print(f'{len(dataframe)} reviews available, sampled {len(random_sample)}')13random_sample.head(3)
6. Generate embeddings of the reviews and add them to your DataFrame
Handing the whole column to model.encode() lets the model embed 32 reviews per forward
pass, which is much faster than calling get_embedding() once per row. normalize_embeddings
is set here for the same reason as in step 4: the stored vectors have to be unit length for
the dot product in step 8 to be a cosine similarity.
In [6]:
1embeddings = model.encode(2 random_sample['review'].tolist(),3 normalize_embeddings=True,4 batch_size=32,5 show_progress_bar=True,6).astype('<f4')7 8random_sample['review_embeddings'] = list(embeddings)9 10print(f'{len(embeddings)} embeddings of {embeddings.shape[1]} dimensions')
7. Insert data into SingleStore
The embeddings go into a VECTOR(384, F32) column, SingleStore's native vector type. It
validates the dimension of every value on insert, prints readably in query results, and can
be backed by a vector index. NOT NULL is required if you want to add such an index later,
so it is worth declaring now.
Because the column type is the point, we write the CREATE TABLE ourselves rather than
letting pandas infer a schema. Each vector is then sent as a hex string and converted by
UNHEX, which hands SingleStore the packed 32-bit floats exactly as the model produced
them, with no text round-trip through the numbers.
The second cell echoes the resulting table definition back, so you can see the vector column as the database understands it.
In [7]:
1with s2.create_engine().connect() as conn:2 conn.execute(sa.text('DROP TABLE IF EXISTS reviews'))3 conn.execute(sa.text(f'''4 CREATE TABLE reviews (5 review TEXT,6 label BIGINT,7 review_embeddings VECTOR({EMBEDDING_DIM}, F32) NOT NULL8 )9 '''))10 11 conn.execute(12 sa.text('''13 INSERT INTO reviews (review, label, review_embeddings)14 VALUES (:review, :label, UNHEX(:embedding))15 '''),16 [17 {18 'review': row.review,19 'label': int(row.label),20 'embedding': row.review_embeddings.tobytes().hex(),21 }22 for row in random_sample.itertuples()23 ],24 )25 conn.commit()26 27print(f'Inserted {len(random_sample)} reviews')
In [8]:
1# Create a database connection and display the `CREATE TABLE` statement2conn = s2.connect()3 4conn.show.create_table('reviews')
8. Run the semantic search algorithm with just one line of SQL
Your search string goes through the same model as the reviews did, which puts it in the same vector space. The query below then compares that one vector against all 100 stored vectors and returns the five nearest.
<*> is the infix operator for DOT_PRODUCT. Because every vector is unit length, the dot
product is the cosine similarity between your query and the review: scores near 1 mean
closely related in meaning, near 0 unrelated. SingleStore compiles the query to machine code
and evaluates the dot products with SIMD instructions, spread in parallel across the cluster.
Try a search that shares no words at all with any review — movies about space travel, or
the same idea written in another language, since this model is multilingual. Matches you get
without a single word in common are the whole point of embedding search.
With 100 rows this is an exact scan of the table, which is the right choice at this size. At millions of rows you would add an approximate vector index to the same column, and the query itself would not change:
ALTER TABLE reviews ADD VECTOR INDEX ivf (review_embeddings)
INDEX_OPTIONS '{"index_type":"IVF_PQFS", "metric_type":"DOT_PRODUCT"}';
In [9]:
1searchstring = input('Please enter a search string:')2 3search_embedding = get_embedding(searchstring).tobytes().hex()4 5results = %sql SELECT review, review_embeddings <*> UNHEX('{{search_embedding}}') AS score \6 FROM reviews ORDER BY score DESC LIMIT 5;7 8print()9for i, res in enumerate(results):10 review = ' '.join(res[0].split())11 excerpt = review[:300] + ('...' if len(review) > 300 else '')12 print(f'{i + 1}: [score {res[1]:.3f}] {excerpt}\n')
9. Clean up
Action Required
If you created a new database in your Standard or Premium Workspace, you can drop the database by running the cell below. Note: this will not drop your database for Free Starter Workspaces. To drop a Free Starter Workspace, terminate the Workspace using the UI.
In [10]:
1shared_tier_check = %sql show variables like 'is_shared_tier'2if not shared_tier_check or shared_tier_check[0][1] == 'OFF':3 %sql DROP DATABASE IF EXISTS semantic_search;

Details
About this Template
Use Hugging Face to create embeddings and run semantic search using dot product in SingleStoreDB.
This Notebook can be run in Shared Tier, Standard and Enterprise deployments.
Tags
See Notebook in action
Launch this notebook in SingleStore and start executing queries instantly.
License
This Notebook has been released under the Apache 2.0 open source license.