Skip to main content

Vector Store

SereneDBVectorStore is the synchronous LangChain VectorStore for SereneDB. It is a thin wrapper around AsyncSereneDBVectorStore, delegating each call to the async implementation through the engine's background event loop, so both classes behave identically.

A store binds to a table that already exists. Create the table with init_vectorstore_table() first.

MethodPurpose
create_sync()Bind a store to an existing table.
add_texts() / add_documents()Embed and upsert rows.
delete()Delete by id and/or metadata filter.
similarity_search()Dense (or hybrid) search by query text.
similarity_search_with_score()Dense search with the distance. Never hybrid.
similarity_search_by_vector()Search with an embedding you already have.
max_marginal_relevance_search()Diversity-aware search (MMR).
get_by_ids()Fetch documents by primary key.
Index managementBuild, drop and inspect the index.

Creating a store

create_sync()

SereneDBVectorStore.create_sync(engine, embedding_service, table_name, **kwargs)

The synchronous factory. engine, embedding_service and table_name are positional; every remaining keyword is forwarded to AsyncSereneDBVectorStore.create(), so the full option set is available here — including ignore_metadata_columns, which is the one keyword missing from create()'s explicit signature.

ParameterTypeDefaultMeaning
engineSereneDBEngineThe engine to run on.
embedding_serviceEmbeddingsAny LangChain embeddings model. Its dimension must match the table's FLOAT[N].
table_namestrTable to bind to.
schema_namestr"public"Schema the table lives in.
content_columnstr"content"Column holding page content.
embedding_columnstr"embedding"Column holding the vector.
metadata_columnsOptional[list[str]]NoneTyped metadata columns to read and write. Mutually exclusive with ignore_metadata_columns.
ignore_metadata_columnsOptional[list[str]]NoneTreat every column except these (and id/content/embedding) as a metadata column.
id_columnstr"langchain_id"Primary key column.
metadata_json_columnOptional[str]"langchain_metadata"JSON metadata column. Downgraded to None if the table has no such column.
distance_strategyDistanceStrategyCOSINE_DISTANCEDistance measure for search. See DistanceStrategy.
kint4Default number of results.
fetch_kint20Default candidate pool for MMR.
lambda_multfloat0.5Default MMR diversity weight (0 = maximal diversity, 1 = pure relevance).
index_query_optionsOptional[QueryOptions]NonePer-query index tuning. See IVFQueryOptions.
hybrid_search_configOptional[HybridSearchConfig]NoneQuery-time fusion settings. The combined index must already exist — see Hybrid Search.
metadata_indexOptional[MetadataIndexConfig]NoneWhich indexed metadata this store filters on. May be a subset of what the index covers, but each declared field must use the type the index was built with.
sync_loadboolTrueWhen False, writes do not auto-refresh the inverted index.

Validation. The factory queries information_schema.columns and fails fast if the table does not match:

ConditionError
Table missing, or has no columnsTable "<schema>"."<table>" does not exist or has no columns.
id_column absentId column, <name>, does not exist.
content_column absentContent column, <name>, does not exist.
content_column is not a character typeContent column, <name>, is type, <type>. It must be a type of character string.
embedding_column absentEmbedding column, <name>, does not exist.
embedding_column is not an arrayEmbedding column, <name>, is type <type>. It must be a FLOAT[N] array.
A name in metadata_columns is absentMetadata column, <name>, does not exist.
Both metadata_columns and ignore_metadata_columns givenCan not use both metadata_columns and ignore_metadata_columns.

SereneDB reports a FLOAT[N] column as data_type = 'ARRAY'; the check also accepts USER-DEFINED and vector.

note

A missing metadata_json_column is not an error — it is silently set to None. That is what makes a store over a store_metadata=False table work, but it also means a typo in the column name degrades to "no JSON metadata" instead of raising.

create()

store = await SereneDBVectorStore.create(engine, embedding_service, table_name, ...)

An async factory that returns the same synchronous store. Use it when your setup code is already async but the store will be consumed synchronously. It takes the parameters listed above with the sole exception of ignore_metadata_columns. For an actual async store, use AsyncSereneDBVectorStore.create().

