Skip to content
Draft
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
1292250
feat(budget): book a pre-priced entry to the active ledger
OchnikBartek Aug 18, 2026
6e02b1d
feat(rag): add rerank columns to knowledge_bases
OchnikBartek Aug 18, 2026
63cf6f6
feat(rag): resolve a collection's reranker and whose key pays
OchnikBartek Aug 18, 2026
5258ec2
feat(rag): add BaseReranker and a Cohere implementation
OchnikBartek Aug 18, 2026
d88b8aa
feat(rag): rerank retrieval candidates when a collection is configured
OchnikBartek Aug 18, 2026
8dd6ca8
feat(rag): meter POST /rag/search against the organization
OchnikBartek Aug 18, 2026
22a2395
feat(rag): let a knowledge base set its reranker
OchnikBartek Aug 18, 2026
3e2bedf
test(rag): pin the rerank key never escapes and stays in its tenant
OchnikBartek Aug 18, 2026
217eaed
docs(rag): document per-collection reranking
OchnikBartek Aug 18, 2026
bf12b00
feat(rag): set a collection's reranker from the KB create dialog
OchnikBartek Aug 18, 2026
b2a84a0
fix(rag): reuse the existing cohere purpose instead of duplicating it
OchnikBartek Aug 18, 2026
72e0744
fix(rag): rerank on the agent-run path, not only /rag/search
OchnikBartek Aug 18, 2026
a1ced17
docs(rag): state the first-collection rerank rule and the min_score s…
OchnikBartek Aug 18, 2026
d97c2bb
feat(rag): edit or turn off a collection's reranker after creation
OchnikBartek Aug 18, 2026
117e8f7
fix(rag): check the caller may use a rerank key before binding it
OchnikBartek Aug 18, 2026
b3bf3b1
fix(rag): warn when a deleted key leaves a half-configured reranker
OchnikBartek Aug 18, 2026
81b6072
fix(rag): book a failed search's spend before its transaction rolls back
OchnikBartek Aug 18, 2026
0249a3f
fix(rag): don't offer a reranking edit on an app-scoped collection
OchnikBartek Aug 18, 2026
79247fe
test(e2e): scope the ingestion Edit click past the new reranking panel
OchnikBartek Aug 18, 2026
345bdfd
Merge branch 'main' into feat/rag-reranker
OchnikBartek Aug 18, 2026
311fe07
merge: bring feat/rag-reranker up to date with main
OchnikBartek Aug 20, 2026
17008b4
fix(rag): refuse a knowledge search past the organization's budget
OchnikBartek Aug 20, 2026
66907bf
fix(rag): reject an unsupported rerank model at create and update
OchnikBartek Aug 20, 2026
77770f6
fix(rag): rerank a multi-collection union only when its collections a…
OchnikBartek Aug 20, 2026
0be16b2
test(e2e): await the now-async howItReads before clicking its Edit
OchnikBartek Aug 20, 2026
4348329
fix(rag): report search spend as retrieval, not indexing
OchnikBartek Aug 20, 2026
4f48ab5
merge: reconcile with the stale remote feat/rag-reranker tip
OchnikBartek Aug 20, 2026
02cf4af
Merge branch 'main' into feat/rag-reranker
OchnikBartek Aug 20, 2026
9a66605
Merge branch 'main' into feat/rag-reranker
OchnikBartek Aug 20, 2026
55bce96
fix(rag): re-parent rerank migrations onto main's new head
OchnikBartek Aug 20, 2026
0397e86
fix(rag): close the Cohere client each rerank builds
OchnikBartek Aug 20, 2026
932d501
fix(rag): scope embedding and rerank resolution to the acting tenant …
OchnikBartek Aug 21, 2026
87039b7
Merge remote-tracking branch 'origin/main' into feat/rag-reranker
OchnikBartek Aug 21, 2026
a5f3a7c
fix(rag): renumber the rerank migrations onto main's 0045
OchnikBartek Aug 21, 2026
d3b2a6d
fix(rag): pass organization_id from the rag-search CLI
OchnikBartek Aug 21, 2026
6ae3331
fix(rag): meter rerank by Cohere's billed search units
OchnikBartek Aug 21, 2026
1ad0015
fix(vault): report knowledge-base bindings in secret usage
OchnikBartek Aug 21, 2026
7c985d4
fix(rag): resolve a search's collection by the authorized knowledge base
OchnikBartek Aug 21, 2026
cfdc96e
test(rag): widen the reserved-names fake resolver to the new signature
OchnikBartek Aug 21, 2026
b869b89
Merge remote-tracking branch 'origin/main' into feat/rag-reranker
OchnikBartek Aug 23, 2026
2c72d65
fix(rag): renumber the rerank migrations onto main's 0055
OchnikBartek Aug 23, 2026
11c8d47
fix(rag): carry bound knowledge-base ids through agent retrieval
OchnikBartek Aug 26, 2026
278b6c7
merge: bring feat/rag-reranker up to date with main
OchnikBartek Aug 26, 2026
2472ff3
style(rag): wrap the reserved-names test resolver ruff-format wants b…
OchnikBartek Aug 26, 2026
2dcbd86
Merge remote-tracking branch 'origin/main' into feat/rag-reranker
OchnikBartek Aug 26, 2026
69e42a0
fix(rag): reparent rerank migrations onto main's 0059_invite_fk_ondelete
OchnikBartek Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions backend/alembic/versions/0056_knowledge_base_rerank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Which reranker - and whose key - a collection reranks search results with.

