183 lines
4.8 KiB
Elixir
183 lines
4.8 KiB
Elixir
defmodule Pleroma.Search.QdrantSearch do
|
|
@behaviour Pleroma.Search.SearchBackend
|
|
import Ecto.Query
|
|
|
|
alias Pleroma.Activity
|
|
alias Pleroma.Config.Getting, as: Config
|
|
|
|
alias __MODULE__.OpenAIClient
|
|
alias __MODULE__.QdrantClient
|
|
|
|
import Pleroma.Search.Meilisearch, only: [object_to_search_data: 1]
|
|
import Pleroma.Search.DatabaseSearch, only: [maybe_fetch: 3]
|
|
|
|
@impl true
|
|
def create_index do
|
|
payload = Config.get([Pleroma.Search.QdrantSearch, :qdrant_index_configuration])
|
|
|
|
with {:ok, %{status: 200}} <- QdrantClient.put("/collections/posts", payload) do
|
|
:ok
|
|
else
|
|
e -> {:error, e}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def drop_index do
|
|
with {:ok, %{status: 200}} <- QdrantClient.delete("/collections/posts") do
|
|
:ok
|
|
else
|
|
e -> {:error, e}
|
|
end
|
|
end
|
|
|
|
def get_embedding(text) do
|
|
with {:ok, %{body: %{"data" => [%{"embedding" => embedding}]}}} <-
|
|
OpenAIClient.post("/v1/embeddings", %{
|
|
input: text,
|
|
model: Config.get([Pleroma.Search.QdrantSearch, :openai_model])
|
|
}) do
|
|
{:ok, embedding}
|
|
else
|
|
_ ->
|
|
{:error, "Failed to get embedding"}
|
|
end
|
|
end
|
|
|
|
defp actor_from_activity(%{data: %{"actor" => actor}}) do
|
|
actor
|
|
end
|
|
|
|
defp actor_from_activity(_), do: nil
|
|
|
|
defp build_index_payload(activity, embedding) do
|
|
actor = actor_from_activity(activity)
|
|
published_at = activity.data["published"]
|
|
|
|
%{
|
|
points: [
|
|
%{
|
|
id: activity.id |> FlakeId.from_string() |> Ecto.UUID.cast!(),
|
|
vector: embedding,
|
|
payload: %{actor: actor, published_at: published_at}
|
|
}
|
|
]
|
|
}
|
|
end
|
|
|
|
defp build_search_payload(embedding, options) do
|
|
base = %{
|
|
vector: embedding,
|
|
limit: options[:limit] || 20,
|
|
offset: options[:offset] || 0
|
|
}
|
|
|
|
if author = options[:author] do
|
|
Map.put(base, :filter, %{
|
|
must: [%{key: "actor", match: %{value: author.ap_id}}]
|
|
})
|
|
else
|
|
base
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def add_to_index(activity) do
|
|
# This will only index public or unlisted notes
|
|
maybe_search_data = object_to_search_data(activity.object)
|
|
|
|
if activity.data["type"] == "Create" and maybe_search_data do
|
|
with {:ok, embedding} <- get_embedding(maybe_search_data.content),
|
|
{:ok, %{status: 200}} <-
|
|
QdrantClient.put(
|
|
"/collections/posts/points",
|
|
build_index_payload(activity, embedding)
|
|
) do
|
|
:ok
|
|
else
|
|
e -> {:error, e}
|
|
end
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def remove_from_index(object) do
|
|
activity = Activity.get_by_object_ap_id_with_object(object.data["id"])
|
|
id = activity.id |> FlakeId.from_string() |> Ecto.UUID.cast!()
|
|
|
|
with {:ok, %{status: 200}} <-
|
|
QdrantClient.post("/collections/posts/points/delete", %{"points" => [id]}) do
|
|
:ok
|
|
else
|
|
e -> {:error, e}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def search(user, original_query, options) do
|
|
query = "Represent this sentence for searching relevant passages: #{original_query}"
|
|
|
|
with {:ok, embedding} <- get_embedding(query),
|
|
{:ok, %{body: %{"result" => result}}} <-
|
|
QdrantClient.post(
|
|
"/collections/posts/points/search",
|
|
build_search_payload(embedding, options)
|
|
) do
|
|
ids =
|
|
Enum.map(result, fn %{"id" => id} ->
|
|
Ecto.UUID.dump!(id)
|
|
end)
|
|
|
|
from(a in Activity, where: a.id in ^ids)
|
|
|> Activity.with_preloaded_object()
|
|
|> Activity.restrict_deactivated_users()
|
|
|> Ecto.Query.order_by([a], fragment("array_position(?, ?)", ^ids, a.id))
|
|
|> Pleroma.Repo.all()
|
|
|> maybe_fetch(user, original_query)
|
|
else
|
|
_ ->
|
|
[]
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def healthcheck_endpoints do
|
|
qdrant_health =
|
|
Config.get([Pleroma.Search.QdrantSearch, :qdrant_url])
|
|
|> URI.parse()
|
|
|> Map.put(:path, "/healthz")
|
|
|> URI.to_string()
|
|
|
|
openai_health = Config.get([Pleroma.Search.QdrantSearch, :openai_healthcheck_url])
|
|
|
|
[qdrant_health, openai_health] |> Enum.filter(& &1)
|
|
end
|
|
end
|
|
|
|
defmodule Pleroma.Search.QdrantSearch.OpenAIClient do
|
|
use Tesla
|
|
alias Pleroma.Config.Getting, as: Config
|
|
|
|
plug(Tesla.Middleware.BaseUrl, Config.get([Pleroma.Search.QdrantSearch, :openai_url]))
|
|
plug(Tesla.Middleware.JSON)
|
|
|
|
plug(Tesla.Middleware.Headers, [
|
|
{"Authorization",
|
|
"Bearer #{Pleroma.Config.get([Pleroma.Search.QdrantSearch, :openai_api_key])}"}
|
|
])
|
|
end
|
|
|
|
defmodule Pleroma.Search.QdrantSearch.QdrantClient do
|
|
use Tesla
|
|
alias Pleroma.Config.Getting, as: Config
|
|
|
|
plug(Tesla.Middleware.BaseUrl, Config.get([Pleroma.Search.QdrantSearch, :qdrant_url]))
|
|
plug(Tesla.Middleware.JSON)
|
|
|
|
plug(Tesla.Middleware.Headers, [
|
|
{"api-key", Pleroma.Config.get([Pleroma.Search.QdrantSearch, :qdrant_api_key])}
|
|
])
|
|
end
|