Adding documents

add_texts()

ids = store.add_texts(texts, metadatas=None, ids=None, **kwargs)
ParameterTypeDefaultMeaning
textsIterable[str]Page content for each row.
metadatasOptional[list[dict]]NoneOne metadata dict per text. Defaults to empty dicts.
idsOptional[list]NoneOne id per text. Missing or None entries get a fresh UUID4 string.

Returns the list of ids actually written.

How it works. The texts are embedded with embedding_service.aembed_documents(), then written as a single INSERT ... ON CONFLICT (<id_column>) DO UPDATE SET ... executed once for the whole batch — one connection checkout, one transaction. This means the write is an upsert, not an append: reusing an id replaces that row's content, embedding and metadata.

Metadata is routed per key: a key matching a declared metadata column is bound to that column (a dict value is JSON-serialized), and what remains goes into the JSON column. A declared column with no matching key binds NULL.

Unless the store was created with sync_load=False, the write ends with a refresh_table() so the new rows are immediately searchable.

For a bulk load, skip the per-batch refresh and build the index afterwards:

store = SereneDBVectorStore.create_sync(engine, embeddings, "my_docs", sync_load=False)

for batch in batches:
store.add_texts(batch)

engine.refresh_table("my_docs")
store.apply_vector_index(IVFIndex())

add_documents()

ids = store.add_documents(documents, ids=None, **kwargs)

Takes list[Document] and forwards page_content and metadata to add_texts(). When ids is not given, each document's own Document.id is used (and None entries still get a UUID4).

Deleting

delete()

store.delete(ids=None, filter=None)
ParameterTypeDefaultMeaning
idsOptional[list]NoneIds to delete.
filterOptional[dict]NoneA metadata filter; rows matching it are deleted.

Both may be given, in which case they are ANDed — the deleted rows are those whose id is in the list and which match the filter. Returns True when a DELETE was issued, and False when neither argument was supplied (nothing is deleted in that case). Refreshes the index afterwards unless sync_load=False.

store.delete(filter={"category": {"$in": ["draft", "archived"]}})

Searching

Every search accepts filter, a metadata filter dict. Passing k=None (the default) uses the store's configured k; the same applies to fetch_k and lambda_mult for MMR.

Dense and hybrid searches

A search takes one of two paths, and the rest of these docs refer to them by name:

  • A dense search ranks purely by vector distance — it embeds the query (or takes your embedding), orders by the distance strategy's operator and returns the nearest k. "Dense" is the usual name for this because an embedding is a dense vector, as opposed to the sparse term vector a keyword index works with. This is what every search method does by default.
  • A hybrid search additionally runs a lexical BM25 branch over the content column and fuses the two rankings into one order. It happens only when the store has a hybrid_search_config and a full-text query can be resolved for the call.

The distinction shows up in three places worth knowing about: the two paths report scores with opposite polarity; a dense search falls back to an exact table scan when the index is missing, while a hybrid search fails outright; and MMR is always dense, even on a hybrid store.

The dense path reads from the inverted index by name when the index exists, and falls back to an exact scan of the base table when it does not — so search works before the index is built, just more slowly.

docs = store.similarity_search(query, k=None, filter=None, **kwargs)

Embeds query, then returns the k nearest documents. When the store has a hybrid_search_config, the query text is also passed as the full-text query, so this one call runs the fused BM25 + vector search.

similarity_search_with_score()

pairs = store.similarity_search_with_score(query, k=None, filter=None, **kwargs)

Returns list[tuple[Document, float]], where the float is a vector distance — see Score semantics.

This path is always dense. Even on a store configured for hybrid search, it runs the vector query and reports a real distance, because a fused ranking is not a distance and would make the number meaningless. Passing fts_query or hybrid_search_config here raises NotImplementedError; use similarity_search() for hybrid ranking.

similarity_search_by_vector()

docs = store.similarity_search_by_vector(embedding, k=None, filter=None, **kwargs)

Skips the embedding step and searches with the vector you supply. Its length must match the table's FLOAT[N].

note

This method has no query text to derive a full-text query from, so on a hybrid store it runs the dense path by default. Pass fts_query="..." explicitly to get the fused query — see Choosing the query text.

