Agentic RAG with CrewAI

Time: 30 minLevel: BeginnerStack: Python, Qdrant, CrewAI, FastEmbed, Claude

Build a small system that answers questions about meeting notes. Qdrant is the vector search engine that stores each meeting with its transcript and metadata. FastEmbed turns text into vectors on your machine, for both the stored meetings and the questions. CrewAI runs two agents on Claude: a researcher that searches Qdrant and quotes what it finds, and a writer that turns the findings into a short answer with citations.

The whole tutorial is one script and six sample meetings. You run it twice: once to load the meetings, once to ask a question. Nothing needs to be cloned.

By the end, you can ask what the marketing team decided about the Q4 campaign and how long those meetings ran. The agents find the right meetings, quote who said what, work out the average, and tell you what the notes do not cover.

Prerequisites

You need Python 3.12 and:

  • uv to create the environment and install the packages.
  • A Qdrant Cloud cluster with its HTTPS endpoint and an API key, or Qdrant running locally with docker run -p 6333:6333 qdrant/qdrant.
  • An Anthropic API key with credit.

How the Flow Works

Ingestion runs once. The script embeds each meeting with FastEmbed and stores the vector together with the meeting’s topic, date, duration, attendees, and transcript as the point’s payload.

Answering runs for each question. CrewAI gives the question to the researcher agent, whose only tool is a semantic search over the collection. The tool embeds the query with the same FastEmbed model, calls Qdrant’s Query API, and returns the closest meetings as text. The researcher writes a list of evidence. The writer agent receives that list and the question, and is instructed to answer from those findings. The writer has no retrieval tool of its own.

Step 1: Install the Dependencies

Create a local environment with uv:

uv venv --python 3.12
source .venv/bin/activate

On Windows PowerShell, activate the environment with .venv\Scripts\Activate.ps1.

Install the three packages this tutorial uses, with the versions it was tested against:

uv pip install "crewai[anthropic]==1.15.28" "qdrant-client[fastembed]==1.20.0" "python-dotenv==1.2.4"

The anthropic extra gives CrewAI its native Claude provider. The fastembed extra lets the Qdrant client embed text locally. CrewAI has a large dependency tree, so allow a few minutes for installation.

Step 2: Configure the Connections

Create a .env file beside your script:

ANTHROPIC_API_KEY=your-anthropic-api-key
QDRANT_URL=https://your-cluster.cloud.qdrant.io:6333
QDRANT_API_KEY=your-qdrant-api-key

For a local Qdrant, set QDRANT_URL=http://localhost:6333 and leave QDRANT_API_KEY out.

Create meeting_crew.py and start with the imports and three constants:

import argparse
import os
import uuid
from typing import Any

from crewai import LLM, Agent, Crew, Process, Task
from crewai.tools import BaseTool
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from qdrant_client import QdrantClient, models

load_dotenv()

COLLECTION = "meeting-notes"
EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5"
CLAUDE_MODEL = "anthropic/claude-opus-5-5"

EMBEDDING_MODEL determines the collection’s vector size and supplies the model for both ingestion and search. This keeps stored vectors and query vectors compatible. CLAUDE_MODEL uses CrewAI’s provider prefix to select Claude Opus 5.5.

Step 3: Add the Sample Meetings

Six short meetings are enough to see the agents work. Each one has a topic, a date, a duration in minutes, attendees, and a transcript. Three are marketing meetings, which is what the sample question is about.

