lib/openagents/memories/retrieval/lexical.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Memories.Retrieval.Lexical do
  @moduledoc """
  PostgreSQL full-text scoring over memory bodies. **This is the stand-in, not
  the target.**

  The workspace retrieval rule is that user-facing retrieval routes on meaning,
  not on words: embeddings and cosine similarity, which
  `OpenAgents.Memories.Retrieval.Semantic` implements. This module exists so a
  deployment with no embedding credential still recalls something, and it is
  marked here the way the capability rail marks its own lexical scorer
  (`OpenAgents.Tools.Selector`) rather than being presented as the answer.

  What it cannot do is the reason the marking matters. "Remember I use pnpm,
  not npm" shares no word with "install the deps", so word overlap scores that
  pair at zero. The `user` bucket is attached regardless of score for exactly
  that reason (`OpenAgents.Memories.recall/3`), which is a bound on the damage
  rather than a fix. Only the semantic backend actually connects the two.

  Scoring is `ts_rank_cd` over the generated `search_vector` column, read
  through the partial GIN index on live rows, under the `english` text-search
  configuration so that stop words drop out and words stem.

  The query is the turn's words joined with `or`, not with the `and` that
  `websearch_to_tsquery` defaults to. A turn is a sentence, not a search box:
  requiring every word of "the migration failed" to appear in a memory would
  match nothing an account ever wrote. Sharing one content word is the bar,
  which is the same bar the issue states for the `learned` bucket, and stop
  words are gone before it is applied so "the" cannot clear it.
  """

  @behaviour OpenAgents.Memories.Retrieval

  import Ecto.Query

  alias OpenAgents.Memories.Memory
  alias OpenAgents.Repo

  # Only what fits a text-search query. A whole conversation turn pasted into
  # `websearch_to_tsquery` costs more to parse than the ranking is worth.
  @maximum_query_characters 512
  @maximum_query_words 64

  # A word the parser would read as an operator rather than as a word. They are
  # stripped so a turn saying "or" cannot produce `or or or` and fail to parse.
  @operators ~w(or and not)

  @impl true
  def available?, do: true

  # Any match at all. `ts_rank_cd` is unnormalized and its magnitude means
  # nothing across queries, so the only honest floor is "the words appear".
  @impl true
  def floor, do: 0.0

  @impl true
  def score(user_id, query, candidates) do
    text = prepare(query)
    ids = Enum.map(candidates, & &1.id)

    if text == "" or ids == [] do
      {:ok, %{}}
    else
      {:ok, ranked(user_id, text, ids)}
    end
  rescue
    _error -> :error
  end

  @impl true
  def score_shared(query, candidates) do
    text = prepare(query)
    ids = Enum.map(candidates, & &1.id)

    if text == "" or ids == [] do
      {:ok, %{}}
    else
      {:ok, shared(text, ids)}
    end
  rescue
    _error -> :error
  end

  # `user_id` is the scope predicate, and it is written here rather than
  # inherited from the candidate ids (MEMORY-010). The id list narrows the
  # read; it does not bound it, and a caller that assembled that list wrongly
  # would otherwise reach another account's rows through this query.
  defp ranked(user_id, text, ids) do
    from(memory in Memory,
      where: memory.user_id == ^user_id,
      where: is_nil(memory.superseded_by_id),
      where: memory.id in ^ids,
      where: fragment("? @@ websearch_to_tsquery('english', ?)", memory.search_vector, ^text),
      select: {
        memory.id,
        fragment(
          "ts_rank_cd(?, websearch_to_tsquery('english', ?), 32)",
          memory.search_vector,
          ^text
        )
      }
    )
    |> Repo.all()
    |> Map.new(fn {id, rank} -> {id, rank / 1} end)
  end

  # The shared bucket's ranking query, and the one query in this module that
  # names no account. `bucket` is what stands in its place, which is
  # MEMORY-001's amendment written as a predicate rather than as a filter over
  # the result: a caller who assembled the candidate ids wrongly still cannot
  # reach an account-scoped row through this read. The caller narrowed those
  # ids to admitted, live, `ledger`-or-above rows before it got here
  # (`OpenAgents.Memories.SystemRecall`); this query re-states the two
  # predicates it can state cheaply rather than trusting the list alone.
  defp shared(text, ids) do
    from(memory in Memory,
      where: memory.bucket == "system",
      where: is_nil(memory.superseded_by_id),
      where: memory.id in ^ids,
      where: fragment("? @@ websearch_to_tsquery('english', ?)", memory.search_vector, ^text),
      select: {
        memory.id,
        fragment(
          "ts_rank_cd(?, websearch_to_tsquery('english', ?), 32)",
          memory.search_vector,
          ^text
        )
      }
    )
    |> Repo.all()
    |> Map.new(fn {id, rank} -> {id, rank / 1} end)
  end

  # `websearch_to_tsquery` reads `-` as negation, quotes as phrases, and `or`
  # as disjunction. A turn carrying punctuation would ask for something the
  # reader did not, so everything but letters and digits goes first; then the
  # words are rejoined with `or`, which is the operator this wants and not the
  # one the parser assumes.
  defp prepare(query) do
    query
    |> String.slice(0, @maximum_query_characters)
    |> String.replace(~r/[^\p{L}\p{N}\s]/u, " ")
    |> String.split(~r/\s+/u, trim: true)
    |> Enum.reject(&(&1 in @operators))
    |> Enum.take(@maximum_query_words)
    |> Enum.join(" or ")
  end
end