Status: plan / not yet built. Kept in-repo while the AI-native-layer work is in flight; it can move out once the roadmap is delivered.
This plan is self-contained. It depends on no unmerged branch and no parked work.
Hotdata is working with LangChain on deeper ecosystem integration, on top of the existing tool
foundation in make_hotdata_tools (SQL, instant databases, BM25 search, schema discovery).
VectorStore/RAG is the next priority, chosen specifically because it converges with Rohan's
own recent vector-search engineering:
datafusion-vector-search-ext— the DataFusion extension that makes USearch HNSW ANN search a first-class SQL operator (ORDER BY <distance_fn>(col, query) LIMIT k, transparently rewritten into an index lookup). PR #31 (merged 2026-07-21) fixed the optimizer rule to see throughSubqueryAliasnodes, which is what makes the fast path reachable from SQL generated by anything that aliases tables (ibis, ORMs, BI tools).runtimedb— the deployed query engine; PR #953 bumped its pin to pick up #31, merged and confirmed live in production. The rewrite has since been observed firing for the queries this package generates (2026-08-06); seeengine-contract.md.hotdata-ibis— gets its own read-side vector helper layer (asemantic_search()+ distance-UDF module), planned and owned separately by Rohan; this plan cross-references it but doesn't depend on it.hotdata-dlt-destination— already flowslist<float32>embedding columns through its write path untouched; a differentiated auto-embed-on-ingest adapter is a separate, later piece.
Put together, these converge on one story: one fast, DataFusion-backed engine, addressable
from SQL, ibis, and now LangChain's own VectorStore primitive — not three disconnected
integrations.
Bigger-picture context (not yet planned, understanding-only as of 2026-07-22): the team
has separately articulated a longer-term vision of Hotdata as an "AI-native query layer" for
LangChain — a single tool that routes across SQL, full-text, vector, and point-lookup
pathways with its own query planning and permissions, rather than several discrete tools the
agent picks between. HotdataVectorStore is one building block toward that vision — the vector
pathway — not the vision itself. That larger design is intentionally not scoped here; see
docs/ai-native-layer-roadmap.md and issue #39, which covers the agent-facing tool surface
for semantic search and rank fusion. #39 and this plan are different surfaces: #39 wraps
vector_search() as a tool the model calls; this is LangChain's VectorStore primitive for
retrievers and chains. They share the engine contract, not the code.
This document covers HotdataVectorStore only — a new class in hotdata_langchain. It
does not cover the hotdata-ibis helper or the hotdata-dlt-destination adapter in
implementation detail; see "Cross-repo dependency tracking" below for how those relate.
New file: hotdata_langchain/vectorstore.py, not folded into databases.py.
HotdataVectorStore(
client: HotdataClient,
embedding: Embeddings,
*,
database_id: str | ManagedDatabase, # REQUIRED — id, never a name
table: str = "vectors",
schema: str = DEFAULT_SCHEMA,
distance: Literal["cosine", "l2", "dot"] = "cosine",
metadata_columns: Mapping[str, Literal["string", "int", "float", "bool"]] | None = None,
)database_id is required and id-addressed (issue #38, shipped in 0.3.0). The store never
creates a database implicitly and never resolves one by name — the caller creates it and passes
the id, the same stance the plan already takes on delete (never expose an unbounded
destructive operation). An already-resolved ManagedDatabase is accepted so a caller holding
one pays no lookup.
That single resolve_database_by_id(client, database_id) call at construction is the only
lookup in the class. Every subsequent query and load addresses the resolved ManagedDatabase
record, so id-addressing propagates throughout by construction.
embedding is held on self, not passed per-call — the universal LangChain convention, and
what lets similarity_search(self, query, k=4, **kwargs) match the ABC's fixed signature.
One managed table, key = ["id"], which is what enables mode="upsert" and mode="delete"
on load_managed_table:
| column | type | purpose |
|---|---|---|
id |
string |
LangChain doc id / managed-table key |
content |
string |
page_content |
metadata_json |
string |
full metadata dict (json.dumps(..., default=_json_default)), always kept in full for read-back fidelity |
embedding |
list<float32> |
confirmed to round-trip through load_managed_table via a live spike in a sibling repo |
| (promoted metadata columns) | typed per metadata_columns |
denormalized copy of declared metadata keys, so WHERE can target a real typed column — see Filtering below |
Only similarity_search and from_texts are truly @abstractmethod on VectorStore.
Everything else has a default or raises NotImplementedError until overridden.
add_texts(implement;add_documentsderives for free from it, confirmed via the base class's own delegation check).self._embedding.embed_documents(texts)→ one pyarrow table (id/content/metadata_json/embedding/promoted columns) → temp parquet →client.load_managed_table(self._db, table, schema=..., file=..., mode="upsert", key=["id"]), passing the resolved record. Generate ids viauuid.uuid4().hexwhen omitted — neverNone(the key column can't be null).similarity_search/similarity_search_by_vector/similarity_search_with_score(_by_vector)— implement all explicitly rather than relying on ABC defaults. See "SQL-path decision" below for the query shape._select_relevance_score_fn— mapped offself._distance. Default constructor value iscosinespecifically because its score function (1 - distance) needs no scale assumption; see thel2caveat below.get_by_ids(ids)—WHERE id IN (...), no vector math involved; the simplest method, built first.delete(ids=None, **kwargs)— requiresids(raises if omitted; no "delete everything" in v1 — never expose an unbounded destructive operation). Backed byload_managed_table(..., mode="delete", key=["id"]). Raises on backend failure — deletes do not fail open: silently reporting a delete succeeded when it didn't is actively dangerous.from_texts(cls, texts, embedding, metadatas=None, *, ids=None, **kwargs)— classmethod;clientthreaded through**kwargs(the ABC's sanctioned per-implementation extension point, same pattern every real integration uses for constructor args the ABC can't standardize). Builds the store, callsadd_texts, returns it. Index creation (if requested) happens strictly afteradd_texts— see "Dimension binding" below.- MMR (
max_marginal_relevance_search_by_vector) — not free. The ABC raisesNotImplementedErrorby default (confirmed by readingInMemoryVectorStore, LangChain's own reference implementation) — every real implementation fetchesfetch_kcandidates with their raw embedding vectors and runslangchain_core.vectorstores.utils.maximal_marginal_relevance. This needs its own query branch that does select theembeddingcolumn, which breaks the "never surface the vector column" rule the primary read path relies on for the engine's fast-path rewrite (since observed directly; seeengine-contract.md) — so this branch is always brute-force by design. Acceptable:fetch_kdefaults small and is caller-bounded, so a full scan over a bounded candidate set is cheap. Own phase, own PR — a distinct correctness surface (raw-vector round-trip on read, which the primary path never needs), not bundled with the MVP. Two further points settled while building it:maximal_marginal_relevanceneeds the query vector as a numpy array, sonumpybecomes a declared dependency; and it scores diversity by cosine similarity whatever the store'sdistanceis, which every LangChain implementation does and which is documented rather than corrected. - Everything else (
add_documents, async variants via thread-pool wrapping,as_retriever(),similarity_search_with_relevance_scores) is free from the base class — verified by tests that they delegate correctly, no new code required.
Internal plumbing (self-contained, no shared base class needed):
- Construction —
self._db = resolve_database_by_id(client, database_id). RaisesKeyErrorfor an unknown id, so a bad id fails at construction rather than on first search. - Table declaration — best-effort
client.add_managed_table(self._db, table, schema=schema, key=["id"]), swallowing an "already declared" failure atlogger.debug. The key is what makesmode="upsert"/"delete"work; a keyless table silently degrades to append-only, so this cannot be skipped. - Every read —
client.execute_sql(sql, database=self._db), passing the resolved record. Never a string:execute_sql(database="<id>")re-resolves per call, and a name would reach the framework's by-name fallback (seedocs/engine-contract.md). - Table reference in SQL —
"default"."<schema>"."<table>"; inside an instant database the built-in catalog is alwaysdefault.
Build every read query as a scalar-UDF ORDER BY ... LIMIT, not the vector_search_vector(...)
table function:
SELECT id, content, metadata_json, <promoted cols>,
<distance_fn>(embedding, ARRAY[...]) AS dist
FROM "default"."<schema>"."<table>"
[WHERE <promoted_col> = <literal>]
ORDER BY dist ASC
LIMIT <k>using the engine's index-independent scalar distance UDFs (cosine_distance, l2_distance,
negative_dot_product — confirmed to work as plain row-by-row functions even with no index
at all, always correct, just a full-table brute-force scan without one).
Why this over the table function: this shape is correct from row one with zero
preconditions, and upgrades transparently to the HNSW fast path once a matching-metric index
exists on that column — one code path, no index-vs-no-index branching to build or test.
(Verified 2026-08-06; the observed plans are in engine-contract.md.) The
vector_search_vector(...) table function, by contrast, errors loudly
("no loaded vector index") if the index doesn't exist yet, which would make a freshly
constructed HotdataVectorStore unusable out of the box — a bad default for a
partnership-facing integration. The raw embedding column is never selected in this path
(engine issue #508: a vector column in the output declines the fast-path rewrite).
Equality filters only, and only on keys explicitly declared via the constructor's
metadata_columns (which promotes them to real typed columns at write time).
filter={"key": value} on a key not in metadata_columns raises ValueError immediately —
fail loudly at call time, not silently-wrong at query time. Free-form/undeclared metadata keys
are simply not filterable in v1.
Filter predicates always go in the same query, in WHERE, ahead of ORDER BY/LIMIT —
never as an outer query wrapping an already-computed top-k result, which would silently
return fewer than k rows (a filter applied after top-k selection can only shrink the result,
never re-fill it). A WHERE-filtered query does still reach the HNSW fast path, with the
predicate pushed into the index lookup (filtered=true in the plan) — verified 2026-08-06,
see engine-contract.md. This plan had assumed the opposite and accepted
brute-force-but-correct as a v1 cost, since attribute-filtered ANN is a harder capability many
engines lack; that caveat no longer applies.
Ids and filter literals are charset-validated before SQL interpolation, reusing
hotdata_langchain/_sql.py's validate_identifier/quote_literal — reject anything outside a
conservative charset rather than attempt general SQL escaping. The query vector itself is never user-controlled text; it's a
list of floats we format ourselves.
This plan assumed create_index would need dimensions up front, and that the dimension was
therefore something the store had to learn from its first embed_documents call and pass on.
That turned out to be wrong, and the sequencing it implied turned out to be right anyway.
For a plain vector index — one over a column that already holds vectors — the engine reads the
width off the stored data, and dimensions applies only to the provider-backed path where
the engine does the embedding itself. A supplied value is ignored here.
So the store passes no dimensions at all, and the ordering inside from_texts still holds
for a different reason: (1) construct the store, (2) add_texts, which embeds and writes,
(3) only then create the index, which now has stored rows to measure. Index creation never
precedes the first write, because before it there is nothing to read a width from.
One consequence: when detection fails, no caller argument can rescue it. That is what makes #52 an engine fix rather than a client one.
The engine's l2_distance is squared L2 (no sqrt), but VectorStore's default
_euclidean_relevance_score_fn assumes true (unsquared) Euclidean distance on
unit-normalized embeddings — using l2 as the configured metric would produce a
wrong-scale relevance score unless corrected, and correcting it properly requires knowing
embedding normalization we don't control. Constructor defaults to cosine for this reason
(1 - distance is exact, no scale assumption); l2/dot remain available but flagged in the
docstring rather than silently "fixed."
Step 0 — confirm the embedding key's scope before writing any code. Done, 2026-08-06.
.env carries OPENAI_EMBEDDING_KEY (a team key) alongside OPENAI_API_KEY. A single
embeddings.create call confirmed it is embeddings-scoped and live: text-embedding-3-small
returns 1536 dimensions, no 403. Live verification of the write/read round-trip is
therefore unblocked, and 1536 is the dimension the first real index will be created with.
Unit tests need no provider credentials. A fake HotdataClient (MagicMock) backed by an
in-memory dict, where load_managed_table does a real pq.read_table(file).to_pylist() (so
the list<float32> round-trip is genuinely exercised, not mocked away) and execute_sql does
real SQL-shape parsing rather than returning a bare canned value. tests/conftest.py already
provides managed_db and databases_api fixtures from the #38 work — reuse them so the
constructor's id resolution is stubbed the same way everywhere. For embeddings, use langchain_core.embeddings.DeterministicFakeEmbedding (confirmed
present in the installed langchain_core, zero new dependency) rather than a bespoke fake.
Coverage: schema/type correctness on write; exact SQL shape on read (distance aliased,
embedding column absent from SELECT, filter predicate inside the same query); ValueError
on an undeclared filter key or a malformed id; delete requiring ids; from_texts
round-tripping end to end; MMR selecting the embedding column and calling into
maximal_marginal_relevance with the right shapes.
Live verification. Done for Phase 1 on 2026-08-06 against the production workspace, via
demo/vectorstore_demo.py (database dbidh4tn5esw2roy7zg4sh1fqv8rov). Confirmed working:
the list<float32> embedding column round-trips through load_managed_table; a
1536-dimension ARRAY[...] literal in cosine_distance(embedding, ARRAY[...]) is accepted
and ranks sensibly; the WHERE predicate filters inside the ranking query; mode="upsert"
with key=["id"] leaves 8 rows after two runs of 8 documents; mode="delete" accepts a
parquet carrying only the key column and removes the row; deleting an absent id is a
no-op; get_by_ids skips ids that are not present; and as_retriever() composes into an
LCEL retrieval chain that answers from retrieved context.
One constraint surfaced that the design had not anticipated: an upsert must carry every
column the table has (upload is missing column '<name>'). So metadata_columns has to
match the table a store is opened against — pointing a differently-configured store at an
existing table fails on the first write rather than silently writing partial rows. Documented
in the class docstring and README.
MMR verified live 2026-08-08 against the same workspace, table public.documents_mmr,
text-embedding-3-small at 1536 dimensions over a 10-document corpus. lambda_mult=1.0
reproduced the similarity ranking document-for-document, which is the implementation's own
control and something the unit tests can only assert on synthetic vectors.
One property surfaced that the plan had not anticipated, and it is about the embedding
model, not the engine. Every cosine distance in the corpus fell between 0.6055 and 0.6690,
so MMR's relevance term spans ~0.06 while its redundancy term spans several times that —
near-duplicates score ~0.9 against each other. The two terms therefore do not have
comparable scale, and LangChain's lambda_mult=0.5 default, which weights them equally, let
variety decide nearly every pick: it promoted a document that did not answer the query at
all. At 0.7 and 0.8 the behaviour was correct and identical — the near-duplicate dropped, a
genuine alternative promoted.
The library keeps 0.5 (ecosystem compatibility outweighs one corpus's evidence); the demo
defaults to 0.7 and both READMEs say to sweep it. Single corpus, single query, single
model — an observation to act on, not a law. The NanoBEIR-style eval harness is what would
turn it into a measurement.
Fast path verified 2026-08-06. Both outstanding EXPLAIN questions are settled, and the
observed plans are recorded in engine-contract.md. The primary query plans as a full scan
with no index and as USearchExec once a matching-metric index exists. A WHERE-filtered
query also reaches the fast path, with the predicate pushed into the index lookup
(filtered=true) — better than this plan assumed, and it retires the "brute-force-but-correct
is an accepted cost" caveat under Filtering above.
Confirmed as forfeiting the rewrite, silently: projecting the embedding column, a distance
function the index was not built for, and omitting LIMIT. The similarity_search* path
avoids all three by construction, which is worth a regression test now that the boundary is
known rather than assumed. MMR (Phase 2) takes the first one deliberately — it cannot compute
diversity without the stored vectors — and pays for it with a fetch_k-bounded full scan.
- MVP —
add_texts,similarity_search(_by_vector),similarity_search_with_score(_by_vector),get_by_ids,delete,from_texts(no self-provisioned index yet), promoted-column equality filtering, full unit-test suite,examples/langchain_vectorstore.py, README section, CHANGELOG entry. A complete, correct, mergeableVectorStoreon its own —as_retriever(), chains, and evals all work once this lands, independent of anything below. - MMR — shipped.
max_marginal_relevance_search()and its_by_vectorvariant, over afetch_k-bounded candidate fetch that projects the stored vectors. The async variants came free from the base class, which delegates them to the sync ones. Projecting the vector column forfeits the index lookup (observed during Phase 3; seeengine-contract.md), so this branch full-scans by design whilesimilarity_searchkeeps the fast path. - Self-provisioning — shipped.
create_index()andfrom_texts(..., create_index=True), onhotdata-framework0.10.0'sHotdataClient.create_index. The store always builds for its owndistance: the server defaults an unspecified metric tol2while this store defaults tocosine, and a metric the query's distance function was not built for silently full-scans instead of erroring. - Docs/examples are pulled into Phase 1 rather than deferred to the end — this is the piece the LangChain conversation will exercise first.
Each phase is its own issue and its own PR, under the epic #47.
| Phase | Scope | State |
|---|---|---|
Epic: HotdataVectorStore |
tracking; links this plan and the phases below | #47 |
| Phase 1 — MVP | add_texts, the four similarity_search*, get_by_ids, delete, from_texts, promoted-column filtering, unit tests, examples/langchain_vectorstore.py, README, CHANGELOG |
shipped in 0.4.0 (#48) |
| Phase 2 — MMR | own PR; raw-vector read path | #55 |
| Phase 3 — self-provisioning | create_index(), from_texts(..., create_index=True) |
shipped |
Phase 1 is a complete, mergeable VectorStore on its own: as_retriever(), chains and evals
all work once it lands, with no index provisioned and no further phases.
hotdata_langchainitself — id-only database addressing, shipped. Issue #38 landed in 0.3.0:resolve_database_by_idfetches byGET /databases/{id}with no by-name fallback, andquery_scoperejects an unresolved string scope. This plan's constructor is built on it; see "File and constructor" above. Nothing further needed.sdk-python-framework(hotdata_framework.HotdataClient) — shipped in 0.10.0. Landed as the generalcreate_index(..., index_type=...)rather than thecreate_vector_indexthis plan scoped, sinceCreateIndexRequestalready accepted"bm25"and"sorted"too; one method covers self-provisioning for all three instead of building it twice. It polls the build job to a terminal state and raises with itserror_message, because the submit call reports success for builds that later fail. It only ever blocked Phase 3 — Phases 1–2 worked regardless, against an existing index, a not-yet-existing index, or no index ever, by construction of the SQL-path decision above.runtimedbPR #953 — merged and live in production. Pin-bump to pick updatafusion-vector-search-extPR #31, confirmed deployed. Our SQL was designed to be correct either way (the scalar UDFs work with no index at all), so this was never a blocker. The rule has since been observed firing for the queries this package generates; see "Live verification" andengine-contract.md. No longer a dependency to track.hotdata-ibisvector helper layer — external, owned separately, tracked for consistency only. Not a dependency of this work. Cross-referenced so both surfaces target the same engine contract (same distance-function names, same "never select the vector column" constraint) rather than drifting apart.hotdata-dlt-destinationwrite-side embedding adapter — external, future, tracked. Still a design sketch (hotdata_adapter(data, embed=[...])). Once built, completes the full pipeline this plan enables end to end: dlt ingests and auto-embeds on the way in →HotdataVectorStorereads it out for a LangChain agent. Not a blocker for this work — this plan's MVP works against precomputed embeddings loaded any way (including today's dlt destination, unchanged).