MEETINGS: list[dict[str, Any]] = [
    {
        "topic": "Marketing Strategy Review",
        "date": "2026-09-02",
        "duration_minutes": 45,
        "attendees": ["Emily", "Michael", "Sarah"],
        "transcript": (
            "Emily: The Q4 campaign budget request is 120k. Michael: Approve 100k now "
            "and hold 20k until the October numbers are in. Sarah: Agreed. We focus "
            "the first wave on the developer newsletter and two conference sponsorships. "
            "Emily: Decision recorded: 100k approved, developer newsletter and two "
            "conferences for wave one, review on 15 October."
        ),
    },
    {
        "topic": "Marketing Weekly Sync",
        "date": "2026-09-09",
        "duration_minutes": 30,
        "attendees": ["Emily", "Sarah"],
        "transcript": (
            "Sarah: Newsletter open rate went from 31 to 38 percent after the redesign. "
            "Emily: Good. The conference shortlist is down to Berlin and Lisbon. "
            "Sarah: Lisbon has the better developer audience. Emily: Let's propose "
            "Lisbon at the strategy review. Social video is paused until we hire."
        ),
    },
    {
        "topic": "Product Roadmap Planning",
        "date": "2026-09-04",
        "duration_minutes": 60,
        "attendees": ["Michael", "Priya", "Tom"],
        "transcript": (
            "Priya: The hybrid search feature ships in October. Tom: We need the "
            "filtering docs updated before launch. Michael: Marketing wants a launch "
            "post for the same week. Priya: Fine, but no date promises in public "
            "before QA signs off."
        ),
    },
    {
        "topic": "Engineering Standup",
        "date": "2026-09-05",
        "duration_minutes": 15,
        "attendees": ["Priya", "Tom", "Lena"],
        "transcript": (
            "Tom: Finished the payload index migration. Lena: Reviewing the ingestion "
            "retry logic today. Priya: Blocker is the staging cluster being out of disk."
        ),
    },
    {
        "topic": "Marketing and Sales Alignment",
        "date": "2026-09-16",
        "duration_minutes": 50,
        "attendees": ["Emily", "Sarah", "Daniel"],
        "transcript": (
            "Daniel: Sales needs leads tagged by use case, not just by source. "
            "Emily: We can add a use-case field to the newsletter signup. Sarah: The "
            "Q4 campaign landing pages will carry the same field. Daniel: Then sales "
            "can route retrieval and recommendation leads to different reps."
        ),
    },
    {
        "topic": "Customer Support Retrospective",
        "date": "2026-09-11",
        "duration_minutes": 40,
        "attendees": ["Lena", "Daniel", "Noor"],
        "transcript": (
            "Noor: Ticket volume dropped 20 percent after the filtering docs rewrite. "
            "Lena: Most remaining tickets are about collection sizing. Daniel: Let's "
            "ask marketing for a sizing guide in the next newsletter."
        ),
    },
]

To use your own notes later, replace this list with whatever loads them. Keep the same keys, since the search tool and the citations read them from the payload.

Step 4: Create the Collection

The collection’s vector size has to match the embedding model. Rather than hard-coding 384, ask the client for the model’s size:

def get_client() -> QdrantClient:
    return QdrantClient(
        url=os.environ["QDRANT_URL"],
        api_key=os.environ.get("QDRANT_API_KEY"),
    )

def create_collection(client: QdrantClient) -> None:
    if client.collection_exists(COLLECTION):
        return
    client.create_collection(
        collection_name=COLLECTION,
        vectors_config=models.VectorParams(
            size=client.get_embedding_size(EMBEDDING_MODEL),
            distance=models.Distance.COSINE,
        ),
    )

Cosine distance suits this embedding model. The function reuses an existing collection; use a new COLLECTION name if you change the embedding model. Ingestion uses stable point IDs, so rerunning it with the same sample data updates the same six points.

Step 5: Ingest the Meetings

Each meeting becomes one point. The text that gets embedded is the topic, the date, and the transcript. The full meeting stays in the payload so the search tool can return dates and durations without a second lookup.

def ingest(client: QdrantClient) -> None:
    points = []
    for meeting in MEETINGS:
        text = f"{meeting['topic']} ({meeting['date']}): {meeting['transcript']}"
        points.append(
            models.PointStruct(
                id=str(uuid.uuid5(uuid.NAMESPACE_URL, meeting["topic"] + meeting["date"])),
                vector=models.Document(text=text, model=EMBEDDING_MODEL),
                payload=meeting,
            )
        )
    client.upsert(collection_name=COLLECTION, points=points)

Passing models.Document as the vector tells the client to run FastEmbed locally and send the resulting vector to Qdrant. The first run downloads and caches the model. The point IDs are derived from topic and date, so rerunning ingestion updates the same points instead of adding duplicates.

Step 6: Retrieve With the Query API

Search embeds the question with the same models.Document call and uses query_points, the Query API. The function returns plain dictionaries so the tool and the question trace can print them.