docs = store.max_marginal_relevance_search(
query, k=None, fetch_k=None, lambda_mult=None, filter=None, **kwargs
)
ParameterTypeDefaultMeaning
querystrQuery text.
kOptional[int]store's kDocuments to return.
fetch_kOptional[int]store's fetch_kCandidates to retrieve before re-selecting.
lambda_multOptional[float]store's lambda_mult0 maximizes diversity, 1 maximizes relevance.

Fetches fetch_k nearest candidates, then re-selects k of them with maximal marginal relevance so the results are less redundant. The selection runs client-side over the candidates' embeddings.

caution

MMR always takes the dense path, even on a hybrid store — it never consults the lexical branch.

get_by_ids()

docs = store.get_by_ids(ids)

Fetches documents by primary key, in the order the ids were requested. Ids with no matching row are skipped rather than raising, so the result can be shorter than the input.

Score semantics

A dense search reports a distance: lower is closer. The value is exactly what the strategy's named SQL function returns.

distance_strategyReported valueRange
COSINE_DISTANCEcosine_distance0 (identical) to 2
EUCLIDEANl2_distance0 upwards
INNER_PRODUCTnegative_inner_product, i.e. -IPunbounded; smaller is more similar
MANHATTANl1_distance0 upwards

Because inner product is reported negated, all four are genuine distances and LangChain's normalization helpers apply directly. similarity_search_with_relevance_scores() and the similarity_score_threshold retriever therefore work out of the box; the Manhattan case maps through 1 - d/2, which assumes unit-normalized embeddings.

The reported value is always a distance, on every store. Scored search does not do hybrid fusion — a fused value is a ranking rather than a distance, so mixing the two in one return type would make the number meaningless. On a hybrid store a scored call therefore runs the dense path and gives you real distances, and passing fts_query or hybrid_search_config to it raises NotImplementedError. See Asking for a scored hybrid search.

The relevance mapping is also clamped to [0, 1], so an exact match whose distance lands a hair below zero through floating-point error cannot push 1 - distance above 1.0 and trip LangChain's range check.

Using it as a retriever

as_retriever() is inherited from LangChain and works unchanged:

retriever = store.as_retriever(
search_type="similarity_score_threshold",
search_kwargs={"score_threshold": 0.9, "k": 3},
)
docs = retriever.invoke("the quick brown fox")

All three search types are supported, and on a hybrid store they do not all use the lexical branch:

search_typeUnderlying callHybrid on a hybrid store?
"similarity" (default)similarity_search()Yes
"mmr"max_marginal_relevance_search()No — always dense
"similarity_score_threshold"similarity_search_with_relevance_scores()No — scored paths are dense, so the threshold applies to distance-derived relevance

Anything the underlying method accepts can go in search_kwargs, including filter:

retriever = store.as_retriever(
search_kwargs={"k": 5, "filter": {"category": "science"}},
)

Because the score-threshold retriever goes through a scored path, do not put fts_query in its search_kwargs — that combination raises. Use the default "similarity" type when you want fused ranking.

Index management

These methods live on the store but are documented with the classes they take, on Indexes and Tuning:

MethodPurpose
apply_vector_index()Build the vector (or combined) inverted index.
apply_hybrid_search_index()Build the combined index for hybrid search.
drop_vector_index()Drop it.
reindex()Rebuild in place — raises NotImplementedError for now.
is_valid_index()Whether the index currently exists.

Not implemented

from_texts() raises NotImplementedError, and from_documents() raises through it, because the table's shape — dimension, id type, metadata columns — cannot be inferred from texts alone:

Initialize the table with SereneDBEngine.init_vectorstore_table, then use create_sync().

reindex() also raises for now, because SereneDB's REINDEX applies to view-backed indexes rather than to the base table a store lives in. Drop and re-apply the index to rebuild it.

The synchronous class also does not implement add_embeddings(), similarity_search_with_score_by_vector(), max_marginal_relevance_search_by_vector() or max_marginal_relevance_search_with_score_by_vector(). They exist on the async store — see Async-only methods.

See also