defmodule AgentSea.VectorStore.Pinecone do
@moduledoc """
A [Pinecone](https://pinecone.io) `AgentSea.VectorStore` over its data-plane
REST API (`Req`) — a managed/remote store alongside the in-memory, pgvector,
and Qdrant stores.
The "store" is a config map (index host + api key + optional namespace). Record
`:text` rides in the point metadata under `"text"` (Pinecone metadata is flat),
and is split back out on read. Index *creation* is a control-plane concern
(Pinecone console / control API) — this adapter covers the data plane.
Per the behaviour, callbacks raise on transport/API errors.
## Setup
store = AgentSea.VectorStore.Pinecone.store(host: "https://my-index-xxxx.svc.pinecone.io")
AgentSea.Embeddings.new(store_mod: AgentSea.VectorStore.Pinecone, store: store, embedder: ...)
"""
@behaviour AgentSea.VectorStore
@type store :: %{
host: String.t(),
api_key: String.t() | nil,
namespace: String.t(),
adapter: (... -> any()) | nil
}
@doc "Build a store. Options: `:host` (required), `:api_key`, `:namespace`, `:adapter`."
@spec store(keyword()) :: store()
def store(opts) do
%{
host: Keyword.fetch!(opts, :host),
api_key: opts[:api_key] || System.get_env("PINECONE_API_KEY"),
namespace: Keyword.get(opts, :namespace, ""),
adapter: opts[:adapter]
}
end
# --- VectorStore behaviour ---
@impl true
def upsert(store, records) do
vectors =
Enum.map(records, fn record ->
%{id: to_string(record.id), values: record.vector, metadata: metadata(record)}
end)
_ = request!(store, "/vectors/upsert", %{vectors: vectors, namespace: store.namespace})
:ok
end
@impl true
def query(store, vector, k, opts) do
body = %{vector: vector, topK: k, includeMetadata: true, namespace: store.namespace}
body = if opts[:filter], do: Map.put(body, :filter, opts[:filter]), else: body
result = request!(store, "/query", body)
Enum.map(result["matches"] || [], &to_hit/1)
end
@impl true
def delete(store, ids) do
body = %{ids: Enum.map(ids, &to_string/1), namespace: store.namespace}
_ = request!(store, "/vectors/delete", body)
:ok
end
@impl true
def count(store) do
result = request!(store, "/describe_index_stats", %{})
result["totalVectorCount"] || 0
end
# --- Helpers ---
defp metadata(record) do
base = Map.get(record, :metadata, %{})
case Map.get(record, :text) do
nil -> base
text -> Map.put(base, "text", text)
end
end
defp to_hit(match) do
md = match["metadata"] || %{}
%{id: match["id"], score: match["score"], text: md["text"], metadata: Map.delete(md, "text")}
end
defp request!(store, path, body) do
case request(store, path, body) do
{:ok, result} -> result
{:error, reason} -> raise "Pinecone POST #{path} failed: #{inspect(reason)}"
end
end
defp request(store, path, body) do
req =
[base_url: store.host, headers: headers(store)]
|> maybe_put(:adapter, store.adapter)
|> Req.new()
case Req.post(req, url: path, json: body) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body}
{:ok, %Req.Response{status: status, body: body}} -> {:error, {:http_error, status, body}}
{:error, reason} -> {:error, reason}
end
end
defp headers(%{api_key: nil}), do: []
defp headers(%{api_key: key}), do: [{"api-key", key}]
defp maybe_put(kw, _key, nil), do: kw
defp maybe_put(kw, key, value), do: Keyword.put(kw, key, value)
end