#!/usr/bin/env python3 """Сверить отметки `documents.faiss_id` с реальным содержимым векторного индекса. Проблема, которую он решает: отметка в базе НЕ означает, что вектор есть в индексе. Она остаётся после пересоздания индекса (например, при смене модели эмбеддингов и размерности) и после сбоев worker-gpu. Из-за этого документ навсегда выпадает из L3: в индексе его нет, а на пересчёт он не попадёт — `reembed_missing.py` ищет только `faiss_id IS NULL`. Замер на проде 2026-08-28: в индексе 93 053 вектора, отмечено в базе 154 046 — 60 993 документа считались векторизованными, не будучи ими. Скрипт обнуляет отметки у документов, которых в индексе нет. После него `reembed_missing.py` отправит их на пересчёт. Запуск (нужен доступ к самому индексу → контейнер worker-gpu): cd /home/user/anti-plagiarism C="docker compose -f docker-compose.prod.yml exec -T worker-gpu python -" $C < scripts/ops/faiss_reconcile.py # dry-run, только показывает $C --apply < scripts/ops/faiss_reconcile.py # обнулить ложные отметки Работает только с FAISS (`VECTOR_BACKEND=faiss`): у Qdrant идентификаторы хранит сам сервис, и сверка там делается иначе. """ import argparse import sys def stale_marks(marked_ids: set[int], indexed_ids: set[int]) -> set[int]: """Документы, отмеченные как векторизованные, но отсутствующие в индексе.""" return marked_ids - indexed_ids def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--apply", action="store_true", help="реально обнулить faiss_id (иначе dry-run)") args = ap.parse_args() from app.config import settings if settings.VECTOR_BACKEND != "faiss": sys.exit(f"VECTOR_BACKEND={settings.VECTOR_BACKEND}: скрипт рассчитан на faiss") import faiss # noqa: F401 (нужен для vector_to_array) from app.faiss_manager import FAISSManager FAISSManager.load_or_create() index = FAISSManager._index if index is None or not hasattr(index, "id_map"): sys.exit("индекс не загрузился или не хранит id (нет id_map)") indexed = {int(i) for i in faiss.vector_to_array(index.id_map)} print(f"векторов в индексе: {index.ntotal} (уникальных id: {len(indexed)})") import psycopg2 conn = psycopg2.connect( host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT, dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER, password=settings.POSTGRES_PASSWORD, ) with conn.cursor() as cur: cur.execute("SELECT id FROM documents WHERE faiss_id IS NOT NULL") marked = {r[0] for r in cur.fetchall()} cur.execute("SELECT count(*) FROM documents") total = cur.fetchone()[0] stale = stale_marks(marked, indexed) print(f"документов всего: {total}") print(f"отмечено как векторизованные: {len(marked)}") print(f"ложных отметок (нет в индексе): {len(stale)}") print(f"реальное покрытие L3: {len(indexed & marked) * 100.0 / total:.1f}%") if not stale: print("Сверка чистая, делать нечего.") conn.close() return if not args.apply: print(f"\n[dry-run] первые id: {sorted(stale)[:10]}") print("Запустите с --apply, чтобы обнулить faiss_id у этих документов,") print("затем reembed_missing.py отправит их на пересчёт эмбеддингов.") conn.close() return ids = list(stale) with conn.cursor() as cur: # Пачками: один UPDATE с десятками тысяч id упирается в лимиты параметров for i in range(0, len(ids), 5000): chunk = ids[i : i + 5000] cur.execute("UPDATE documents SET faiss_id = NULL WHERE id = ANY(%s)", (chunk,)) conn.commit() conn.close() print(f"\nОбнулено отметок: {len(ids)}") print("Дальше: reembed_missing.py --apply (отправит их на пересчёт).") if __name__ == "__main__": main()