Skip to main content
  1. Blog/

Making vector search pluggable in Akgents with Qdrant

Jettro Coenradie
Author
Jettro Coenradie
Software architect and search enthusiast. I write about AI, search, cloud, and software development.

I wanted to build an Akgents team around a Qdrant-backed knowledge base. One agent would ingest a web page, another would answer questions from the stored knowledge, and both would use the same vector store. The idea was small enough to explain in one sentence. The change underneath it was not.

akgentic-tool already supported an in-memory vector store and Weaviate. Qdrant was not simply another client to initialise. The supported backend names were fixed in the collection configuration, the vector-store actor selected implementations with name-based branches, and even the configuration checks were shaped around Weaviate. Adding Qdrant in those places would make it work, but the next database would require another round of edits to the same central code.

I therefore opened issue #340 and created pull request #341. The pull request adds Qdrant, but its more important contribution is a generic backend layer. A vector-store implementation can now register itself, state what it needs, and satisfy the existing service contract without teaching the actor its name.

The runnable proof is my knowledge-akgents sample. In this post, I first explain the abstraction I wanted, then show how the sample uses it to create a small knowledge team. The pull request is still open while I write this, so the sample uses the branch directly rather than a released version of akgentic-tool.

Watch the silent recording on YouTube

The real goal was not one more backend
#

The original vector-store actor knew about the available implementations. A collection could name inmemory or weaviate, and the actor decided which concrete object to create. It also knew that in-memory data had to be copied into actor state while Weaviate owned its persistence externally. That design works with two backends, but each extra backend increases the number of choices in the actor.

I wanted the actor to know only about behaviour:

  • how to create a backend;
  • whether its data must be persisted in actor state;
  • whether deployment configuration is present;
  • whether it is eligible to become the default backend.

Those decisions belong to the backend registration, not to a growing series of if statements in the actor. The resulting flow looks like this:

flowchart LR
    CONSUMER["PlanningTool or
KnowledgeGraphTool"] CONFIG["CollectionConfig
backend: string"] ACTOR["#VectorStore actor
backend-neutral routing"] REGISTRY["Backend registry
name + factory + capabilities"] MEMORY["In-memory backend"] WEAVIATE["Weaviate backend"] QDRANT["Qdrant backend"] NEXT["Your next backend"] CONSUMER --> CONFIG --> ACTOR --> REGISTRY REGISTRY --> MEMORY REGISTRY --> WEAVIATE REGISTRY --> QDRANT REGISTRY -. register .-> NEXT

The CollectionConfig.backend field is now an open string rather than a closed literal. The registry resolves that string to a BackendSpec. Its factory receives a BackendContext with the vector-store configuration and the owning team ID. The actor caches the resulting backend and delegates collection creation, ingestion, removal, and search through the existing VectorStoreService protocol.

The important part is what disappeared from the actor: it no longer needs a Qdrant branch. The registry entry contains the Qdrant factory and its capability flags. A future pgvector or bespoke backend can take the same route.

The Qdrant registration shows the complete connection between an implementation and the generic layer:

register_backend(
    BackendSpec(
        name="qdrant",
        factory=_make_qdrant_backend,
        persists_in_actor_state=False,
        selectable_as_default=True,
        is_configured=qdrant_is_configured,
        require_configured=require_qdrant_configured,
    ),
    replace=True,
)

Another backend supplies the same small description and its own VectorStoreService implementation. It does not need a change in VectorStoreActor.

Capabilities replace name-based assumptions
#

A registry only moves a problem if the central code still asks questions such as “is this backend Weaviate?” The actor has to reason about capabilities instead.

The clearest example is persistence. The in-memory backend stores its data inside the actor, so a mutation must update the serialisable actor state. Weaviate and Qdrant are external durable stores; they persist their own data. BackendSpec.persists_in_actor_state makes that distinction explicit. The actor asks for the capability and never infers it from a backend name.

The same pattern handles deployment configuration. A backend can provide an is_configured probe for default selection and a require_configured check for a clear startup error. When AKGENTIC_QDRANT_URL is present, Qdrant can be selected without putting a Qdrant setting on every agent’s tool card. When an agent explicitly requests Qdrant but the URL or optional client is missing, team construction fails with instructions instead of silently using an in-memory store.

This matters to me because configuration should fail at the boundary where it is understood. An agent that expects durable shared storage must not appear healthy while writing to a process-local fallback.

Qdrant proves that the extension point works
#

The new QdrantBackend implements the same structural VectorStoreService contract as the other backends. It creates cosine-distance collections, adds vector entries, removes entries by reference ID, and returns the common SearchResult model. The Qdrant client stays an optional dependency, installed through the qdrant extra and imported lazily.

There are a few details behind that apparently simple adapter:

  • Every point carries the owning team_id in its payload. Searches and removals always include that value, so one team cannot read another team’s knowledge from a shared collection.
  • An optional tenant is folded into the same scope.
  • Qdrant point IDs must be integers or UUIDs. The backend derives a stable UUID from the team, tenant, and domain reference ID, which makes re-ingestion stable without letting teams overwrite each other’s points.
  • The shared service contract uses cosine similarity, so the backend creates cosine collections and rejects an existing collection with an incompatible distance metric.

I also added an optional VectorQuery to the generic search contract. It can carry metadata filters, a score threshold, and backend-native parameters. Existing callers can keep using the original three arguments; callers that need more control can refine one search without changing the collection or leaking a Qdrant type through the actor API.

The built-in Qdrant implementation translates exact-match filters and passes native query options to query_points. Protected hooks for filter construction and search arguments provide a smaller subclassing seam when a project needs Qdrant-specific range, geo, or tuning behaviour. Registering that subclass under a new name leaves the actor untouched.

The sample: one knowledge base, two specialists
#

An abstraction earns its place when application code becomes simpler. The sample contains a web Human Proxy and three Akgents agents:

  • @Manager decides which specialist should receive a human request.
  • @Knowledge searches the shared knowledge graph and answers only from stored information.
  • @WebIngest fetches a web page, extracts a compact set of entities and relations, and writes them to that same graph.

The browser communicates with a small FastAPI backend over a WebSocket. The backend boots the actor system, registers the agent profiles, creates the Human Proxy, and streams messages and tool calls back to the page. This keeps the user interface deliberately small; the interesting part remains the team and its shared knowledge.

flowchart TB
    BROWSER["Browser
Human Proxy UI"] API["FastAPI + WebSocket"] HUMAN["@Human"] MANAGER["@Manager
route the request"] KNOWLEDGE["@Knowledge
query only"] INGEST["@WebIngest
fetch + extract + update"] SEARCH["SearchTool
Tavily fetch/crawl"] KGREAD["KnowledgeGraphTool
search enabled"] KGWRITE["KnowledgeGraphTool
search + update enabled"] VECTOR["#VectorStore
shared actor"] QDRANT["Qdrant
shared durable store"] BROWSER <--> API <--> HUMAN --> MANAGER MANAGER --> KNOWLEDGE MANAGER --> INGEST KNOWLEDGE --> KGREAD --> VECTOR INGEST --> SEARCH INGEST --> KGWRITE --> VECTOR VECTOR --> QDRANT

The manager has no tools. Its prompt tells it to route ingestion requests to @WebIngest and questions about stored knowledge to @Knowledge. Keeping it thin makes the responsibility visible: it coordinates specialists instead of becoming another all-purpose agent.

The knowledge agent gets two cards:

def knowledge_card() -> AgentCard:
    return AgentCard(
        agent_class="akgentic.agent.BaseAgent",
        description="Answers questions from the shared knowledge base.",
        skills=["knowledge", "retrieval", "question-answering"],
        config=AgentConfig(
            name="@Knowledge",
            role="Knowledge",
            prompt=PromptTemplate(template=KNOWLEDGE_PROMPT),
            model_cfg=_model(),
            tools=[vector_store_card(), knowledge_query_card()],
        ),
    )

VectorStoreTool is a configuration card. It does not expose an LLM tool call; it guarantees that the shared #VectorStore actor exists. KnowledgeGraphTool(search=True, update_graph=False) is the consumer. It gives the agent the read side of the knowledge graph while keeping writes out of its toolset.

The web-ingest agent uses the same vector-store card, adds SearchTool for Tavily web search, fetching, and crawling, and enables the graph’s write operation:

def webingest_card() -> AgentCard:
    return AgentCard(
        agent_class="akgentic.agent.BaseAgent",
        description="Fetches web pages and stores extracted knowledge in the shared base.",
        skills=["web", "extraction", "ingestion"],
        config=AgentConfig(
            name="@WebIngest",
            role="WebIngest",
            prompt=PromptTemplate(template=WEBINGEST_PROMPT),
            model_cfg=_model(),
            tools=[vector_store_card(), web_card(), knowledge_ingest_card()],
        ),
    )

Both KnowledgeGraphTool cards use the same defaults, including the knowledge_graph collection, and both resolve the same #VectorStore singleton. That is the bridge between the two specialists: what @WebIngest writes is available to @Knowledge without either agent knowing how Qdrant is called.

Configuration selects the infrastructure
#

The sample currently depends on the local pull-request checkout because Qdrant support is not part of a released akgentic-tool version yet. The relevant dependency configuration is:

dependencies = [
    "akgentic-core",
    "akgentic-llm",
    "akgentic-agent",
    "akgentic-team",
    "akgentic-tool[qdrant,vector_search,docs]",
    # FastAPI, uvicorn, websockets, and settings dependencies omitted here
]

[tool.uv.sources]
akgentic-tool = { path = "../akgentic-tool", editable = true }
akgentic-llm = { path = "../akgentic-llm", editable = true }

The repositories therefore need to be siblings. This checkout layout matches those source paths:

akgents/
├── knowledge-akgents/
├── akgentic-tool/       # PR branch
└── akgentic-llm/

For the application itself, create .env from the supplied example and provide these values:

OPENAI_API_KEY=
OPENAI_BASE_URL=
TAVILY_API_KEY=
AKGENTIC_QDRANT_URL=http://localhost:6333
# AKGENTIC_QDRANT_API_KEY=

OPENAI_BASE_URL is optional when you use the standard OpenAI endpoint. The sample’s chat model is configurable through LLM_MODEL and defaults to gpt-5.6-luna; its embedding model is text-embedding-3-small. TAVILY_API_KEY enables the web-ingest agent to fetch and crawl pages. The Qdrant URL is the switch that makes collections use the registered Qdrant backend. If it is absent and no other external backend is configured, the default remains in-memory.

Docker Compose makes the infrastructure choice even clearer:

services:
  qdrant:
    image: qdrant/qdrant:latest
    volumes:
      - qdrant_data:/qdrant/storage

  backend:
    environment:
      AKGENTIC_QDRANT_URL: http://qdrant:6333
    depends_on:
      - qdrant

The tool cards do not change between local in-memory use and the containerised Qdrant deployment. Deployment configuration selects the registered backend; the knowledge agents keep talking to the same vector-store contract.

Run the sample
#

Until the pull request is merged and released, clone the sample and the required source checkouts as siblings. The Qdrant implementation lives on my pull-request branch:

mkdir knowledge-akgents-demo
cd knowledge-akgents-demo

git clone https://github.com/jettro/knowledge-akgents.git
git clone --branch issue-340-refactor-vector-tool-for-multiple-backends \
  https://github.com/jettro/akgentic-tool.git
git clone https://github.com/b12consulting/akgentic-llm.git

cd knowledge-akgents
cp .env.example .env

After adding the API keys to .env, the shortest route is the complete Docker stack:

make up
# open http://localhost:8080
make logs

Use the Ingest URL field to send a page directly to @WebIngest. After it reports the entities and relations it stored, ask a question in the regular chat field. The message goes to @Manager, which delegates it to @Knowledge. You can also address @Knowledge or @WebIngest explicitly in the message.

When you are finished:

make down

For local development, install the dependencies, then start Qdrant, the backend, and the frontend in separate terminals. Start Qdrant in the first one:

docker run --rm --name knowledge-qdrant -p 6333:6333 qdrant/qdrant

Prepare and run the backend in the second:

make sync
make run

Run the frontend in the third:

make web
# open http://localhost:8080/?backend=localhost:8000

What I learned from adding Qdrant
#

The first visible result is a Qdrant-backed knowledge team. The more reusable result is the boundary behind it.

VectorStoreService describes what the actor needs. BackendSpec describes how an implementation is created and what operational capabilities it has. CollectionConfig chooses a registered name, and VectorQuery carries optional per-search refinement without changing the common result. Qdrant then becomes one implementation of that design rather than a special path through the actor.

That separation also improves the sample. The agents express their responsibilities through tool cards: one can search, one can ingest, and both share a vector store. Qdrant appears in the optional dependency and deployment environment, where infrastructure belongs. If another vector database is a better fit later, the agent team should not need to be redesigned around it.

The open pull request is the next test of the design. Review may still change details, but the direction is the part I wanted to establish: adding a vector database should mean implementing and registering a backend, not editing the actor that every backend shares.

Related