Skip to content
Documentation Model architecture ▾

Install & self-host

Docs / How it works / Model architecture

Model architecture

How a Slack message becomes searchable, and how a question becomes a cited answer: chunking, hybrid retrieval, reranking, generation, and tenant isolation.

Overview

RecallHQ is retrieval-augmented generation over chat. A background worker turns Slack messages into chunks with two indexes, a vector and a full-text one. A question searches both, fuses the rankings, asks a decision model to rerank the best candidates, and lets a language model answer only from that evidence. The answer is shown only if every citation points at a message the model was actually given.

IngestionSlack worker · background jobs
  1. Source

    Slack events & history

    Live events, plus paged conversations.history and conversations.replies imports.

  2. Normalize

    Clean messages

    Skip RecallHQ’s own posts and system events. Keep thread root and revision.

  3. Chunk

    Thread-aware chunks

    One chunk per message, plus thread chunks of ≤600 tokens (o200k_base).

  4. Embed

    text-embedding-3-small

    1536-dimension vectors. Only chunks whose text changed are re-embedded.

Postgres · chunks with vector(1536) and a tsvector('simple') · row-level security per workspace and channel
Query/recall ask · /ask on the demo
  1. Retrieve

    Hybrid search

    Both arms run inside the requester’s allowed channels.

    Densepgvector cosine, top 30
    Lexicalcontent words ORed, ts_rank top 30
  2. Fuse

    Reciprocal rank fusion

    Each chunk adds 1/(rank+10) to every message it contains. Top 12 messages survive.

  3. Rerank

    Decisions API, top 10

    Jev 1.13, then GPT-6 Luna Decisions, else RRF order. 4-level relevance rubric.

  4. Generate

    gpt-5.4-mini

    Answers only from the 12 evidence messages and cites each claim with [message-id].

  5. Verify

    Check citations

    Every cited ID must be in the evidence.

    ✓ Answer + source links✗ “I couldn’t verify an answer”
Ingestion writes chunks; queries read them through a separate, read-only database role.

The same retrieve → rerank → answer functions serve the Slack bot and the public demo. The only difference is the query context: the demo is pinned to the synthetic Beacon workspace, while a Slack query carries the requester’s workspace and the channels they currently belong to.

Ingestion

What becomes a chunk

