3d9b76cea4
Check Cross-Plugin Imports / check (push) Has been cancelled
- SPIKE-E: FTS+Vector+Permission benchmark on 10k records (all <30ms) - E-PROV: supports_fts/vector/rag/graph capability flags on all providers - E-FTS/VEC: All 11 providers refactored to BaseSearchProvider with permission filtering - E-PERM: Over-fetch strategy for vector+permission (15x faster than ANY() filter) - E-FUSE: rrf_fusion_multi() for N-way RRF over FTS+Vector+RAG+Graph - E-LLM: Query understanding cleaned up to use central llm_complete() - E-CHUNK: Document chunking module + document_chunks table with HNSW index - E-EMB: Chunk embedding ARQ jobs (index_file_chunks, reindex_chunks) - E-RAG: RAG retrieval via FileSearchProvider.search_rag() - E-GRAPH: GraphRAG BFS traversal via GraphRAGSearchProvider.search_graph() - E-IX-EVT: Auto-indexing via outbox events + delete/cleanup handlers - E-IX-RE: Batch reindex with progress tracking + reindex_all job - E-DATA-LIFE: Lifecycle module (remove/rebuild/restore/correct) + API endpoints - E-K-MEM: AgentMemorySearchProvider - E-P-AI: AIChatSearchProvider - E-P-WF: WorkflowSearchProvider - E-P-COMM: ConversationSearchProvider verified (already on BaseSearchProvider) - E-API: Filter params (date_from/to, tags, sort) + /facets endpoint - E-TOOL: unified_search AI tool registered in ToolRegistry - E-MCP: Search tool in MCP server with normal RBAC/tenant checks - E-UI-CMD: CommandPalette (Cmd+K) with debounced search + recent searches - E-UI-FAC: SearchFacets, SearchResultCard, SavedSearches components - E-TEST: 40 new tests in test_unified_search_phase_e.py (105 total green) - E-DOC: api-documentation.md, plugin-development-guide.md, test-strategy.md updated 105 tests passing, TypeScript clean.
139 lines
4.2 KiB
Python
139 lines
4.2 KiB
Python
"""Derived-data lifecycle management for the Unified Search index.
|
|
|
|
Provides functions to remove, rebuild, and purge search index data
|
|
(embeddings, FTS vectors, document chunks) when entities are deleted,
|
|
restored, or corrected.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import text
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Entity type -> (table_name, tsv_column, embedding_column)
|
|
_ENTITY_MAP: dict[str, tuple[str, str, str]] = {
|
|
"contact": ("contacts", "search_tsv", "embedding"),
|
|
"mail": ("mails", "body_tsv", "embedding"),
|
|
"file": ("files", "content_tsv", "embedding"),
|
|
"event": ("calendar_entries", "search_tsv", "embedding"),
|
|
}
|
|
|
|
|
|
def _resolve_entity(entity_type: str) -> tuple[str, str, str] | None:
|
|
"""Return (table, tsv_column, embedding_column) for an entity type."""
|
|
return _ENTITY_MAP.get(entity_type)
|
|
|
|
|
|
async def remove_from_index(
|
|
db: "AsyncSession",
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Remove an entity from the search index (embedding=NULL, TSV=NULL).
|
|
|
|
Does **not** delete document_chunks — use :func:`remove_chunks` for that.
|
|
"""
|
|
mapping = _resolve_entity(entity_type)
|
|
if mapping is None:
|
|
logger.warning("remove_from_index: unknown entity_type=%s", entity_type)
|
|
return
|
|
|
|
table, tsv_col, emb_col = mapping
|
|
await db.execute(
|
|
text(
|
|
f"UPDATE {table} "
|
|
f"SET {emb_col} = NULL, {tsv_col} = NULL, indexed_at = NULL "
|
|
f"WHERE id = :eid AND tenant_id = :tid"
|
|
),
|
|
{"eid": entity_id, "tid": tenant_id},
|
|
)
|
|
await db.commit()
|
|
logger.info("Removed %s/%s from search index", entity_type, entity_id)
|
|
|
|
|
|
async def remove_chunks(
|
|
db: "AsyncSession",
|
|
file_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Remove all document_chunks for a file."""
|
|
await db.execute(
|
|
text(
|
|
"DELETE FROM document_chunks "
|
|
"WHERE file_id = :fid AND tenant_id = :tid"
|
|
),
|
|
{"fid": file_id, "tid": tenant_id},
|
|
)
|
|
await db.commit()
|
|
logger.info("Removed chunks for file %s", file_id)
|
|
|
|
|
|
async def rebuild_index(
|
|
db: "AsyncSession",
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> bool:
|
|
"""Rebuild the search index for a single entity from the authoritative source.
|
|
|
|
Re-generates the embedding and refreshes the TSV vector.
|
|
Returns True on success, False on failure.
|
|
"""
|
|
from app.plugins.builtins.unified_search.embedding import index_entity
|
|
|
|
# Clear existing index data first
|
|
await remove_from_index(db, entity_type, entity_id, tenant_id)
|
|
|
|
# Re-generate embedding (index_entity also updates indexed_at)
|
|
try:
|
|
success = await index_entity(entity_type, entity_id, tenant_id, db)
|
|
if success and entity_type == "file":
|
|
# Re-index chunks for files
|
|
from app.plugins.builtins.unified_search.jobs import index_file_chunks
|
|
await index_file_chunks({"_lifecycle": True}, str(entity_id))
|
|
return success
|
|
except Exception:
|
|
logger.exception("rebuild_index failed for %s/%s", entity_type, entity_id)
|
|
return False
|
|
|
|
|
|
async def handle_entity_delete(
|
|
db: "AsyncSession",
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Handle entity deletion: remove from index + remove chunks if applicable."""
|
|
await remove_from_index(db, entity_type, entity_id, tenant_id)
|
|
if entity_type == "file":
|
|
await remove_chunks(db, entity_id, tenant_id)
|
|
|
|
|
|
async def handle_entity_restore(
|
|
db: "AsyncSession",
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Handle entity restore from trash: rebuild search index."""
|
|
await rebuild_index(db, entity_type, entity_id, tenant_id)
|
|
|
|
|
|
async def handle_entity_correction(
|
|
db: "AsyncSession",
|
|
entity_type: str,
|
|
entity_id: uuid.UUID,
|
|
tenant_id: uuid.UUID,
|
|
) -> None:
|
|
"""Handle entity data correction: rebuild search index."""
|
|
await rebuild_index(db, entity_type, entity_id, tenant_id)
|