fix(admin): покрытие L3 мерить по индексу, а не по отметкам в базе
Проверка на живых данных вскрыла, что панель отладки врала в мою же пользу: показывала 154 046 «документов с эмбеддингом» (87%), тогда как в FAISS реально 93 053 вектора (52.5%). Колонка documents.faiss_id для этого непригодна — отметка остаётся после пересоздания индекса (смена модели: 768 → 1024) и после сбоев worker-gpu. Выборочная проверка: у 8 из 20 «отмеченных» вектора нет. - gpu.index_stats — новая задача, отдаёт реальное содержимое активного векторного бэкенда; API спрашивает её для панели отладки; - панель показывает «векторов в индексе» и отдельно предупреждает о ложных отметках (их 60 993), потому что такие документы молча выпадают из L3: в индексе их нет, а на пересчёт они не попадут — reembed ищет faiss_id IS NULL; - scripts/ops/faiss_reconcile.py — сверяет отметки с индексом и обнуляет ложные, после чего reembed_missing.py отправляет их на пересчёт. Проверен вживую (dry-run на проде: 93 053 в индексе, 60 993 ложных отметок). Документация: зафиксирована реальная глубина корпуса — полный текст только у 27.5% документов, у 79% меньше 500 отпечатков (уровень аннотации). Система ловит списывание из того, что есть целиком; это граница, а не поломка. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
101
scripts/ops/faiss_reconcile.py
Executable file
101
scripts/ops/faiss_reconcile.py
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user