Why: - Establish the versioned FastAPI boundary for authentication, tenant isolation, chat runs, file ingestion, point management, and health checks. Changes: - Define /v1 routers, bearer-token authentication, scopes, response envelopes, error conventions, and job-shaped ingestion responses. - Rename the older indicative /chunks routes to /points while preserving the existing Qdrant payload and CRUD semantics. - Define tenant injection and concurrency requirements at the HTTP boundary. Impact: - The REST API is owned by ADR-0008 when older endpoint examples differ. - Clients should use /v1/points and /v1/threads resource paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.6 KiB
0002. Chunk CRUD and payload/keyword search API
Status
Accepted
Route naming note: ADR-0008 owns the REST API surface and renames the
indicative endpoint examples in this ADR from /chunks/... to /points/....
The CRUD semantics, Qdrant primitives, payload schema, soft-delete behavior,
and tenant-isolation rules in this ADR remain accepted.
Context
Beyond bulk ingestion (0001), we need FastAPI endpoints for direct, fine-grained management of individual chunks: create a single chunk, update a chunk's content/vectors/payload, delete chunks (by ID or in bulk by filter), and list/search chunks by keyword and payload metadata (tenant, domain, file, etc.) — independent of the semantic hybrid retrieval used by the AI agent (ADR-0003).
This pipeline operates on the same collection and payload schema defined in ADR-0001, and Qdrant's Points API already provides the primitives needed — there's no need for a custom data layer on top.
Decision
Expose a thin FastAPI layer directly over Qdrant's Points API, operating on
the chunks collection and payload schema from ADR-0001:
| Operation | FastAPI endpoint (indicative) | Qdrant primitive |
|---|---|---|
| Create a point | POST /points |
upsert (single point) |
| Update a point's vectors/content | PUT /points/{point_id} |
upsert (update_only mode) |
| Partially update payload | PATCH /points/{point_id}/payload |
set_payload / overwrite_payload |
| Delete one point | DELETE /points/{point_id} |
delete by ID |
| Delete many points | DELETE /points?file_id=... |
delete by filter |
| List/paginate points, in order | GET /points?file_id=... |
scroll with filter + pagination, order_by: order_id |
| Reorder/insert a point | PATCH /points/{point_id}/order |
set_payload on order_id only |
| Count points | GET /points/count |
count |
| Keyword search | GET /points/search?q=... |
full-text payload index on content (match, not semantic) |
| Bulk multi-op edits | POST /points/batch |
Qdrant points/batch |
Ordering and reordering
Chunks belonging to a file_id are listed via scroll ordered by the
mutable order_id payload field (ADR-0001) — this is what lets the frontend
display and let a backend user modify chunk order. Because order_id is a
fractional float key rather than a sequential integer position, moving or
inserting a chunk only requires assigning it a new value between its two new
neighbors — no renumbering of siblings, and no effect on the chunk's stable
point ID (which is derived from the immutable chunk_index, not order_id).
PATCH /points/{point_id}/order does more than set one field, though: since
ADR-0001 also maintains previous_chunk_id/next_chunk_id pointers for O(1)
adjacency lookups, a single reorder touches up to four chunks in one
points/batch call:
- The moved chunk: new
order_id, newprevious_chunk_id/next_chunk_id. - Its old neighbors: re-link their
previous_chunk_id/next_chunk_idto skip over the moved chunk. - Its new neighbors: re-link them to point at the moved chunk.
The same fix-up applies to insert (new chunk) and delete (soft-deleted chunk
— see below) operations: any operation that changes a chunk's position or
removes it from the sequence must atomically update the neighbors' pointers
in the same points/batch request, or previous_chunk_id/next_chunk_id
go stale.
Keyword search is not semantic search
GET /points/search matches against the full-text payload index on
content (and structured filters on tenant_id, domain, file_id,
etc.) — it is filter/match-based keyword search, not embedding-based
retrieval. This is deliberately distinct from the hybrid dense+sparse
retrieval used by the AI agent in ADR-0003; the two "search" concepts serve
different callers (a human/admin managing chunks vs. an agent retrieving
context) and should not be conflated in the API or in future discussion.
Delete is soft by default
DELETE /points/{point_id} and DELETE /points?file_id=... set
is_active: false and deleted_at (via set_payload) rather than removing
the point from Qdrant — consistent with ADR-0001's soft-delete fields. This
keeps deleted points available for audit and lets GET /points and
GET /points/search filter them out by default (is_active: true implied
unless the caller explicitly asks to include inactive chunks). A hard delete
(actual point removal, e.g. points/delete) is available separately for
compliance-driven purges, not as the default CRUD behavior. Either way, the
deleted chunk's previous_chunk_id/next_chunk_id neighbors are relinked to
point at each other in the same points/batch call, so context-window
expansion (ADR-0003) never walks into a deactivated or removed chunk.
Tenant isolation
Every endpoint (read and write) has its tenant_id filter injected
server-side from the authenticated request context — never accepted as
client-supplied input in the request body or query string. This is
non-negotiable: it's the same isolation boundary ADR-0001 relies on for
multitenancy, and it must hold for every code path that touches the
collection, not just ingestion.
Concurrency
Updates use the version payload field (from ADR-0001) together with
Qdrant's update_filter, giving an optimistic-concurrency-style guard
against races between a concurrent ingestion re-run (ADR-0001) and a manual
edit through this API.
Consequences
Positive
- Reuses the exact collection/schema from ADR-0001 — no parallel data model to keep in sync.
- Thin mapping to Qdrant primitives keeps the API predictable and easy to extend as new filter/sort needs arise.
- Clear separation from ADR-0003's semantic search avoids API consumers confusing "find chunks matching this filter" with "retrieve chunks relevant to this query."
Negative
- Keyword search here is limited to what Qdrant's full-text payload index supports — no stemming/synonym handling beyond what that index offers.
- Optimistic concurrency via
versionrequires every writer (ingestion and this API) to consistently read-check-write; a writer that skips this can silently clobber concurrent edits.
Alternatives Considered
- Separate keyword-search infrastructure (e.g. Elasticsearch): rejected —
Qdrant's native full-text payload index already covers filter-style keyword
search on
content, and adding a second search system would duplicate data and infrastructure for no clear benefit at current scale. - Client-supplied
tenant_idin request body: rejected — trusting client input for the isolation boundary is a direct multitenancy security risk; it must come from server-side auth context.