def search_meetings(client: QdrantClient, query: str, limit: int = 3) -> list[dict[str, Any]]:
    response = client.query_points(
        collection_name=COLLECTION,
        query=models.Document(text=query, model=EMBEDDING_MODEL),
        limit=limit,
        with_payload=True,
    )
    results = []
    for point in response.points:
        payload = point.payload or {}
        results.append(
            {
                "topic": payload["topic"],
                "date": payload["date"],
                "duration_minutes": payload["duration_minutes"],
                "score": round(point.score, 3),
                "transcript": payload["transcript"],
            }
        )
    return results

For the sample question, the three closest meetings and their cosine scores are:

0.739  Marketing Strategy Review (2026-09-02, 45 min)
0.632  Marketing Weekly Sync (2026-09-09, 30 min)
0.613  Marketing and Sales Alignment (2026-09-16, 50 min)

All three marketing meetings come back ahead of the product, engineering, and support meetings, with the one that records the Q4 decision on top.

Step 7: Wrap Retrieval as a CrewAI Tool

CrewAI agents call tools that subclass BaseTool. The input schema tells the model what argument to pass. The tool formats each result as one line with the topic, date, duration, score, and transcript, because the researcher agent reads this text and quotes from it.

class SearchInput(BaseModel):
    query: str = Field(description="What to look for in the meeting notes")

class SearchMeetingsTool(BaseTool):
    name: str = "search_meetings"
    description: str = (
        "Semantic search over meeting transcripts. Returns the closest meetings "
        "with topic, date, duration in minutes, similarity score and transcript."
    )
    args_schema: type[BaseModel] = SearchInput

    def _run(self, query: str) -> str:
        results = search_meetings(get_client(), query)
        lines = []
        for r in results:
            lines.append(
                f"- {r['topic']} on {r['date']}, {r['duration_minutes']} min, "
                f"score {r['score']}: {r['transcript']}"
            )
        return "\n".join(lines) or "No matching meetings."

Step 8: Define the Agents and Tasks

Two agents, two tasks, run in order. The researcher has the search tool and is told to quote evidence and to say when the transcripts do not answer something. The writer has no tools and receives the researcher’s output as context. These instructions encourage grounded answers, but they do not enforce factual correctness. Check the answer’s citations and calculations against the retrieved notes.

def build_crew(question: str) -> Crew:
    llm = LLM(model=CLAUDE_MODEL, max_tokens=4096)

    researcher = Agent(
        role="Meeting Researcher",
        goal="Find every meeting relevant to the question and report only what the transcripts say",
        backstory="You search the meeting archive and quote evidence. You never guess.",
        tools=[SearchMeetingsTool()],
        llm=llm,
        verbose=True,
    )
    writer = Agent(
        role="Answer Writer",
        goal="Turn the researcher's findings into a short, grounded answer",
        backstory="You write for a busy manager. Every claim cites a meeting and date.",
        llm=llm,
        verbose=True,
    )

    research = Task(
        description=(
            f"Question: {question}\n"
            "Use the search_meetings tool, at least once, with a query that matches the question. "
            "List each relevant meeting with its date, duration and the exact lines that answer the question. "
            "If the transcripts do not answer part of the question, say so."
        ),
        expected_output="A bullet list of relevant meetings with dates, durations and quoted evidence.",
        agent=researcher,
    )
    answer = Task(
        description=(
            f"Question: {question}\n"
            "Write the answer in at most five sentences using only the researcher's findings. "
            "Cite the meeting topic and date for each fact. If a number is asked for, show the calculation."
        ),
        expected_output="A short grounded answer with citations.",
        agent=writer,
        context=[research],
    )
    return Crew(agents=[researcher, writer], tasks=[research, answer], process=Process.sequential)

Both agents share one LLM object. Process.sequential runs the tasks in list order, and context=[research] is what hands the researcher’s findings to the writer.

Step 9: Run One Question

