# Spike 8 Implementation Plan — Vector Backend Abstraction + Qdrant + Hybrid Search

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Introduce a VectorBackend protocol, refactor ChromaDB behind it, add Qdrant as a second backend with native tag filtering and hybrid search (BM25 + vector fusion), and provide migration tooling.

**Architecture:** New `backend.py` defines protocols. `backends/chromadb.py` and `backends/qdrant.py` are implementations. `MemoryStore` becomes a thin business-logic layer. `StorePool` handles backend instantiation from config. TDD throughout.

**Tech Stack:** Python 3.12, ChromaDB, Qdrant (local binary), qdrant-client, pytest

---

### Task 1: Create VectorBackend and Embedder protocols

**Files:**
- New: `src/annal/backend.py`

**Step 1: Write the protocols**

Create `src/annal/backend.py` with:

```python
"""Backend protocols for Annal vector storage."""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Protocol


@dataclass
class VectorResult:
    """A single result from a vector backend operation."""
    id: str
    text: str
    metadata: dict = field(default_factory=dict)
    distance: float | None = None


class VectorBackend(Protocol):
    """Low-level vector storage operations."""

    def insert(self, id: str, text: str, embedding: list[float], metadata: dict) -> None: ...
    def update(self, id: str, text: str | None, embedding: list[float] | None, metadata: dict | None) -> None: ...
    def delete(self, ids: list[str]) -> None: ...
    def query(self, embedding: list[float], limit: int, where: dict | None = None) -> list[VectorResult]: ...
    def get(self, ids: list[str]) -> list[VectorResult]: ...
    def scan(self, offset: int, limit: int, where: dict | None = None) -> tuple[list[VectorResult], int]: ...
    def count(self, where: dict | None = None) -> int: ...


class Embedder(Protocol):
    """Text to vector embedding."""

    @property
    def dimension(self) -> int: ...
    def embed(self, text: str) -> list[float]: ...
    def embed_batch(self, texts: list[str]) -> list[list[float]]: ...
```

**Step 2: Create OnnxEmbedder**

Add to the same file:

```python
import numpy as np
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2


class OnnxEmbedder:
    """Default embedder using the ONNX MiniLM-L6-V2 model."""

    def __init__(self) -> None:
        self._fn = ONNXMiniLM_L6_V2()
        # Probe dimension by embedding a test string
        self._dimension = len(self._fn(["test"])[0])

    @property
    def dimension(self) -> int:
        return self._dimension

    def embed(self, text: str) -> list[float]:
        return self._fn([text])[0]

    def embed_batch(self, texts: list[str]) -> list[list[float]]:
        return self._fn(texts)
```

**Step 3: Commit**

```bash
git add src/annal/backend.py
git commit -m "feat: add VectorBackend, Embedder protocols and OnnxEmbedder"
```

---

### Task 2: Extract ChromaBackend from MemoryStore

**Files:**
- New: `src/annal/backends/__init__.py`
- New: `src/annal/backends/chromadb.py`
- New: `tests/test_backend_chromadb.py`

**Step 1: Write tests for ChromaBackend**

Create `tests/test_backend_chromadb.py`. Test each protocol method: `insert`, `query`, `get`, `scan`, `count`, `update`, `delete`. Use a tmp data dir fixture. These are low-level tests — insert a document with a real embedding from `OnnxEmbedder`, then query/scan/get it back.