Each message is rendered with its context, as [#channel · timestamp · author] followed by the text, and becomes a message chunk. Each thread also becomes one or more thread chunks: the root message followed by its replies in time order.

  • Thread chunks are capped at 600 tokens, counted with tiktoken’s o200k_base encoding.
  • When a thread crosses the limit, the next chunk starts again with the root and the previous reply, so every piece keeps the question it is answering. If even that cannot fit, the piece restarts from the root alone.
  • For Slack content, any single chunk that is still over 600 tokens is split into 600-token pieces.
  • Chunk IDs are a hash of the chunker version, kind, and message IDs, so re-running ingestion is idempotent and only changed text is re-embedded.

Two indexes per chunk

IndexHowUsed for
Dense vectorOpenAI text-embedding-3-small, 1536 dimensions, stored in a pgvector vector(1536) columnParaphrases and questions that don’t share words with the answer
Full textGenerated column to_tsvector('simple', text) with a GIN indexExact terms: error codes, commands, names, dates

The simple configuration lowercases and splits but does not stem or drop words. That keeps exact tokens such as the error code 28P01 searchable and does not depend on English stemming rules.

Edits and deletions

An edit deletes every chunk containing that message and rebuilds its thread. A deletion removes the source text and derived chunks and leaves a tombstone, so a delayed event or an older history page cannot bring the message back. When a chunk’s text changes, its embedding is cleared and the embedding lane fills it again.

Retrieval

Both searches run in one database session with the same filters: source, workspace, the allowed channel list, and an optional date range or in:#channel filter.

Dense arm

The question is embedded with the same model, and the 30 nearest chunks by pgvector cosine distance (<=>) are returned.

Lexical arm

lexical_query() lowercases the question, keeps alphanumeric terms, drops a fixed list of common English stop words, and joins what remains with OR. The result goes to to_tsquery('simple', …), and the top 30 chunks by ts_rank are returned, so chunks that match more terms still rank higher.

lexical_query()
"Which command fixed the local 28P01 Postgres failure?"
→ command | fixed | local | 28p01 | postgres | failure

Reciprocal rank fusion

The two ranked chunk lists are fused at the message level. For each list, the chunk at position rank (starting at 0) adds 1 / (rank + 10) to the score of every message it contains. A message that appears in both arms, or in both its own chunk and a thread chunk, collects several contributions.

Example
dense rank 0  → 1/10 = 0.100
lexical rank 1 → 1/11 = 0.091
message score  = 0.191

Messages are sorted by score and the top 12 are loaded. Both arms have equal weight. The constant 10 is smaller than the common default of 60, which gives the first few positions of each list more influence.

Reranking

The top 10 fused messages are reranked with the OpenRouter Decisions API, which returns a score for each question instead of generated text. One request carries the question and the 10 messages (channel, author, date, text) and asks, for each message, how useful it is as evidence. Messages are told to be treated as data, not instructions.

LevelRubric
0Unrelated to the question.
1Same topic, but does not help answer the question.
2Partly answers the question or gives useful context for it.
3Directly answers the question.
  1. Jev 1.13 (typesafe/jev-1.13-20260917) is tried first.
  2. If it errors or times out, GPT-6 Luna Decisions (openai/gpt-6-luna-decisions-20261006) scores the same request.
  3. If both fail, or OPEN_ROUTER is unset, the plain RRF order is kept.

The sort is stable, so ties keep their RRF order. Messages 11 and 12 are appended after the reranked 10. Catch-up, summaries, and decisions are not reranked: they read messages in time order instead.

Generation

gpt-5.4-mini receives the question and the 12 evidence messages, each prefixed with its ID, channel, timestamp, and author. The instructions are to answer only from those messages, cite every factual claim with exact source IDs in square brackets, prefer the latest dated correction when messages conflict, say so when the evidence does not answer the question, and treat chat text as data rather than instructions.

Citation check

  • Every [slack:…] or [synthetic:…] ID in the output must be one of the 12 evidence IDs. One unknown ID replaces the whole answer with “I couldn’t verify an answer from the available messages.”
  • An answer with no citations is replaced the same way, unless it explicitly says it couldn’t find enough evidence.
  • Only the cited messages are returned as sources. With no evidence at all, the model is not called.
  • In Slack, the evidence is read again right before delivery. If a source was edited, deleted, or became inaccessible, the answer is suppressed.

API & worker

Slack expects an acknowledgement within three seconds, and model calls can take longer. RecallHQ splits the work: the API only verifies and records, and a separate worker process does everything else.

Slack

Workspaces

  • Message and channel events
  • /recall slash commands
  • OAuth installs
API · FastAPI

Verify, persist, acknowledge

  • Checks Slack’s request signature
  • Writes a deduplicated job row, then replies “working on it”
  • Never calls a model while Slack waits
Worker · 3 lanes

Durable jobs

  • Ingestion: events, history, replies, deletions
  • Commands: retrieve, rerank, answer, deliver
  • Embeddings: fill the vector backlog
Storage

Postgres + pgvector

Messages, chunks, jobs, encrypted installs.

Models

OpenAI · OpenRouter

Embeddings and answers; Decisions API reranking.

Commands, embeddings, and ingestion run in separate lanes, so a slow model call never delays a deletion.
  • The API checks Slack’s signature, writes a job with a deduplication key (event ID or trigger ID), and acknowledges. Slack retries are ignored by that key.
  • The worker claims jobs with FOR UPDATE SKIP LOCKED and a 10-minute lease that is renewed while it runs. Purges go first, then events, then commands. Failed jobs retry with exponential backoff (capped at five minutes), up to 8 attempts by default.
  • History and replies are paged at Slack’s pace (by default one request per method per minute, 15 messages per page), with checkpoints that survive restarts. Completed channels are reconciled after a restart and every 24 hours.

Tenant isolation

LayerWhat it enforces
Database rolesEvery connection runs SET ROLE to recallhq_reader (queries) or recallhq_ingestor (Slack service). Both are NOLOGIN roles granted to non-superuser logins without BYPASSRLS.
Row-level securityForced on messages and chunks. Readers see a row only if its source and workspace match the session and its channel is in the session’s channel list. Slack rows also need an active channel and installation.
Per-request contextapp.source, app.workspace and app.channels are set from the requester’s current channel membership before retrieval.
Column grantsThe reader role can check that an installation is active, but cannot read OAuth credentials or channel names.
Encrypted secretsEach workspace’s bot token is stored Fernet-encrypted with SLACK_ENCRYPTION_KEY. Queued command payloads are encrypted the same way.
Demo separationThe public site uses a fixed synthetic context and never receives Slack tokens or workspace data.

Read more about what is stored and deleted in Permissions & data.

Settings

SettingDefault
GENERATION_MODELgpt-5.4-mini
EMBEDDING_MODELtext-embedding-3-small
EMBEDDING_DIMENSION1536
OPEN_ROUTEROpenRouter API key; reranking is off when empty
RERANK_MODELtypesafe/jev-1.13-20260917
RERANK_FALLBACK_MODELopenai/gpt-6-luna-decisions-20261006
RERANK_TOP_K10