The bulk upload paths (vp.vectors.upsert("col", "data.parquet") or vp.vectors.upsert("col", dataframe=df)) are designed for thousands-to-millions of rows in one shot. They land vectors in the next epoch and get them indexed properly.

Inline upsert is the other shape: one row at a time, written through a Write-Ahead Log on the source server, propagated to the workers as an in-memory overlay, and queryable as soon as the call returns. Designed for the "user clicked Save and now wants their new item to show up in the next search" pattern.

Inline upserts and the WAL fast path: comparison of bulk upload (Parquet/DataFrame → epoch build → cluster persist → indexed parquet, good for large imports) vs inline upsert (single row → source vectors.log WAL fsync → in-memory overlay on workers → search returns the new row at score 1.0000 immediately). Use inline for same-second visibility; use bulk upload for large loads.

This page is the reference for when to use it, what guarantees it gives you, and the gotchas that bite the first time.

Setup

from veep import VP, samples

df = samples.dataframe()

vp = VP.from_creds()
vp.collections.create("inline-demo", tier="hot")
vp.vectors.upsert("inline-demo", dataframe=df)

The bundled DataFrame ships with pre-computed embeddings, so the initial upload goes through veep directly. Wrap the populated collection in VectorPandaStore for the LangChain query path; inline upserts of pre-computed vectors call store.client.vectors.upsert(...) (LangChain's add_texts is text-first and re-embeds).

from veep import VP, samples
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_vectorpanda import VectorPandaStore

df = samples.dataframe()

vp = VP.from_creds()
vp.collections.create("inline-demo", tier="hot")
vp.vectors.upsert("inline-demo", dataframe=df)

embeddings = HuggingFaceEmbeddings(
    model_name="sentence-transformers/all-MiniLM-L6-v2"
)
store = VectorPandaStore(
    collection_name="inline-demo",
    embedding=embeddings,
    client=vp,
)

Insert one row, query for it immediately

The inline shape: pass vectors=[...] instead of a file path or DataFrame. Each item is a dict with id, vector, and an optional metadata.

qv = list(df[df.id == "film-00002"].iloc[0].vector)

vp.vectors.upsert("inline-demo", vectors=[{
    "id": "demo-quickfix-1",
    "vector": qv,
    "metadata": {
        "title": "Director's Cut: Live Patch",
        "year": 2026,
        "genre": "demo",
    },
}])

results = vp.vectors.query("inline-demo", vector=qv, top_k=3,
                           filter={"id": "demo-quickfix-1"})
for r in results:
    print(f"  {r.score:.4f}  {r.metadata.get('title','?'):<40}  ({r.metadata.get('year','?')}, {r.metadata.get('genre','?')})")
qv = list(df[df.id == "film-00002"].iloc[0].vector)

# Pre-computed vectors drop down to the underlying veep client.
store.client.vectors.upsert("inline-demo", vectors=[{
    "id": "demo-quickfix-1",
    "vector": qv,
    "metadata": {
        "title": "Director's Cut: Live Patch",
        "year": 2026,
        "genre": "demo",
    },
}])

results = store.similarity_search_by_vector_with_score(
    qv, k=3, filter={"id": "demo-quickfix-1"},
)
for doc, score in results:
    m = doc.metadata
    print(f"  {score:.4f}  {m.get('title','?'):<40}  ({m.get('year','?')}, {m.get('genre','?')})")
  1.0000  Director's Cut: Live Patch                (2026, demo)

The new row is back at score 1.0 because we used its own vector as the query. The query landed milliseconds after the upsert call returned; the row was in the result. No epoch promotion, no rebuild, no waiting.

How it works

How the WAL fast path works: 4-step coordination. (1) Source server appends the full payload (vector + metadata) to vectors.log and fsyncs before responding. (2) Coordinator receives a per-collection mutation notification, fans out ApplyMutation to workers, and invalidates its metadata cache. (3) Workers apply the upsert to an in-memory overlay map; subsequent searches consult the overlay first and overlay wins over any artifact-resident entry for the same key. (4) At the next epoch boundary the WAL entries fold into the indexed parquet and the overlay clears. What this guarantees: fsync before SDK call returns; next query sees vector + metadata; overlay clears at promotion without changing query results.

The inline-upsert path goes through three components:

  1. Source server receives the mutation, writes the full payload (key + vector + metadata) to a per-collection vectors.log Write-Ahead Log on disk, fsyncs, and forwards a VectorMutation notification to the coordinator.
  2. Coordinator receives the notification, fans out an ApplyMutation message to every worker serving that collection, and invalidates its own metadata cache for the collection so the next query rebuilds against the fresh log entry.
  3. Workers apply the upsert to an in-memory overlay map (vector + magnitude). Subsequent searches consult the overlay first; an overlay hit wins over any artifact-resident entry for the same key.

When an epoch is next promoted (a routine background event), the WAL entries fold into the indexed parquet for that collection and the overlay clears.

The customer-facing guarantees you can rely on:

Filters work, including on id

A frequent confusion: the row's id is its key (used for routing and lookup), and you might assume it's not available as a metadata field. For inline upsert it is. The SDK auto-mirrors id into the metadata payload so you can filter on it without doing the duplication yourself.

# $ne filter excludes the inline row by id
results = vp.vectors.query("inline-demo", vector=qv, top_k=3,
                           filter={"id": {"$ne": "demo-quickfix-1"}})
for r in results:
    print(f"  {r.score:.4f}  {r.metadata.get('title','?'):<40}  ({r.metadata.get('year','?')})")
# $ne filter excludes the inline row by id
results = store.similarity_search_by_vector_with_score(
    qv, k=3, filter={"id": {"$ne": "demo-quickfix-1"}},
)
for doc, score in results:
    m = doc.metadata
    print(f"  {score:.4f}  {m.get('title','?'):<40}  ({m.get('year','?')})")
  1.0000  The Lion King                             (1994)
  0.7591  The Lion King II: Simba's Pride           (1998)
  0.5560  Madagascar: Escape 2 Africa               (2008)

The Director's Cut row is gone; the next-most-similar items take its place. The $ne filter excludes by id correctly even though the row was just inserted via the WAL path. Same for $nin, $eq, and every other operator covered in the filter syntax tutorial.

Inline delete

Insert, filter, and delete, all immediately. Insert one row (Director's Cut: Live Patch) and query for it: returned at score 1.0000. Filter excluding by id ($ne demo-quickfix-1): the inline row is gone and Lion King 0.6125 + Lion King II 0.5714 + Madagascar 0.5382 take its place. Inline delete (tombstone written to WAL): the next query for that id returns 0 results. All three operations are symmetric WAL operations; filters work on inline rows the same as bulk-uploaded rows.

Symmetric to insert. vp.vectors.delete(collection, ids=[...]) writes a tombstone to the WAL; subsequent queries skip the tombstoned key.

vp.vectors.delete("inline-demo", ids=["demo-quickfix-1"])

results = vp.vectors.query("inline-demo", vector=qv, top_k=3,
                           filter={"id": "demo-quickfix-1"})
print(f"  {len(results)} results (expected 0, row is tombstoned)")
store.delete(ids=["demo-quickfix-1"])

results = store.similarity_search_by_vector_with_score(
    qv, k=3, filter={"id": "demo-quickfix-1"},
)
print(f"  {len(results)} results (expected 0, row is tombstoned)")
  0 results (expected 0, row is tombstoned)

The tombstone fold-in happens at the next epoch promotion the same as inserts.

Batched inline upsert

You can pass a list of items in one call. The SDK pushes them as one mutation message; the WAL appends them sequentially with monotonic sequence numbers; the worker overlays apply atomically per item.

batch = [
    {"id": f"demo-batch-{i}", "vector": qv,
     "metadata": {"title": f"Batch row {i}", "year": 2026, "genre": "demo"}}
    for i in range(3)
]
vp.vectors.upsert("inline-demo", vectors=batch)

results = vp.vectors.query("inline-demo", vector=qv, top_k=5,
                           filter={"genre": "demo"})
for r in results:
    print(f"  {r.score:.4f}  {r.metadata.get('title','?'):<25}  id={r.metadata.get('id', r.key)}")
batch = [
    {"id": f"demo-batch-{i}", "vector": qv,
     "metadata": {"title": f"Batch row {i}", "year": 2026, "genre": "demo"}}
    for i in range(3)
]
store.client.vectors.upsert("inline-demo", vectors=batch)

results = store.similarity_search_by_vector_with_score(
    qv, k=5, filter={"genre": "demo"},
)
for doc, score in results:
    m = doc.metadata
    print(f"  {score:.4f}  {m.get('title','?'):<25}  id={m.get('id', doc.id)}")
  1.0000  Batch row 2                id=demo-batch-2
  1.0000  Batch row 0                id=demo-batch-0
  1.0000  Batch row 1                id=demo-batch-1

For up to a few hundred items at a time, batched inline upsert is fine. Past that, you cross into "this is really a bulk load," and the file-upload paths are the right shape (parquet streams from disk, no per-row HTTP round-trip).

When NOT to use inline upsert

When NOT to use inline upsert: a decision guide. Good use cases: user clicked Save, same-second search visibility, small batches. Borderline: batched inline upsert up to a few hundred items, or chunks of around 100 if immediate visibility is truly required. Use bulk upload path for: initial corpus loads, background ingest of large feeds, anything that can wait minutes. Common gotchas: reusing an existing id overwrites vector + metadata wholesale, vector dimensionality must match, delete-then-reinsert creates a brief tombstone window, WAL is per-collection (not global), epoch promotion clears overlay without changing query results. Rule of thumb: more than a few hundred rows? Ask whether you really need millisecond visibility.

The WAL is optimized for small writes that need to be queryable now. It's not the right shape for:

The threshold is fuzzy, but a useful rule: if you're writing more than a few hundred rows, ask whether you really need them to be queryable in milliseconds. If yes, batched inline upsert in chunks of ~100. If no, write a Parquet file and use vp.vectors.upsert(collection, "path/to/file.parquet").

Common gotchas

Related