```python
import pytest
from annal.backend import OnnxEmbedder, VectorResult
from annal.backends.chromadb import ChromaBackend


@pytest.fixture
def embedder():
    return OnnxEmbedder()


@pytest.fixture
def backend(tmp_path, embedder):
    return ChromaBackend(path=str(tmp_path / "chroma"), collection_name="test", dimension=embedder.dimension)


def test_insert_and_query(backend, embedder):
    emb = embedder.embed("authentication decision about JWT")
    backend.insert("m1", "authentication decision about JWT", emb, {"tags": ["auth"], "chunk_type": "agent-memory"})
    results = backend.query(emb, limit=5)
    assert len(results) == 1
    assert results[0].id == "m1"
    assert results[0].text == "authentication decision about JWT"


def test_insert_and_get(backend, embedder):
    emb = embedder.embed("some memory")
    backend.insert("m1", "some memory", emb, {"tags": ["test"]})
    results = backend.get(["m1"])
    assert len(results) == 1
    assert results[0].id == "m1"


def test_scan(backend, embedder):
    for i in range(5):
        emb = embedder.embed(f"memory {i}")
        backend.insert(f"m{i}", f"memory {i}", emb, {"tags": ["test"], "chunk_type": "agent-memory"})
    results, total = backend.scan(offset=0, limit=3)
    assert len(results) == 3
    assert total == 5


def test_scan_with_where(backend, embedder):
    backend.insert("m1", "agent mem", embedder.embed("agent mem"), {"tags": ["a"], "chunk_type": "agent-memory"})
    backend.insert("m2", "file chunk", embedder.embed("file chunk"), {"tags": ["b"], "chunk_type": "file-indexed"})
    results, total = backend.scan(offset=0, limit=10, where={"chunk_type": "agent-memory"})
    assert total == 1
    assert results[0].id == "m1"


def test_count(backend, embedder):
    assert backend.count() == 0
    backend.insert("m1", "test", embedder.embed("test"), {"tags": []})
    assert backend.count() == 1


def test_count_with_where(backend, embedder):
    backend.insert("m1", "a", embedder.embed("a"), {"tags": [], "chunk_type": "agent-memory"})
    backend.insert("m2", "b", embedder.embed("b"), {"tags": [], "chunk_type": "file-indexed"})
    assert backend.count(where={"chunk_type": "agent-memory"}) == 1


def test_delete(backend, embedder):
    backend.insert("m1", "test", embedder.embed("test"), {"tags": []})
    backend.delete(["m1"])
    assert backend.count() == 0


def test_update(backend, embedder):
    emb = embedder.embed("original")
    backend.insert("m1", "original", emb, {"tags": ["old"]})
    new_emb = embedder.embed("updated")
    backend.update("m1", text="updated", embedding=new_emb, metadata={"tags": ["new"]})
    results = backend.get(["m1"])
    assert results[0].text == "updated"
    assert results[0].metadata["tags"] == ["new"]


def test_query_with_tag_filter(backend, embedder):
    backend.insert("m1", "auth stuff", embedder.embed("auth stuff"), {"tags": ["auth", "decision"], "chunk_type": "agent-memory"})
    backend.insert("m2", "frontend stuff", embedder.embed("frontend stuff"), {"tags": ["frontend"], "chunk_type": "agent-memory"})
    results = backend.query(embedder.embed("stuff"), limit=10, where={"tags": {"$contains_any": ["auth"]}})
    assert len(results) == 1
    assert results[0].id == "m1"
```