The entry point takes two flags. --ingest creates the collection and loads the meetings. --ask prints what retrieval returns for the question, runs the crew, and prints the answer.

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--ingest", action="store_true", help="Create the collection and load the sample meetings")
    parser.add_argument("--ask", help="Question to answer from the meeting notes")
    args = parser.parse_args()

    client = get_client()
    if args.ingest:
        create_collection(client)
        ingest(client)
        print(f"Loaded {client.count(COLLECTION).count} meetings into '{COLLECTION}'")
    if args.ask:
        print("Retrieved for the question:")
        for r in search_meetings(client, args.ask):
            print(f"  {r['score']}  {r['topic']} ({r['date']}, {r['duration_minutes']} min)")
        result = build_crew(args.ask).kickoff()
        print("\nAnswer:\n" + str(result))

if __name__ == "__main__":
    main()

Load the meetings:

python meeting_crew.py --ingest
Loaded 6 meetings into 'meeting-notes'

Ask the question:

python meeting_crew.py --ask "What did the marketing meetings decide about the Q4 campaign, and how long did they run on average?"

The script prints the retrieval result first, then the crew log, then the answer. The output and trace record a run from 22 October 2026 with the pinned versions. Agent-chosen queries and answer wording can vary between runs. Trimmed to the first and last part:

Retrieved for the question:
  0.739  Marketing Strategy Review (2026-09-02, 45 min)
  0.632  Marketing Weekly Sync (2026-09-09, 30 min)
  0.613  Marketing and Sales Alignment (2026-09-16, 50 min)

...

Answer:
Only the Marketing Strategy Review (2026-09-02) formally decided anything about the Q4 campaign: it approved 100k of the 120k requested, with Michael proposing to hold the remaining 20k until the October numbers are in, and it focused wave one on the developer newsletter and two conference sponsorships, with a review on 15 October. The Marketing and Sales Alignment meeting (2026-09-16) also agreed, though not as a formal decision, that the Q4 campaign landing pages will carry a use-case field so sales can route leads to different reps. The Marketing Weekly Sync (2026-09-09) never mentions the Q4 campaign and only planned to propose Lisbon at the strategy review, so neither conference is confirmed. The three marketing meetings averaged (45 + 30 + 50) / 3 = 41.7 minutes (Strategy Review 2026-09-02, Weekly Sync 2026-09-09, Sales Alignment 2026-09-16), or (45 + 50) / 2 = 47.5 minutes counting only the two that name the Q4 campaign. The transcripts do not show the outcome of the 15 October review, whether the 20k was released, or any meetings after 2026-09-16.

Trace the Question Through the System

With verbose=True on both agents, CrewAI prints each step. Here is what moved between the parts for the sample question, trimmed to the lines that matter.

1. The researcher turns the question into a search. It called search_meetings twice, with these queries:

marketing meeting Q4 campaign decision
Q4 campaign budget review October marketing

2. Qdrant returns the three closest meetings. This is the exact text the tool handed back for the first query. The second query returned the same three meetings with slightly different scores, so this is the only meeting data either agent ever sees:

- Marketing Strategy Review on 2026-09-02, 45 min, score 0.811: Emily: The Q4 campaign budget request is 120k. Michael: Approve 100k now and hold 20k until the October numbers are in. Sarah: Agreed. We focus the first wave on the developer newsletter and two conference sponsorships. Emily: Decision recorded: 100k approved, developer newsletter and two conferences for wave one, review on 15 October.
- Marketing Weekly Sync on 2026-09-09, 30 min, score 0.694: Sarah: Newsletter open rate went from 31 to 38 percent after the redesign. Emily: Good. The conference shortlist is down to Berlin and Lisbon. Sarah: Lisbon has the better developer audience. Emily: Let's propose Lisbon at the strategy review. Social video is paused until we hire.
- Marketing and Sales Alignment on 2026-09-16, 50 min, score 0.694: Daniel: Sales needs leads tagged by use case, not just by source. Emily: We can add a use-case field to the newsletter signup. Sarah: The Q4 campaign landing pages will carry the same field. Daniel: Then sales can route retrieval and recommendation leads to different reps.

3. The researcher writes its findings. This is the full output of the research task, which becomes the writer’s context:

I found three marketing meetings. Only one recorded a formal decision on the Q4 campaign. The three meetings ran 41.7 minutes on average.

**Relevant meetings**

- **Marketing Strategy Review, 2026-09-02, 45 min.** This is the only meeting that formally decided anything about the Q4 campaign.
  - "Emily: The Q4 campaign budget request is 120k."
  - "Michael: Approve 100k now and hold 20k until the October numbers are in."
  - "Sarah: Agreed. We focus the first wave on the developer newsletter and two conference sponsorships."
  - "Emily: Decision recorded: 100k approved, developer newsletter and two conferences for wave one, review on 15 October."

