Ingesting Slack conversations into LlamaIndex gives you a searchable, queryable knowledge base built from your team’s actual discussions. This LlamaIndex Slack connector tutorial walks through the complete pipeline: configuring a Slack app, pulling messages with the SlackReader, transforming them into documents, and building an index you can query with any LLM. By the end you’ll have a working ingestion script and a clear path to productionizing it.
Step 1: Create a Slack app with the right scopes
You need a Slack app installed in your workspace with permission to read channels and messages. Go to https://api.slack.com/apps and click Create New App → From scratch. Name it (for example, “llamaindex-ingestion”) and pick your workspace.
Under OAuth & Permissions, add these Bot Token Scopes:
channels:read— list public channelsgroups:read— list private channels the bot is inchannels:history— read messages in public channelsgroups:history— read messages in private channelsusers:read— resolve user IDs to namesreactions:read— optional, if you want reaction metadata
Scroll up and click Install to Workspace. Copy the Bot User OAuth Token (starts with xoxb-). Store it in your environment as SLACK_BOT_TOKEN.
export SLACK_BOT_TOKEN=xoxb-your-token-here
Step 2: Install the LlamaIndex Slack reader
The connector lives in the llama-index-readers-slack package. Install it alongside the core library and your vector store of choice.
pip install llama-index llama-index-readers-slack llama-index-vector-stores-chroma
If you prefer a different vector store (Pinecone, Weaviate, Qdrant), swap the last package. The reader API stays the same.
Step 3: Write the ingestion script
Create a file named ingest_slack.py. The script below does three things: fetches messages from the channels you specify, converts each message into a Document with useful metadata, and upserts them into a Chroma collection.
import os
from datetime import datetime, timezone
from llama_index.core import Document, VectorStoreIndex, StorageContext
from llama_index.core.node_parser import SentenceSplitter
from llama_index.readers.slack import SlackReader
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb
SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN")
if not SLACK_BOT_TOKEN:
raise RuntimeError("Set SLACK_BOT_TOKEN in your environment")
# Channels to ingest. Use channel IDs (not names) for reliability.
CHANNEL_IDS = [
"C01234567", # general
"C01234568", # engineering
"C01234569", # product-discussion
]
# How far back to pull. None = all history (respects Slack plan limits).
OLDEST_DATE = datetime(2024, 1, 1, tzinfo=timezone.utc)
def build_documents(messages: list[dict]) -> list[Document]:
"""Convert raw Slack message dicts into LlamaIndex Documents."""
docs = []
for msg in messages:
# Skip bot messages, system messages, and empty text
if msg.get("subtype") in ("bot_message", "channel_join", "channel_leave"):
continue
text = msg.get("text", "").strip()
if not text:
continue
# Build metadata for filtering and citation
metadata = {
"channel_id": msg.get("channel_id"),
"channel_name": msg.get("channel_name"),
"user_id": msg.get("user"),
"user_name": msg.get("username"),
"ts": msg.get("ts"), # Slack timestamp string
"permalink": msg.get("permalink"),
"thread_ts": msg.get("thread_ts"),
"reply_count": msg.get("reply_count", 0),
"reactions": msg.get("reactions", []),
}
docs.append(Document(text=text, metadata=metadata))
return docs
def main():
# 1. Pull messages
reader = SlackReader(slack_token=SLACK_BOT_TOKEN)
raw_messages = reader.load_data(
channel_ids=CHANNEL_IDS,
earliest_date=OLDEST_DATE,
# latest_date=datetime.now(timezone.utc), # optional upper bound
)
print(f"Fetched {len(raw_messages)} raw messages from Slack")
# 2. Transform to Documents
documents = build_documents(raw_messages)
print(f"Built {len(documents)} documents after filtering")
# 3. Chunk with overlap for better retrieval
parser = SentenceSplitter(chunk_size=512, chunk_overlap=64)
nodes = parser.get_nodes_from_documents(documents)
print(f"Split into {len(nodes)} nodes")
# 4. Persist to Chroma
chroma_client = chromadb.PersistentClient(path="./chroma_slack")
chroma_collection = chroma_client.get_or_create_collection("slack_messages")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex(nodes, storage_context=storage_context, show_progress=True)
print("Index built and persisted to ./chroma_slack")
if __name__ == "__main__":
main()
Run it:
python ingest_slack.py
Verify success: The script prints message counts at each stage. Open the Chroma directory and confirm the collection exists:
python -c "
import chromadb
client = chromadb.PersistentClient(path='./chroma_slack')
col = client.get_collection('slack_messages')
print(f'Collection count: {col.count()}')
print(col.peek(3))
"
You should see a non-zero count and sample records with your metadata fields.
Step 4: Query the index
Now that data is indexed, write a tiny query script to sanity-check retrieval.
import os
from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb
chroma_client = chromadb.PersistentClient(path="./chroma_slack")
chroma_collection = chroma_client.get_collection("slack_messages")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_vector_store(vector_store, storage_context=storage_context)
query_engine = index.as_query_engine(similarity_top_k=5)
response = query_engine.query("What did we decide about the API versioning strategy?")
print(response)
for node in response.source_nodes:
meta = node.metadata
print(f" — #{meta.get('channel_name')} @{meta.get('user_name')} ({meta.get('ts')})")
Run it and you should get a synthesized answer with citations pointing back to the original Slack messages.
Step 5: Handle incremental updates
Re-ingesting the entire history on every run is wasteful. Slack’s conversations.history supports a latest parameter (timestamp), so you can fetch only new messages since your last run. Persist the latest timestamp you’ve processed and pass it as latest_date on the next run.
import json
from pathlib import Path
STATE_FILE = Path("./ingestion_state.json")
def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return {"latest_ts": None}
def save_state(state: dict):
STATE_FILE.write_text(json.dumps(state))
def main():
state = load_state()
latest = state.get("latest_ts")
reader = SlackReader(slack_token=SLACK_BOT_TOKEN)
# If latest exists, use it as earliest_date for the *next* batch.
# SlackReader accepts earliest_date and latest_date as datetime objects.
# Convert string ts (e.g., "1704067200.123456") to datetime:
from datetime import datetime, timezone
earliest = datetime.fromtimestamp(float(latest), tz=timezone.utc) if latest else OLDEST_DATE
raw_messages = reader.load_data(
channel_ids=CHANNEL_IDS,
earliest_date=earliest,
)
# ... same transform / index logic ...
# After successful upsert, update state with the newest message timestamp
if raw_messages:
newest_ts = max(msg["ts"] for msg in raw_messages)
state["latest_ts"] = newest_ts
save_state(state)
Schedule this script via cron, GitHub Actions, or your orchestrator of choice. A daily run keeps the index fresh without hammering Slack’s rate limits.
Step 6: Respect rate limits and pagination
SlackReader handles pagination internally, but Slack imposes tiered rate limits (typically 50+ requests/minute for conversations.history). If you ingest dozens of channels with years of history, you’ll hit limits. Two practical mitigations:
- Batch channels: Process a few channels per run, rotating through the full list over several hours.
- Backoff and retry: Wrap the reader call in a retry loop with exponential backoff.
import time
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from slack_sdk.errors import SlackApiError
@retry(
wait=wait_exponential(multiplier=1, min=2, max=60),
stop=stop_after_attempt(5),
retry=retry_if_exception_type(SlackApiError),
)
def fetch_with_backoff(reader, channel_ids, earliest_date):
return reader.load_data(channel_ids=channel_ids, earliest_date=earliest_date)
The tenacity library is a dependency of LlamaIndex, so it’s already available.
Step 7: Enrich metadata for better retrieval
Raw Slack messages lack context. A message like “approved the PR” is useless without knowing which PR, which repo, and who approved. Enrich during the build_documents step:
- Resolve user IDs to real names using
users.info(already done bySlackReaderwhenusers:readscope is present). - Fetch thread context: If
thread_tsexists, pull the parent message and prepend it to the reply text. - Link to GitHub/Jira: If your team pastes issue URLs, extract them and add
github_pr,jira_ticketfields to metadata. - Channel topic/purpose: Call
conversations.infoonce per channel and attachchannel_topicandchannel_purposeto every message from that channel.
Example thread enrichment:
def enrich_with_thread_context(reader: SlackReader, msg: dict, all_messages: list[dict]) -> str:
"""Prepend parent message text if this is a thread reply."""
thread_ts = msg.get("thread_ts")
if not thread_ts or thread_ts == msg.get("ts"):
return msg.get("text", "")
# Find parent in already-fetched messages (or fetch via conversations.replies)
parent = next((m for m in all_messages if m.get("ts") == thread_ts), None)
if parent:
return f"[Thread parent: {parent.get('text', '')}]\n{msg.get('text', '')}"
return msg.get("text", "")
Step 8: Secure the pipeline for production
A few hardening steps before you point this at a production workspace:
- Secret management: Never hardcode
SLACK_BOT_TOKEN. Use your platform’s secret store (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, GitHub Actions secrets). - Least-privilege token: Use a user token (
xoxp-) withchannels:historyonly for specific channels viaconversations.membersif your org requires it. Bot tokens are simpler but broader. - Data retention policy: Decide how long you keep vectors. Add a TTL field to metadata and run a periodic cleanup job that deletes nodes older than your policy.
- Access control: If you expose a query API, gate it behind your auth system. The index itself has no ACL; enforcement happens at the query layer.
- Observability: Log ingestion duration, message counts, error rates, and index size. Alert on anomalies (e.g., sudden drop in messages ingested).
Step 9: Optional — hybrid search with BM25
Vector search excels at semantic similarity but struggles with exact matches (error codes, ticket IDs, version numbers). Add a BM25 index over the same documents and fuse results.
from llama_index.core.retrievers import VectorIndexRetriever, BM25Retriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor
# Build BM25 index (in-memory, persists to disk if needed)
bm25_retriever = BM25Retriever.from_defaults(
nodes=nodes,
similarity_top_k=10,
)
vector_retriever = VectorIndexRetriever(
index=index,
similarity_top_k=10,
)
# Simple hybrid: merge and deduplicate by node_id
class HybridRetriever:
def __init__(self, vector_ret, bm25_ret):
self.vector_ret = vector_ret
self.bm25_ret = bm25_ret
def retrieve(self, query_bundle):
vec_nodes = self.vector_ret.retrieve(query_bundle)
bm25_nodes = self.bm25_ret.retrieve(query_bundle)
seen = set()
merged = []
for n in vec_nodes + bm25_nodes:
if n.node_id not in seen:
seen.add(n.node_id)
merged.append(n)
return merged[:10]
hybrid = HybridRetriever(vector_retriever, bm25_retriever)
query_engine = RetrieverQueryEngine.from_args(hybrid, node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.7)])
This gives you the best of both worlds without a managed hybrid-search service.
Step 10: Evaluate retrieval quality
Don’t ship blind. Build a small eval set of 20–30 questions with known answers from your Slack history. Run them through the query engine and measure:
- Hit rate: Does the correct message appear in the top-k?
- Answer correctness: Does the synthesized answer match the ground truth?
- Latency: End-to-end query time.
from llama_index.core.evaluation import RetrieverEvaluator
eval_questions = [
"What was the decision on API versioning?",
"Who approved the Q3 budget?",
"What's the current staging deploy process?",
]
evaluator = RetrieverEvaluator.from_metric_names(
["hit_rate", "mrr"], retriever=hybrid
)
results = await evaluator.aevaluate_dataset(eval_questions)
print(results)
Iterate on chunk size, top-k, and metadata filters until hit rate is acceptable.
Common pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
SlackReader returns empty list |
Bot not added to channel | /invite @llamaindex-ingestion in each target channel |
channels:read missing error |
Scope not granted | Reinstall app after adding scope |
| Duplicate documents on re-run | No incremental state | Implement Step 5 timestamp tracking |
| Query returns generic answers hallucinates | Low similarity cutoff / bad chunks | Raise similarity_cutoff, tune chunk_size |
| Rate limited on large workspaces | Too many channels at once | Batch channels, add backoff (Step 6) |
Scaling considerations
For workspaces with >100 active channels and millions of messages:
- Parallelize channel fetches with a thread pool (respecting rate limits).
- Use a managed vector store (Pinecone, Weaviate, Qdrant Cloud) instead of local Chroma.
- Separate ingestion from query path: Write to a message queue (Kafka, SQS) and have workers upsert to the vector store.
- Partition by time: Create monthly indices; query only recent partitions for “what happened this week” questions.
If you’re running this behind an inference gateway like n4n.ai, you can route the query engine’s LLM calls through a single endpoint while the ingestion pipeline stays unchanged.
Next steps
You now have a working LlamaIndex Slack connector tutorial pipeline: authenticated ingestion, incremental updates, hybrid retrieval, and an evaluation loop. From here, consider:
- Adding a Slack bot that answers questions directly in-channel via the query engine.
- Building a RAG-powered onboarding assistant that cites the actual discussions where decisions were made.
- Extending the same pattern to Notion, GitHub, and Linear for a unified internal knowledge graph.
The connector is a thin wrapper; the real work is shaping the metadata, chunking strategy, and eval loop to match how your team actually searches. Start small, measure, and iterate.