Documentation 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.
- Source
Slack events & history
Live events, plus paged conversations.history and conversations.replies imports.
- Normalize
Clean messages
Skip RecallHQ’s own posts and system events. Keep thread root and revision.
- Chunk
Thread-aware chunks
One chunk per message, plus thread chunks of ≤600 tokens (
o200k_base). - Embed
text-embedding-3-small
1536-dimension vectors. Only chunks whose text changed are re-embedded.
vector(1536) and a tsvector('simple') · row-level security per workspace and channel- Retrieve
Hybrid search
Both arms run inside the requester’s allowed channels.
Densepgvector cosine, top 30Lexicalcontent words ORed, ts_rank top 30 - Fuse
Reciprocal rank fusion
Each chunk adds
1/(rank+10)to every message it contains. Top 12 messages survive. - Rerank
Decisions API, top 10
Jev 1.13, then GPT-6 Luna Decisions, else RRF order. 4-level relevance rubric.
- Generate
gpt-5.4-mini
Answers only from the 12 evidence messages and cites each claim with [message-id].
- Verify
Check citations
Every cited ID must be in the evidence.
✓ Answer + source links✗ “I couldn’t verify an answer”
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_baseencoding. - 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
| Index | How | Used for |
|---|---|---|
| Dense vector | OpenAI text-embedding-3-small, 1536 dimensions, stored in a pgvector vector(1536) column | Paraphrases and questions that don’t share words with the answer |
| Full text | Generated column to_tsvector('simple', text) with a GIN index | Exact 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.
"Which command fixed the local 28P01 Postgres failure?"
→ command | fixed | local | 28p01 | postgres | failureReciprocal 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.
dense rank 0 → 1/10 = 0.100
lexical rank 1 → 1/11 = 0.091
message score = 0.191Messages 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.
| Level | Rubric |
|---|---|
| 0 | Unrelated to the question. |
| 1 | Same topic, but does not help answer the question. |
| 2 | Partly answers the question or gives useful context for it. |
| 3 | Directly answers the question. |
- Jev 1.13 (
typesafe/jev-1.13-20260917) is tried first. - If it errors or times out, GPT-6 Luna Decisions (
openai/gpt-6-luna-decisions-20261006) scores the same request. - If both fail, or
OPEN_ROUTERis 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.
Workspaces
- Message and channel events
/recallslash commands- OAuth installs
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
Durable jobs
- Ingestion: events, history, replies, deletions
- Commands: retrieve, rerank, answer, deliver
- Embeddings: fill the vector backlog
Postgres + pgvector
Messages, chunks, jobs, encrypted installs.
OpenAI · OpenRouter
Embeddings and answers; Decisions API reranking.
- 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 LOCKEDand 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
| Layer | What it enforces |
|---|---|
| Database roles | Every 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 security | Forced 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 context | app.source, app.workspace and app.channels are set from the requester’s current channel membership before retrieval. |
| Column grants | The reader role can check that an installation is active, but cannot read OAuth credentials or channel names. |
| Encrypted secrets | Each workspace’s bot token is stored Fernet-encrypted with SLACK_ENCRYPTION_KEY. Queued command payloads are encrypted the same way. |
| Demo separation | The 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
| Setting | Default |
|---|---|
GENERATION_MODEL | gpt-5.4-mini |
EMBEDDING_MODEL | text-embedding-3-small |
EMBEDDING_DIMENSION | 1536 |
OPEN_ROUTER | OpenRouter API key; reranking is off when empty |
RERANK_MODEL | typesafe/jev-1.13-20260917 |
RERANK_FALLBACK_MODEL | openai/gpt-6-luna-decisions-20261006 |
RERANK_TOP_K | 10 |