- **Marketing and Sales Alignment, 2026-09-16, 50 min.** This meeting set a detail of the Q4 campaign pages, though nobody called it a formal decision.
  - "Daniel: Sales needs leads tagged by use case, not just by source."
  - "Emily: We can add a use-case field to the newsletter signup."
  - "Sarah: The Q4 campaign landing pages will carry the same field."
  - "Daniel: Then sales can route retrieval and recommendation leads to different reps."

- **Marketing Weekly Sync, 2026-09-09, 30 min.** This meeting never says "Q4 campaign." It's only loosely related because it discusses conferences and the newsletter, the two channels chosen for wave one. Even so, the transcript doesn't link these lines to the campaign, and it only plans to propose Lisbon rather than deciding on it.
  - "Sarah: Newsletter open rate went from 31 to 38 percent after the redesign."
  - "Emily: Good. The conference shortlist is down to Berlin and Lisbon."
  - "Sarah: Lisbon has the better developer audience."
  - "Emily: Let's propose Lisbon at the strategy review. Social video is paused until we hire."

**What was decided:** The team approved 100k of the 120k requested and held back 20k until the October numbers come in. Wave one will focus on the developer newsletter and two conference sponsorships, with a review on 15 October. The campaign landing pages will also include a use-case field so sales can route leads.

**Average length:**
- All three marketing meetings: (45 + 30 + 50) / 3 = **41.7 minutes**.
- Only the two that mention the Q4 campaign by name: (45 + 50) / 2 = **47.5 minutes**.

**Not in the transcripts:**
- What happened at the 15 October review.
- Whether the held-back 20k was released.
- Which two conferences were finally chosen. Lisbon was only planned as a proposal.
- Any meetings after 2026-09-16.

4. The writer receives the question and the researcher’s findings as task context. Its output is the answer printed at the end of Step 9. Compare the two: every meeting, date, quote, and number in the answer appears in the researcher’s findings, and the last sentence carries over what the researcher marked as not in the transcripts.

The useful boundary here is the handoff between retrieval and writing: you can inspect the researcher’s evidence before checking the final answer. Separating the roles does not prevent either agent from inventing a claim. In this run, the cited meetings and durations match the retrieved payloads, and the two averages can be checked directly.

All three marketing meetings in this sample were retrieved. In a larger archive, the three closest matches are not necessarily all relevant meetings, so their average describes only that retrieved set. For an archive-wide statistic, define the matching set with payload filters, retrieve every matching point with paginated scroll, and calculate the result in code.

Troubleshooting

SymptomWhat to check
Vector dimension error on upsert or queryUse a new COLLECTION name for the new embedding model and run --ingest, or restore the model used to create the existing collection.
KeyError: 'QDRANT_URL'The .env file is not beside the script or the variable name differs.
credit balance is too low or authentication_error from AnthropicThe key has no credit or is wrong. Check the Anthropic console.
The researcher answers without calling the toolCheck the verbose log for a search_meetings call and keep the task’s instruction to use it. Treat an answer without retrieved evidence as unverified; the prompt alone does not guarantee tool use.
Answers mention meetings that are not in the sampleCompare the tool output, research findings, and final answer to locate the unsupported claim. Verify each citation against the returned notes. Reducing the retrieval limit does not prevent hallucinations and may discard relevant evidence.

Adapt the Flow

To use your own transcripts, set COLLECTION to a new name, replace MEETINGS with your loader, keep the same payload keys, and run --ingest. Upsert updates points with matching IDs; it does not remove the sample meetings when you replace the list.

Add a payload filter when a question has an explicit constraint, such as a named attendee. For date ranges, store RFC 3339 timestamps and use a datetime range filter. Semantic similarity alone does not enforce those constraints. Choose any additional tool or agent around a specific missing capability: fetching a known meeting by ID needs a retrieval tool, while a separate review task may justify another agent.

Was this page useful?

Thank you for your feedback! 🙏

We are sorry to hear that. 😔 You can edit this page on GitHub, or create a GitHub issue.