**Step 2: Run tests — they should fail (ChromaBackend doesn't exist yet)**

```bash
pytest tests/test_backend_chromadb.py -v
```

**Step 3: Implement ChromaBackend**

Create `src/annal/backends/__init__.py` (empty) and `src/annal/backends/chromadb.py`.

The ChromaBackend wraps the existing ChromaDB PersistentClient logic extracted from `MemoryStore`. Key details:

- Tags in metadata: stored as JSON strings (ChromaDB limitation), deserialized in `_to_result()`
- `$contains_any`: handled post-query (over-fetch 3x, filter in Python) — same as current behaviour
- `$prefix`: handled post-query on the `source` field
- `$gt`/`$lt`: handled post-query on string comparison
- Equality filters (`chunk_type`): passed directly to ChromaDB's `where` clause
- Embeddings passed directly via `embeddings=` parameter (no longer using ChromaDB's internal embedding function)

```python
"""ChromaDB vector backend for Annal."""

from __future__ import annotations

import json

import chromadb

from annal.backend import VectorResult


class ChromaBackend:
    def __init__(self, path: str, collection_name: str, dimension: int) -> None:
        self._client = chromadb.PersistentClient(path=path)
        self._collection = self._client.get_or_create_collection(
            name=collection_name,
            metadata={"hnsw:space": "cosine"},
        )
    # ... implement all 7 protocol methods
```

For each method, extract the relevant logic from the current `MemoryStore` implementation. The `_serialize_meta` / `_deserialize_meta` helpers handle the tags JSON roundtrip.

**Step 4: Run tests**

```bash
pytest tests/test_backend_chromadb.py -v
```

Expected: all PASS.

**Step 5: Commit**

```bash
git add src/annal/backends/ tests/test_backend_chromadb.py
git commit -m "feat: ChromaBackend implementing VectorBackend protocol"
```

---

### Task 3: Refactor MemoryStore to use backend + embedder

**Files:**
- Modify: `src/annal/store.py`
- Modify: `src/annal/pool.py`
- Modify: `src/annal/config.py`

This is the critical refactor. `MemoryStore.__init__` changes from `(data_dir, project)` to `(backend, embedder)`. All internal ChromaDB calls go through `self._backend`. All embedding calls go through `self._embedder`.

**Step 1: Refactor MemoryStore**

Change constructor:

```python
class MemoryStore:
    def __init__(self, backend: VectorBackend, embedder: Embedder) -> None:
        self._backend = backend
        self._embedder = embedder
        self._tag_cache: dict[str, np.ndarray] | None = None
```

Refactor each method to delegate to the backend. For example, `store()`:

```python
    def store(self, content, tags, source="", chunk_type="agent-memory", file_mtime=None):
        mem_id = str(uuid.uuid4())
        embedding = self._embedder.embed(content)
        metadata = {
            "tags": tags,
            "source": source,
            "chunk_type": chunk_type,
            "created_at": datetime.now(timezone.utc).isoformat(),
        }
        if file_mtime is not None:
            metadata["file_mtime"] = file_mtime
        self._backend.insert(mem_id, content, embedding, metadata)
        self._invalidate_tag_cache()
        return mem_id
```

`search()` uses `self._embedder.embed(query)` then `self._backend.query()`. `browse()` uses `self._backend.scan()`. Etc.

The fuzzy tag expansion stays in MemoryStore — `_expand_tags` uses `self._embedder.embed_batch()` instead of `self._embed_fn()`.

Key: `_iter_metadata()`, `list_topics()`, `get_all_file_mtimes()`, `delete_by_source()` all now use `self._backend.scan()` to iterate data.

**Step 2: Update StorePool**

Change `get_store()` to create a backend and embedder:

```python
    def get_store(self, project: str) -> MemoryStore:
        need_save = False
        with self._lock:
            if project not in self._stores:
                backend = self._create_backend(project)
                embedder = self._get_embedder()
                self._stores[project] = MemoryStore(backend, embedder)
                # ... auto-register logic
            store = self._stores[project]
        # ...
        return store
```

Add `_create_backend()` and `_get_embedder()` methods. For now, `_create_backend` always creates a `ChromaBackend`. The backend selection by config comes in Task 5.

The embedder is shared across all stores (it's stateless) — cache it as `self._embedder`.

**Step 3: Run ALL existing tests**

```bash
pytest -v
```

Expected: ALL existing tests pass. This is a pure refactor — no behaviour change.

**Step 4: Commit**

```bash
git add src/annal/store.py src/annal/pool.py
git commit -m "refactor: MemoryStore delegates to VectorBackend + Embedder protocols"
```

---

### Task 4: Implement QdrantBackend

**Files:**
- New: `src/annal/backends/qdrant.py`
- New: `tests/test_backend_qdrant.py`

**Step 1: Add qdrant-client dependency**

```bash
pip install qdrant-client
```

Add to `pyproject.toml` dependencies.

**Step 2: Write tests**

Create `tests/test_backend_qdrant.py` — mirror the ChromaBackend tests but use a `QdrantBackend` fixture that connects to `localhost:6333`. Use a skip marker if Qdrant isn't running:

```python
import pytest
from qdrant_client import QdrantClient

def qdrant_available():
    try:
        QdrantClient(url="http://localhost:6333").get_collections()
        return True
    except Exception:
        return False

pytestmark = pytest.mark.skipif(not qdrant_available(), reason="Qdrant not available")
```

Same test methods as ChromaBackend: `test_insert_and_query`, `test_scan`, `test_scan_with_where`, `test_count`, `test_delete`, `test_update`, `test_query_with_tag_filter`. Plus a Qdrant-specific test: `test_tag_filter_is_server_side` — verify that tag filtering doesn't over-fetch (the result count matches the limit without extra items).

**Step 3: Implement QdrantBackend**

```python
"""Qdrant vector backend for Annal."""

from __future__ import annotations

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, FieldCondition, Filter, MatchAny, MatchValue,
    PointStruct, Range, VectorParams,
)

from annal.backend import VectorResult


class QdrantBackend:
    def __init__(self, url: str, collection_name: str, dimension: int) -> None:
        self._client = QdrantClient(url=url)
        self._collection = collection_name
        self._ensure_collection(dimension)

    def _ensure_collection(self, dimension: int) -> None:
        collections = [c.name for c in self._client.get_collections().collections]
        if self._collection not in collections:
            self._client.create_collection(
                collection_name=self._collection,
                vectors_config=VectorParams(size=dimension, distance=Distance.COSINE),
            )
```

Key implementation details:

- `insert`: `client.upsert()` with `PointStruct(id=id, vector=embedding, payload={**metadata, "text": text})`
- `query`: `client.query_points()` with `query=embedding`. Build `Filter` from where dict:
  - `chunk_type` → `FieldCondition(key="chunk_type", match=MatchValue(value=...))`
  - `tags.$contains_any` → `FieldCondition(key="tags", match=MatchAny(any=[...]))`
  - `source.$prefix` → `FieldCondition(key="source", match=MatchText(text=prefix))` or use `Range` with prefix comparison
  - `created_at.$gt/$lt` → `FieldCondition(key="created_at", range=Range(gt=..., lt=...))`
- `scan`: `client.scroll()` with offset and limit
- `count`: `client.count()` with filter
- `delete`: `client.delete()` with point IDs
- `update`: `client.set_payload()` for metadata, `client.update_vectors()` for embeddings

Tags are stored as a native list in the payload — no JSON serialization needed.

**Step 4: Run Qdrant tests**

```bash
pytest tests/test_backend_qdrant.py -v
```

**Step 5: Commit**

```bash
git add src/annal/backends/qdrant.py tests/test_backend_qdrant.py pyproject.toml
git commit -m "feat: QdrantBackend implementing VectorBackend protocol"
```

---

### Task 5: Config-driven backend selection

**Files:**
- Modify: `src/annal/config.py`
- Modify: `src/annal/pool.py`

**Step 1: Extend config**

Add storage config to `AnnalConfig`:

```python
@dataclass
class StorageConfig:
    backend: str = "chromadb"
    backends: dict[str, dict] = field(default_factory=lambda: {
        "chromadb": {"path": DEFAULT_DATA_DIR},
    })
```

Parse from YAML:

```yaml
storage:
  backend: qdrant
  backends:
    qdrant:
      url: http://localhost:6333
    chromadb:
      path: ~/.annal/data
```

When `storage` section is absent, default to `chromadb` with `path: data_dir` for backwards compatibility.

**Step 2: Update StorePool._create_backend**

```python
    def _create_backend(self, project: str) -> VectorBackend:
        storage = self._config.storage
        backend_name = storage.backend
        backend_config = storage.backends.get(backend_name, {})
        collection_name = f"annal_{project}"

        if backend_name == "chromadb":
            from annal.backends.chromadb import ChromaBackend
            path = backend_config.get("path", self._config.data_dir)
            return ChromaBackend(path=path, collection_name=collection_name, dimension=self._get_embedder().dimension)

        elif backend_name == "qdrant":
            from annal.backends.qdrant import QdrantBackend
            url = backend_config.get("url", "http://localhost:6333")
            return QdrantBackend(url=url, collection_name=collection_name, dimension=self._get_embedder().dimension)

        else:
            raise ValueError(f"Unknown backend: {backend_name}")
```

**Step 3: Run all tests**

```bash
pytest -v
```

Expected: all pass. Default backend is still `chromadb`.

**Step 4: Commit**

```bash
git add src/annal/config.py src/annal/pool.py
git commit -m "feat: config-driven backend selection (chromadb default, qdrant optional)"
```

---

### Task 6: Migration CLI

**Files:**
- New: `src/annal/migrate.py`
- Modify: `src/annal/cli.py`
- New: `tests/test_migration.py`

**Step 1: Write migration test**

Test the core logic: create a ChromaBackend with some data, migrate to a fresh ChromaBackend (or Qdrant if available), verify all documents transferred correctly.

```python
def test_migrate_chromadb_to_chromadb(tmp_path, embedder):
    """Migration preserves all documents and metadata."""
    src = ChromaBackend(path=str(tmp_path / "src"), collection_name="test", dimension=embedder.dimension)
    dst = ChromaBackend(path=str(tmp_path / "dst"), collection_name="test", dimension=embedder.dimension)

    # Insert test data
    for i in range(10):
        emb = embedder.embed(f"memory {i}")
        src.insert(f"m{i}", f"memory {i}", emb, {"tags": ["test"], "chunk_type": "agent-memory", "created_at": "2026-01-01T00:00:00"})

    migrate(src, dst, embedder)

    assert dst.count() == 10
    results = dst.get([f"m{i}" for i in range(10)])
    assert len(results) == 10
```

**Step 2: Implement migration**

Create `src/annal/migrate.py`:

```python
"""Migrate data between vector backends."""

from __future__ import annotations

import logging
import sys

from annal.backend import Embedder, VectorBackend

logger = logging.getLogger(__name__)
BATCH_SIZE = 100


def migrate(src: VectorBackend, dst: VectorBackend, embedder: Embedder) -> int:
    """Copy all documents from src to dst. Returns count migrated."""
    total = src.count()
    migrated = 0
    offset = 0

    while offset < total:
        results, _ = src.scan(offset=offset, limit=BATCH_SIZE)
        if not results:
            break
        for r in results:
            embedding = embedder.embed(r.text)
            dst.insert(r.id, r.text, embedding, r.metadata)
            migrated += 1
        offset += len(results)
        print(f"\r  {migrated}/{total} documents migrated", end="", file=sys.stderr)

    print(file=sys.stderr)
    return migrated
```

**Step 3: Add CLI subcommand**

In `src/annal/cli.py`, add `annal migrate --from chromadb --to qdrant --project <name>`. If `--project` is omitted, migrate all projects. Creates source and destination backends from config, calls `migrate()` for each project.

**Step 4: Run tests**

```bash
pytest tests/test_migration.py -v
```

**Step 5: Commit**

```bash
git add src/annal/migrate.py src/annal/cli.py tests/test_migration.py
git commit -m "feat: annal migrate CLI for moving data between backends"
```

---

### Task 7: Hybrid search (BM25 + vector fusion)

**Files:**
- Modify: `src/annal/backends/qdrant.py`
- Modify: `tests/test_backend_qdrant.py`

**Step 1: Write hybrid search tests**

Add to `tests/test_backend_qdrant.py`:

```python
def test_hybrid_search_finds_exact_keyword(backend, embedder):
    """Hybrid search should find documents by exact keyword match even when vector similarity is low."""
    # Store a document with a very specific technical term
    backend.insert("m1", "The HNSW algorithm uses hierarchical navigable small world graphs",
                   embedder.embed("The HNSW algorithm uses hierarchical navigable small world graphs"),
                   {"tags": ["tech"], "chunk_type": "agent-memory"})
    backend.insert("m2", "We decided to use PostgreSQL for the user database",
                   embedder.embed("We decided to use PostgreSQL for the user database"),
                   {"tags": ["tech"], "chunk_type": "agent-memory"})

    # Search for the exact term — vector search alone might rank both similarly,
    # but hybrid should boost the exact keyword match
    results = backend.query(embedder.embed("HNSW"), limit=5)
    assert results[0].id == "m1"
```

**Step 2: Update QdrantBackend collection creation**

Add BM25 sparse vector config:

```python
from qdrant_client.models import SparseVectorParams, Modifier

def _ensure_collection(self, dimension: int) -> None:
    collections = [c.name for c in self._client.get_collections().collections]
    if self._collection not in collections:
        self._client.create_collection(
            collection_name=self._collection,
            vectors_config=VectorParams(size=dimension, distance=Distance.COSINE),
            sparse_vectors_config={
                "bm25": SparseVectorParams(modifier=Modifier.IDF),
            },
        )
```

**Step 3: Update insert to store BM25 sparse vector**

Qdrant computes BM25 from document text when using the `Document` input type:

```python
from qdrant_client.models import Document

def insert(self, id, text, embedding, metadata):
    # Upsert with both dense vector and BM25 sparse vector
    self._client.upsert(
        collection_name=self._collection,
        points=[PointStruct(
            id=id,
            vector={"": embedding},
            payload={**metadata, "text": text},
        )],
    )
    # Update BM25 sparse vector from document text
    self._client.set_payload(
        collection_name=self._collection,
        payload={},  # no-op payload
        points=[id],
    )
```

Note: The exact mechanism for populating BM25 sparse vectors depends on the Qdrant version. If the built-in BM25 inference isn't available, fall back to computing sparse vectors client-side using Qdrant's `fastembed` library, or skip BM25 and use pure dense search as a graceful degradation.

**Step 4: Update query to use hybrid fusion**

```python
from qdrant_client.models import Prefetch, FusionQuery, Fusion

def query(self, embedding, limit, where=None):
    qfilter = self._build_filter(where) if where else None

    # Check if collection has sparse vectors
    if self._has_bm25:
        results = self._client.query_points(
            collection_name=self._collection,
            prefetch=[
                Prefetch(query=embedding, limit=limit * 2),
                Prefetch(query=SparseVector(...), using="bm25", limit=limit * 2),
            ],
            query=FusionQuery(fusion=Fusion.RRF),
            limit=limit,
            query_filter=qfilter,
        )
    else:
        # Fallback to pure dense search
        results = self._client.query_points(
            collection_name=self._collection,
            query=embedding,
            limit=limit,
            query_filter=qfilter,
        )
    return [self._to_result(r) for r in results.points]
```

The BM25 sparse query vector for the search query text needs to be computed. Options:
1. Qdrant's built-in inference (if the collection is configured for it)
2. Client-side via `fastembed` BM25 tokenizer
3. Qdrant's `Document` query type for automatic sparse vector generation

Pick whichever approach works with the installed Qdrant version. The key constraint is that it must be transparent to `MemoryStore` — the `query()` signature doesn't change.

**Step 5: Run tests**

```bash
pytest tests/test_backend_qdrant.py -v
```

**Step 6: Commit**

```bash
git add src/annal/backends/qdrant.py tests/test_backend_qdrant.py
git commit -m "feat: hybrid search — BM25 + vector fusion via Qdrant"
```

---

### Task 8: Integration testing with Qdrant

**Files:**
- New: `tests/test_integration_qdrant.py`

**Step 1: Write integration tests**

Full-stack tests that create a `MemoryStore` with `QdrantBackend`, exercise the business-logic layer: `store`, `search` with fuzzy tags, `browse` with filters, `delete`, `update`, `stats`, `list_topics`, `get_all_file_mtimes`, `delete_by_source`.

```python
pytestmark = pytest.mark.skipif(not qdrant_available(), reason="Qdrant not available")


@pytest.fixture
def qdrant_store(tmp_path):
    backend = QdrantBackend(url="http://localhost:6333", collection_name=f"test_{uuid4().hex[:8]}", dimension=embedder.dimension)
    embedder = OnnxEmbedder()
    store = MemoryStore(backend, embedder)
    yield store
    # cleanup: delete collection


def test_store_and_search(qdrant_store):
    qdrant_store.store("JWT auth decision", tags=["auth", "decision"])
    results = qdrant_store.search("authentication", limit=5)
    assert len(results) == 1
    assert "JWT" in results[0]["content"]


def test_fuzzy_tags_on_qdrant(qdrant_store):
    qdrant_store.store("Auth uses OAuth", tags=["authentication"])
    results = qdrant_store.search("auth", tags=["auth"], limit=5)
    assert len(results) == 1  # fuzzy match: auth → authentication


def test_browse_with_tag_filter_server_side(qdrant_store):
    qdrant_store.store("Agent memory", tags=["decision"], chunk_type="agent-memory")
    qdrant_store.store("File chunk", tags=["indexed"], chunk_type="file-indexed")
    results, total = qdrant_store.browse(tags=["decision"])
    assert total == 1
```

**Step 2: Run integration tests**

```bash
pytest tests/test_integration_qdrant.py -v
```

**Step 3: Commit**

```bash
git add tests/test_integration_qdrant.py
git commit -m "test: full integration tests for MemoryStore + QdrantBackend"
```

---

### Task 9: Update backlog, version bump, docs

**Files:**
- Modify: `docs/plans/2026-02-20-feature-backlog.md`
- Modify: `pyproject.toml`
- Modify: `README.md`
- Modify: `docs/proposals/vector-backend-abstraction.md`

**Step 1: Update backlog**

Add a `## Shipped (spike 8)` section marking:
- Vector backend abstraction (VectorBackend + Embedder protocols)
- ChromaBackend extraction
- QdrantBackend with native tag filtering
- Hybrid search (BM25 + vector fusion)
- Migration tooling (`annal migrate`)
- Config-driven backend selection

Mark the architecture items as resolved:
- ~~Concurrent write safety~~ — resolved by Qdrant
- ~~Hybrid search~~ — BM25 + vector fusion via Qdrant

**Step 2: Bump version**

In `pyproject.toml`, bump to `0.6.0`.

**Step 3: Update README**

Add a "Backend Configuration" section explaining the default ChromaDB backend and optional Qdrant backend with config example.

**Step 4: Update proposal status**

In `docs/proposals/vector-backend-abstraction.md`, change status from Draft to Implemented.

**Step 5: Commit**

```bash
git add docs/ pyproject.toml README.md
git commit -m "chore: bump to 0.6.0, update backlog and README for spike 8"
```