Retrieval fetches candidates by vector similarity and returns the top ones. A
reranker is a second pass: a model scores each candidate against the query
directly and reorders them, which is more accurate than the distance the vector
index sorts by. It is optional and off by default.

Two nullable columns, mirroring the embedding pair. `rerank_model` is the
reranker's name; `rerank_secret_id` is the organization vault key that pays for
it. Reranking runs only when *both* are set - either NULL leaves retrieval
exactly as it was, so existing rows and unconfigured deployments are unchanged
by this migration. SET NULL on delete for the same reason the embedding key is:
losing the key drops reranking, it does not take document search down. Unlike
the embedding key there is no deployment fallback - a reranker with no key is
simply off.

Revision ID: 0056_knowledge_base_rerank
Revises: 0055_sandbox_operations
Create Date: 2026-08-18

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "0056_knowledge_base_rerank"
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
down_revision: str | None = "0055_sandbox_operations"
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"knowledge_bases",
sa.Column("rerank_model", sa.String(length=128), nullable=True),
)
op.add_column(
"knowledge_bases",
sa.Column("rerank_secret_id", sa.UUID(), nullable=True),
)
op.create_foreign_key(
op.f("knowledge_bases_rerank_secret_id_fkey"),
"knowledge_bases",
"organization_secrets",
["rerank_secret_id"],
["id"],
ondelete="SET NULL",
)


def downgrade() -> None:
op.drop_constraint(
op.f("knowledge_bases_rerank_secret_id_fkey"),
"knowledge_bases",
type_="foreignkey",
)
op.drop_column("knowledge_bases", "rerank_secret_id")
op.drop_column("knowledge_bases", "rerank_model")
43 changes: 43 additions & 0 deletions backend/alembic/versions/0057_ingestion_spend_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Tag each non-run RAG spend row as indexing or retrieval.

`ingestion_spend` began as indexing alone, then a metered `POST /rag/search`
landed its embedding and rerank cost in the same table - both are RAG spend
outside any agent run. Left undistinguished, a search inflated the dashboard's
"indexing" subtotal. `source` tells them apart; both still count toward the
monthly budget, only the reporting split reads the column.

Every row that predates the column is indexing, so `server_default` backfills
them to `'ingestion'` without a data migration.

Revision ID: 0057_ingestion_spend_source
Revises: 0056_knowledge_base_rerank
Create Date: 2026-08-20

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "0057_ingestion_spend_source"
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
down_revision: str | None = "0056_knowledge_base_rerank"
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"ingestion_spend",
sa.Column(
"source",
sa.String(length=16),
nullable=False,
server_default="ingestion",
),
)


