Working with Vector Data
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.
A vector is a list of numbers that places content in space so that distance means similarity. That is the whole idea -- types, operators and indexes exist to make it fast at scale.
We start with four hand-written vectors you can check with a calculator, then load 10,000 real customer questions, then run a search that finds the right answer using none of the words you typed.
What's in this notebook:
What a vector actually is.
Measuring similarity, correctly.
Turning real text into vectors.
Loading 10,000 real questions.
Where keyword search falls down.
Searching at scale with a vector index.
Vectors are just another column.
Clean up.
Questions?
Reach out to us through our forum.
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.
Create a database for the examples below.
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 vector_data;4 %sql CREATE DATABASE vector_data;
Action Required
Make sure a database is selected in the drop-down menu at the top of this notebook -- vector_data on a Standard or Premium Workspace, or your existing database on a Free Starter Workspace, where the cell above does not create one. It sets the connection_url used by the %%sql magic command and by singlestoredb.
1. What a vector actually is.
Let's describe four drinks with three numbers each -- caffeine, sweetness, fizz, on a 0-1 scale. They are hand-written so you can check every result below yourself.
The column type is VECTOR(3): a first-class type, not a BLOB. Values go in as plain JSON arrays.
In [2]:
1%%sql2DROP TABLE IF EXISTS drinks;3 4CREATE TABLE drinks (5 name VARCHAR(32),6 v VECTOR(3) NOT NULL,7 SORT KEY ()8);9 10INSERT INTO drinks (name, v) VALUES11 ('espresso', '[0.9, 0.1, 0.0]'),12 ('latte', '[0.6, 0.4, 0.0]'),13 ('cola', '[0.3, 0.9, 1.0]'),14 ('sparkling water', '[0.0, 0.0, 1.0]');
Now search. An iced coffee is caffeinated, slightly sweet, not fizzy: [0.7, 0.3, 0.0]. Two operators compare it against every row:
<->isEUCLIDEAN_DISTANCE-- straight-line distance. Lower is more similar.<*>isDOT_PRODUCT-- higher is more similar.
Note the shape: no special syntax, just ORDER BY ... LIMIT. Vector search is ordinary SQL.
In [3]:
1%%sql2SET @q = '[0.7, 0.3, 0.0]' :> VECTOR(3);3 4SELECT name,5 ROUND(v <-> @q, 4) AS dist_euclidean,6 ROUND(v <*> @q, 4) AS score_dot7FROM drinks8ORDER BY dist_euclidean;
2. Measuring similarity, correctly.
Look at those two columns -- they disagree.
<-> ranks latte closest (0.1414 vs 0.2828), but <*> scores espresso highest (0.66 vs 0.54). Same data, same query, different winner.
Espresso wins on dot product because it is longer, not because it points in a more similar direction -- magnitude 0.9055 against latte's 0.7211, and dot product rewards length. For search we want direction, not size.
The fix is normalization: scale every vector to length 1. The docs are explicit -- "vectors must be normalized to length 1 before using the DOT_PRODUCT function to obtain the cosine similarity metric" -- and recommend doing it before the data is saved.
In [4]:
1%%sql2ALTER TABLE drinks ADD COLUMN v_unit VECTOR(3);3 4UPDATE drinks SET v_unit = CASE name5 WHEN 'espresso' THEN '[0.9939, 0.1104, 0.0]'6 WHEN 'latte' THEN '[0.8321, 0.5547, 0.0]'7 WHEN 'cola' THEN '[0.2176, 0.6529, 0.7255]'8 WHEN 'sparkling water' THEN '[0.0, 0.0, 1.0]'9END;10 11SELECT name,12 ROUND(v_unit <*> ('[0.9191, 0.3939, 0.0]' :> VECTOR(3)), 4) AS cosine_similarity13FROM drinks14ORDER BY cosine_similarity DESC;
Now <*> agrees with <->: latte 0.9833, espresso 0.9570. With both sides at unit length, dot product is cosine similarity, 1 for identical direction, -1 for opposite.
So normalize once, at write time. <*> then gives you cosine similarity for free on every query, and it is the cheaper operator. This one step prevents the most common surprise in vector search: scores that rank by document size instead of meaning.
3. Turning real text into vectors.
Hand-written numbers made the geometry visible, but nobody scores documents by hand. An embedding model does it: text in, a fixed-length list of numbers out, trained so that similar meaning lands in a similar direction.
We will use all-MiniLM-L6-v2 -- 384 dimensions, runs locally on this workspace. No API key, and no text leaves your deployment.
In [5]:
1%pip install --quiet "sentence-transformers>=5.0,<6" "transformers>=4.41,<5"
In [6]:
1import numpy as np2import transformers3from sentence_transformers import SentenceTransformer4 5# Fails loudly here rather than silently five cells later.6assert transformers.utils.is_torch_available(), 'transformers disabled its torch backend'7 8model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')9print(f'transformers {transformers.__version__}, torch backend OK')10print(f'dimensions: {model.get_sentence_embedding_dimension()}')
Here is what one sentence looks like as a vector. Note normalize_embeddings=True -- that is section 2's lesson applied at write time, so every vector leaves the model at length 1.
In [7]:
1sample = model.encode('can I use my card in another country?', normalize_embeddings=True)2 3print(f'shape: {sample.shape}')4print(f'length: {np.linalg.norm(sample):.6f}')5print(f'first 8 of 384: {np.round(sample[:8], 4)}')
Those 384 numbers mean nothing one at a time -- there is no "caffeine" dimension to point at. What matters is that the whole arrangement puts this sentence near others about using a card abroad, and far from ones about forgotten passcodes.
4. Loading 10,000 real questions.
Four hand-written rows were enough to see the geometry, but four rows never needed a database — you could rank them with a calculator. Vector search earns its keep at a scale you cannot eyeball, so here are 10,000 genuine customer-support questions sent to a bank, each labelled with the intent behind it — real phrasing, real typos.
In [8]:
1import pandas as pd2 3URL = (4 'https://raw.githubusercontent.com/PolyAI-LDN/task-specific-datasets'5 '/master/banking_data/train.csv'6)7tickets = pd.read_csv(URL)8 9print(f'{len(tickets):,} questions across {tickets["category"].nunique()} categories')10tickets.head()
In [9]:
1embeddings = model.encode(2 tickets['text'].tolist(),3 normalize_embeddings=True,4 batch_size=64,5 show_progress_bar=True,6)7print(f'{embeddings.shape[0]:,} vectors of {embeddings.shape[1]} dimensions')
VECTOR(384) stores each embedding beside the text it came from and its category -- one row, one table, no second system to keep in sync. SORT KEY makes it columnstore, which section 6 requires: vector indexes can only be built on columnstore tables.
Rows go in batched, each vector a JSON array string that SingleStore parses straight into the VECTOR column.
In [10]:
1%%sql2DROP TABLE IF EXISTS tickets;3 4CREATE TABLE tickets (5 id INT NOT NULL,6 question TEXT,7 category VARCHAR(64),8 v VECTOR(384) NOT NULL,9 SHARD KEY (id),10 SORT KEY (id)11);
In [11]:
1import json2import singlestoredb as s23 4rows = [5 (i, question, category, json.dumps(vector.tolist()))6 for i, (question, category, vector) in enumerate(7 zip(tickets['text'], tickets['category'], embeddings)8 )9]10 11with s2.connect() as conn:12 with conn.cursor() as cur:13 for start in range(0, len(rows), 1000):14 cur.executemany(15 'INSERT INTO tickets (id, question, category, v) VALUES (%s, %s, %s, %s)',16 rows[start:start + 1000],17 )18 19print(f'{len(rows):,} rows inserted')
In [12]:
1%%sql2SELECT COUNT(*) AS row_count FROM tickets;3 4SELECT VECTOR_NUM_ELEMENTS(v) AS dimensions FROM tickets LIMIT 1;
5. Where keyword search falls down.
A customer types "can I take this overseas". Nobody in this dataset phrased it that way. What can keyword search do with it?
First the literal phrase, then its one distinctive word overseas, grouped by what those rows are actually about.
In [13]:
1%%sql2SELECT COUNT(*) AS phrase_matches3FROM tickets4WHERE question LIKE '%can I take this overseas%';5 6SELECT category, COUNT(*) AS keyword_matches7FROM tickets8WHERE question LIKE '%overseas%'9GROUP BY category10ORDER BY keyword_matches DESC;
The phrase matches nothing. The word overseas matches 13 rows -- but look at what they are: mostly transfer_fee_charged and wrong_exchange_rate_for_cash_withdrawal. Those are complaints about fees, not questions about whether the card works abroad. Keyword search found the word and missed the question.
Now the same query as a vector. This helper embeds the search text and passes it to SQL as a VECTOR(384) parameter; we reuse it for the rest of the notebook.
In [14]:
1def search(query: str, sql: str) -> pd.DataFrame:2 """3 Embed `query` and run `sql` with the resulting vector as its parameter.4 5 Parameters6 ----------7 query : str8 Natural-language text to search with9 sql : str10 SQL statement containing a single `%s` placeholder for the query vector11 12 Returns13 -------14 pandas.DataFrame15 16 """17 vector = json.dumps(model.encode(query, normalize_embeddings=True).tolist())18 with s2.connect() as conn:19 with conn.cursor() as cur:20 cur.execute(sql, (vector,))21 return pd.DataFrame(22 cur.fetchall(),23 columns=[c[0] for c in cur.description],24 )
In [15]:
1search('can I take this overseas', '''2 SELECT question, category, ROUND(v <*> %s :> VECTOR(384), 4) AS score3 FROM tickets4 ORDER BY score DESC5 LIMIT 56''')
This is the whole point of vector search.
The top result is "Can I use this all over the world?" -- it shares not one word with "can I take this overseas". Neither does the second or the third. No amount of stemming or synonym tuning reaches those rows, because there is no lexical overlap to match on. The model matched the meaning.
And the one genuinely relevant keyword row, "Can I use my card while on vacation overseas?", is still here -- ranked fourth, below three better answers. Nothing was lost; word overlap simply stopped being the definition of relevance.
6. Searching at scale with a vector index.
That query used no index, so SingleStore compared against all 10,000 rows. Exact k-nearest-neighbour is correct but linear: ten times the data, ten times the work.
A vector index trades a little accuracy for a lot of speed by searching approximately (ANN). We use HNSW_FLAT, one of the two types SingleStore recommends.
In [16]:
1%%sql2ALTER TABLE tickets ADD VECTOR INDEX hnsw_v(v)3 INDEX_OPTIONS '{"index_type":"HNSW_FLAT"}';4 5OPTIMIZE TABLE tickets FULL;
The index is used automatically. USE INDEX () with an empty index list disables it and forces the exact scan, so we can run the identical query both ways and compare.
Two details matter for an honest comparison. Embedding the query takes far longer than the search does, so the text is encoded before the clock starts -- otherwise we would be timing MiniLM, not SingleStore. And a single query is mostly connection overhead, so we reuse one connection and run a batch.
In [17]:
1import time2 3PROBES = [4 'can I take this overseas',5 'my card got swallowed by the machine',6 'why has my transfer not arrived yet',7 'how do I change the address on my account',8 'the exchange rate I was given looks wrong',9 'someone used my card without permission',10 'I forgot the code for my card',11 'when will my new card arrive',12 'can I add money with a cheque',13 'why was I charged twice for one purchase',14]15 16# Encoded up front: the model is not what we are measuring.17vectors = [18 json.dumps(model.encode(p, normalize_embeddings=True).tolist())19 for p in PROBES20]21 22VARIANTS = {23 'approximate (HNSW index)': 'ORDER BY score DESC',24 'exact (index disabled)': 'ORDER BY score USE INDEX () DESC',25}26TEMPLATE = 'SELECT id, v <*> %s :> VECTOR(384) AS score FROM tickets {} LIMIT 10'27 28with s2.connect() as conn:29 with conn.cursor() as cur:30 for label, ordering in VARIANTS.items():31 sql = TEMPLATE.format(ordering)32 cur.execute(sql, (vectors[0],)) # warm up; not timed33 cur.fetchall()34 35 start = time.perf_counter()36 for vector in vectors:37 cur.execute(sql, (vector,))38 cur.fetchall()39 total = (time.perf_counter() - start) * 100040 41 print(f'{label}: {total:.0f} ms total, '42 f'{total / len(vectors):.1f} ms per query')
Approximate search is only worth it if the answers stay good. That is recall: of the rows exact search would have returned, how many did ANN actually find? Measure it rather than assume it -- same ten queries, top 10 each, both ways.
In [18]:
1matched = 02with s2.connect() as conn:3 with conn.cursor() as cur:4 for vector in vectors:5 found = {}6 for label, ordering in VARIANTS.items():7 cur.execute(TEMPLATE.format(ordering), (vector,))8 found[label] = {row[0] for row in cur.fetchall()}9 matched += len(found['approximate (HNSW index)'] & found['exact (index disabled)'])10 11total = 10 * len(vectors)12print(f'recall@10 over {len(vectors)} queries: {matched / total:.1%} '13 f'({total - matched} of {total} rows missed)')
Read those two cells together, and be suspicious of the obvious conclusion.
At 10,000 rows the exact scan is already fast -- 3.8 million multiply-adds is nothing -- so the two timings may land close together, or ANN may even come out slower, because walking a graph has its own cost.
Recall is the number worth staring at. Ours came out around 99%: across ten queries the exact scan returned 100 rows, and the approximate search found 99 of them. That miss is not a bug, it is the definition of approximate -- and it is almost certainly a row near the bottom of a ranking where the scores were nearly tied, the kind of difference no user would ever notice. Expect your own figure to move by a point either way, since graph construction is not deterministic.
Put the two cells together and this dataset makes an awkward argument: you gave up about 1% of your answers and got no speed for it. On 10,000 rows, indexing is the wrong call, and a notebook that claimed otherwise would be lying to you. The value is asymptotic -- exact search is linear, so 100x the rows is 100x the work, while a graph index grows far slower -- and the crossover depends on your row count, dimensionality and recall target. That is why the real advice is to measure it on your own data rather than trust anyone's benchmark, and why both cells above are worth keeping around: switching between the two is one hint in the ORDER BY.
7. Vectors are just another column.
This is the part a bolt-on vector store cannot do.
The vectors live in a normal table, so they compose with everything else SQL gives you. Here is a reference table mapping each intent to its owning team and response-time target -- ordinary relational data, sitting beside the embeddings.
In [19]:
1%%sql2DROP TABLE IF EXISTS intent_owner;3 4CREATE TABLE intent_owner AS5SELECT DISTINCT6 category,7 CASE SUBSTRING_INDEX(category, '_', 1)8 WHEN 'card' THEN 'Cards'9 WHEN 'pin' THEN 'Cards'10 WHEN 'transfer' THEN 'Payments'11 WHEN 'top' THEN 'Payments'12 WHEN 'exchange' THEN 'Payments'13 WHEN 'country' THEN 'Onboarding'14 WHEN 'verify' THEN 'Onboarding'15 WHEN 'age' THEN 'Onboarding'16 ELSE 'General Support'17 END AS team,18 CASE19 WHEN category LIKE '%compromised%' OR category LIKE '%stolen%' THEN 120 ELSE 2421 END AS sla_hours22FROM tickets;
Now one statement that does semantic search, a metadata filter, and a join at the same time: find the questions most similar to this text, but only ones owned by Onboarding, and tell me the SLA.
In [20]:
1search('can I take this overseas', '''2 SELECT t.question,3 o.team,4 o.sla_hours,5 ROUND(t.v <*> %s :> VECTOR(384), 4) AS score6 FROM tickets t7 JOIN intent_owner o ON o.category = t.category8 WHERE o.team = 'Onboarding'9 ORDER BY score DESC10 LIMIT 511''')
One query, one database. The filter, the join and the similarity ranking were planned and executed together.
Do that across a standalone vector store and it becomes a distributed systems problem: fetch candidate IDs from the vector service, ship them to your database, join there, and hope the two copies agree. Every filter is a round trip; every write lands in two places. Here the optimizer just sees one table with an extra column type.
That is why vectors belong next to your operational data, not in a service beside it.
8. Clean up.
Drops the tables this notebook created, so you can re-run from the top.
In [21]:
1%%sql2DROP TABLE IF EXISTS drinks;3DROP TABLE IF EXISTS tickets;4DROP TABLE IF EXISTS intent_owner;
What to explore next
Hybrid full-text and vector search -- blend
MATCH ... AGAINSTand vector scores in one ranked query, for when exact terms do matter.Vector indexing --
IVF_PQFS, quantization, and tuning recall against latency.
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 [22]:
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 vector_data;

Details
About this Template
Learn what a vector is, how to measure similarity correctly, and how to search 10,000 real support questions by meaning using the native VECTOR type, DOT_PRODUCT and a vector index.
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.