Skip to main content

lib/agent_sea/vector_store/qdrant.ex

defmodule AgentSea.VectorStore.Qdrant do
  @moduledoc """
  A [Qdrant](https://qdrant.tech) `AgentSea.VectorStore` over its REST API (`Req`)
  — a managed/remote alternative to the in-memory and pgvector stores.

  The "store" is a config map (base url + collection + optional api key), so it
  drops into `AgentSea.Embeddings` like the others. Similarity is cosine; record
  `:text` and `:metadata` ride along in the point payload.

  Per the behaviour, the callbacks raise on transport/API errors (like the
  pgvector store's `Postgrex.query!`). Note Qdrant point ids must be unsigned
  integers or UUIDs.

  ## Setup

      store = AgentSea.VectorStore.Qdrant.store(url: "http://localhost:6333", collection: "docs")
      :ok = AgentSea.VectorStore.Qdrant.ensure_collection(store, 1536)

      AgentSea.Embeddings.new(store_mod: AgentSea.VectorStore.Qdrant, store: store, embedder: ...)
  """

  @behaviour AgentSea.VectorStore

  @type store :: %{
          url: String.t(),
          collection: String.t(),
          api_key: String.t() | nil,
          adapter: (... -> any()) | nil
        }

  @doc "Build a store. Options: `:collection` (required), `:url`, `:api_key`, `:adapter`."
  @spec store(keyword()) :: store()
  def store(opts) do
    %{
      url: Keyword.get(opts, :url, "http://localhost:6333"),
      collection: Keyword.fetch!(opts, :collection),
      api_key: opts[:api_key],
      adapter: opts[:adapter]
    }
  end

  @doc "Create the collection if absent, with the given vector size and distance (default Cosine)."
  @spec ensure_collection(store(), pos_integer(), keyword()) :: :ok
  def ensure_collection(store, dimensions, opts \\ []) do
    distance = Keyword.get(opts, :distance, "Cosine")
    body = %{vectors: %{size: dimensions, distance: distance}}
    _ = request!(store, :put, "/collections/#{store.collection}", body)
    :ok
  end

  # --- VectorStore behaviour ---

  @impl true
  def upsert(store, records) do
    points =
      Enum.map(records, fn record ->
        %{
          id: record.id,
          vector: record.vector,
          payload: %{
            "text" => Map.get(record, :text),
            "metadata" => Map.get(record, :metadata, %{})
          }
        }
      end)

    _ = request!(store, :put, "/collections/#{store.collection}/points", %{points: points})
    :ok
  end

  @impl true
  def query(store, vector, k, opts) do
    body = %{vector: vector, limit: k, with_payload: true}
    body = if opts[:filter], do: Map.put(body, :filter, opts[:filter]), else: body

    result = request!(store, :post, "/collections/#{store.collection}/points/search", body)
    Enum.map(result["result"] || [], &to_hit/1)
  end

  @impl true
  def delete(store, ids) do
    _ = request!(store, :post, "/collections/#{store.collection}/points/delete", %{points: ids})
    :ok
  end

  @impl true
  def count(store) do
    result =
      request!(store, :post, "/collections/#{store.collection}/points/count", %{exact: true})

    get_in(result, ["result", "count"]) || 0
  end

  # --- HTTP ---

  defp request!(store, method, path, body) do
    case request(store, method, path, body) do
      {:ok, result} ->
        result

      {:error, reason} ->
        raise "Qdrant #{method} #{path} failed: #{inspect(reason)}"
    end
  end

  defp request(store, method, path, body) do
    req =
      [base_url: store.url, headers: headers(store)]
      |> maybe_put(:adapter, store.adapter)
      |> Req.new()

    response =
      case method do
        :put -> Req.put(req, url: path, json: body)
        :post -> Req.post(req, url: path, json: body)
      end

    case response 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 to_hit(%{"id" => id} = point) do
    payload = point["payload"] || %{}
    %{id: id, score: point["score"], text: payload["text"], metadata: payload["metadata"] || %{}}
  end

  defp maybe_put(kw, _key, nil), do: kw
  defp maybe_put(kw, key, value), do: Keyword.put(kw, key, value)
end