def downgrade() -> None:
op.drop_column("ingestion_spend", "source")
2 changes: 2 additions & 0 deletions backend/app/agents/capabilities/budget/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
SpendLedger,
SpendLimit,
SpendShare,
book_ambient_spend,
booked_to,
metered_by,
price_request,
Expand All @@ -26,6 +27,7 @@
"SpendLedger",
"SpendLimit",
"SpendShare",
"book_ambient_spend",
"booked_to",
"metered_by",
"price_request",
Expand Down
18 changes: 18 additions & 0 deletions backend/app/agents/capabilities/budget/_capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,24 @@ def record_ambient_usage(
ledger.record(model_name, usage, provider)


def book_ambient_spend(entry: SpendEntry) -> None:
"""Book one already-priced entry to whichever ledger is active, if any is.

The sibling of :func:`record_ambient_usage` for spend that is not
token-priced. `record` prices through `genai-prices`, which knows chat and
embedding models and nothing else, so a reranker call routed through it
would book `cost_usd=0, priced=False`. A reranker computes its own cost from
a published per-search price and hands the finished :class:`SpendEntry` here
instead, so the entry lands `priced=True` with a real number.

A no-op when nothing is metering, for the same reason the sibling is: a
provider should not refuse to work because no ledger is open.
"""
ledger = _active_ledger.get()
if ledger is not None:
ledger.book(entry)


def usage_counts(usage: RunUsage) -> tuple[int, int, int, int]:
"""The four counters a price is computed from, read off the run's usage.

Expand Down
27 changes: 24 additions & 3 deletions backend/app/agents/capabilities/knowledge/_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import contextvars
import logging
from typing import TYPE_CHECKING, Any
from uuid import UUID

from app.core.config import settings
from app.core.exceptions import AppException, ExternalServiceError
from app.services.embedding_resolution import embeddings_for_collection
from app.services.rag.embeddings import EmbeddingService
from app.services.rag.reranker import build_reranker
from app.services.rag.retrieval import RetrievalService
from app.services.rag.vectorstore import PgVectorStore

Expand All @@ -32,7 +34,13 @@ def get_retrieval_service() -> "BaseRetrievalService":
rag_settings, embedding_service, resolver=embeddings_for_collection
)
_vector_store = vector_store
_retrieval_service = RetrievalService(vector_store, rag_settings)
# The reranker resolver is wired here too, not only on the /rag/search
# route: an agent's knowledge search reranks when its collection is
# configured, and the run's open ledger books the cost - which is the
# agent-run half of "spend recorded on both paths".
_retrieval_service = RetrievalService(
vector_store, rag_settings, reranker_resolver=build_reranker
)
return _retrieval_service


Expand Down Expand Up @@ -93,6 +101,8 @@ async def search_knowledge_base(
query: str,
kb_collection_names: list[str] | None = None,
top_k: int = 5,
*,
organization_id: UUID | None,
) -> str:
"""Search the knowledge base and return formatted results.

Expand All @@ -102,6 +112,9 @@ async def search_knowledge_base(
agent's spec. Never supplied by the LLM directly - injected via
PydanticAI Deps or the _active_kb_collections ContextVar.
top_k: Number of top results to retrieve (default: 5).
organization_id: The organization the run acts for, so a collection name
shared across tenants resolves this one's embedding and rerank config
rather than another's (#913).
"""
resolved = kb_collection_names if kb_collection_names else (_active_kb_collections.get() or [])
if not resolved:
Expand All @@ -111,10 +124,18 @@ async def search_knowledge_base(
one_collection = len(resolved) == 1
try:
if one_collection:
results = await service.retrieve(query=query, collection_name=resolved[0], limit=top_k)
results = await service.retrieve(
query=query,
collection_name=resolved[0],
limit=top_k,
organization_id=organization_id,
Comment thread
OchnikBartek marked this conversation as resolved.
)
else:
results = await service.retrieve_multi(
query=query, collection_names=resolved, limit=top_k
query=query,
collection_names=resolved,
limit=top_k,
organization_id=organization_id,
)
except AppException:
# Already an account of what is wrong and what to do about it - an
Expand Down
3 changes: 3 additions & 0 deletions backend/app/agents/capabilities/knowledge/_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ async def search_documents(
# model chooses *what* to search, never *where*.
kb_collection_names=ctx.deps.kb_collection_names,
top_k=top_k or default_top_k,
# The run's own organization, so a collection name shared with
# another tenant resolves this agent's config, not theirs (#913).
organization_id=ctx.deps.organization_id,
)
except Exception as exc:
raise ModelRetry("Knowledge base temporarily unavailable, please try again.") from exc
Expand Down
33 changes: 29 additions & 4 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,8 @@ async def verify_api_key(
from app.services.embedding_resolution import embeddings_for_collection
from app.services.rag.ingestion import IngestionService
from app.services.rag.documents import DocumentProcessor
from app.services.knowledge_search import KnowledgeSearchService
from app.services.rag.reranker import build_reranker
from app.services.rag.retrieval import RetrievalService
from app.services.rag.vectorstore import PgVectorStore
from app.services.rag.vectorstore import BaseVectorStore
Expand Down Expand Up @@ -958,13 +960,31 @@ async def get_vectorstore(


def get_retrieval_service(vector_store: VectorStoreSvc) -> RetrievalService:
"""Create RetrievalService instance."""
return RetrievalService(vector_store=vector_store, settings=settings.rag)
"""Create RetrievalService instance.

The reranker resolver is `build_reranker`, the one composition point shared
with the agent-run knowledge tool, so both paths rerank the same way.
"""
return RetrievalService(
vector_store=vector_store,
settings=settings.rag,
reranker_resolver=build_reranker,
)


RetrievalSvc = Annotated[RetrievalService, Depends(get_retrieval_service)]


def get_knowledge_search_service(
db: DBSession, retrieval: RetrievalSvc, access: CollectionAccessSvc
) -> KnowledgeSearchService:
"""Create KnowledgeSearchService instance."""
return KnowledgeSearchService(db, retrieval, access)


KnowledgeSearchSvc = Annotated[KnowledgeSearchService, Depends(get_knowledge_search_service)]


def get_document_processor() -> DocumentProcessor:
"""Create DocumentProcessor instance."""
return DocumentProcessor(settings=settings.rag)
Expand All @@ -976,9 +996,14 @@ def get_document_processor() -> DocumentProcessor:
def get_ingestion_service(
processor: DocumentProcessorSvc,
vector_store: VectorStoreSvc,
org: ActiveOrg,
) -> IngestionService:
"""Create IngestionService instance."""
return IngestionService(processor=processor, vector_store=vector_store)
"""Create IngestionService instance, scoped to the active organization.

The organization is what lets the store resolve this tenant's embedding key
on a collection name another tenant may share, rather than another's (#913).
"""
return IngestionService(processor=processor, vector_store=vector_store, organization_id=org.id)


IngestionSvc = Annotated[IngestionService, Depends(get_ingestion_service)]
Expand Down
32 changes: 9 additions & 23 deletions backend/app/api/routes/v1/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
CurrentAppAdmin,
IngestionSvc,
KnowledgeBaseSvc,
KnowledgeSearchSvc,
RAGDocumentSvc,
RAGSyncSvc,
RetrievalSvc,
SyncSourceSvc,
VectorStoreSvc,
require,
Expand Down Expand Up @@ -159,7 +159,7 @@ async def create_collection(
is refused rather than quietly aliased onto their vector table.
"""
await access.claim(ctx, name)
await vector_store.create_collection(name)
await vector_store.create_collection(name, organization_id=ctx.organization_id)
await kb_svc.create_for_rag_collection(
name, user_id=ctx.subject_id, organization_id=ctx.organization_id
)
Expand Down Expand Up @@ -216,7 +216,9 @@ async def get_collection_info(
) -> Any:
"""Retrieve stats for a specific collection."""
collection = await access.readable(ctx, name)
return await vector_store.get_collection_info(collection.collection_name)
return await vector_store.get_collection_info(
collection.collection_name, organization_id=collection.organization_id
)


@router.get(
Expand All @@ -242,33 +244,17 @@ async def list_documents(
)
async def search_documents(
request: RAGSearchRequest,
retrieval_service: RetrievalSvc,
access: CollectionAccessSvc,
service: KnowledgeSearchSvc,
ctx: Auth,
) -> Any:
"""Search for relevant document chunks. Supports multi-collection search.

Every collection named is resolved before the first vector is read, and one
the caller cannot reach refuses the whole search rather than being dropped
from it - see `CollectionAccessService.readable_all`.
from it. The embedding and any rerank the search runs are metered against
the caller's organization - see `KnowledgeSearchService`.
"""
names = request.collection_names or [request.collection_name]
collections = [kb.collection_name for kb in await access.readable_all(ctx, names)]
if len(collections) > 1:
results = await retrieval_service.retrieve_multi(
query=request.query,
collection_names=collections,
limit=request.limit,
min_score=request.min_score,
)
else:
results = await retrieval_service.retrieve(
query=request.query,
collection_name=collections[0],
limit=request.limit,
min_score=request.min_score,
filter=request.filter or "",
)
results = await service.search(ctx, request)
api_results = [RAGSearchResult(**hit.model_dump()) for hit in results]
return RAGSearchResponse(results=api_results)

Expand Down
11 changes: 8 additions & 3 deletions backend/app/commands/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ def get_rag_services() -> tuple[
)
processor = DocumentProcessor(settings=settings)
retrieval = RetrievalService(vector_store=vector_store, settings=settings)
ingestion = IngestionService(processor=processor, vector_store=vector_store)
# CLI admin context: no single tenant, so resolution falls back to the name.
# Uploads through the API are org-scoped.
ingestion = IngestionService(
processor=processor, vector_store=vector_store, organization_id=None
)
return settings, vector_store, processor, retrieval, ingestion


Expand All @@ -93,7 +97,7 @@ async def list_collections_async(vector_store: BaseVectorStore) -> None:

for name in collection_names:
try:
info_obj = await vector_store.get_collection_info(name)
info_obj = await vector_store.get_collection_info(name, organization_id=None)
click.echo(f" {name}")
click.echo(f" Vectors: {info_obj.total_vectors:,}")
click.echo(f" Dimension: {info_obj.dim}")
Expand Down Expand Up @@ -344,6 +348,7 @@ async def search_async(
query=query,
collection_name=collection,
limit=top_k,
organization_id=None,
)

if not results:
Expand Down Expand Up @@ -474,7 +479,7 @@ async def stats_async(settings: RAGSettings, vector_store: BaseVectorStore) -> N
total_vectors = 0
for name in collection_names:
try:
info_obj = await vector_store.get_collection_info(name)
info_obj = await vector_store.get_collection_info(name, organization_id=None)
click.echo(f" {name}:")
click.echo(f" Vectors: {info_obj.total_vectors:,}")
total_vectors += info_obj.total_vectors
Expand Down
Loading
Loading