feat(sprint2.5): чистка чанков и переиндексация
Чанкер тащил в базу markdown-мусор: навигационные блоки «Вернуться на:»
со списками ссылок, инлайн-ссылки [текст](url) в теле, служебные
пометки _Источник: .../file.md_, лишние пустые строки. Всё это ело
контекст LLM и засоряло правую панель отладки.
- services/text_cleanup: clean_markdown_text — удаляет навигационные
строки, строки-только-ссылки (обычно это меню), служебные _Источник:_,
раскрывает инлайн-ссылки [x](url) → x, сжимает 3+ переносов до 2.
- services/document_processor: process_document теперь возвращает
(id, raw_text, sections, chunks); чистку применяем к заголовкам и
телам секций; чанки короче 20 символов выбрасываем с пересчётом
индексов. Вспомогательная rechunk_raw_text — для переиндексации.
Чтобы переиндексировать без повторной загрузки файла, нужен исходный
текст. Вводим отдельный слой:
- новая таблица SQLite documents (id, name, file_type, raw_text,
created_at, updated_at) + миграция Alembic 7ee7296ccd6d.
- db/models/Document + регистрация в db.models.__init__.
- services/document_service: save/get/list/delete для raw_text.
- routers/documents.upload: сохраняет raw_text в SQLite перед
индексацией в Chroma; delete убирает и из SQLite, и из Chroma.
- Новые эндпоинты POST /documents/{id}/reindex и
POST /documents/reindex-all — берут raw_text из SQLite, пропускают
через rechunk_raw_text, заменяют чанки в Chroma.
Существующие 4 документа были перезалиты вручную (решение: не делать
одноразовый backfill, проще залить заново). Старая Chroma очищена,
новые чанки прошли через чистку — мусор ушёл.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@ import fitz # pymupdf
|
||||
from docx import Document as DocxDocument
|
||||
|
||||
from config import settings
|
||||
from services.text_cleanup import clean_markdown_text
|
||||
|
||||
MIN_CHUNK_TEXT_LENGTH = 20 # чанки короче — выбрасываем (обычно это хвосты после чистки)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -276,25 +279,74 @@ def chunk_sections(
|
||||
# --- Main processor ---
|
||||
|
||||
|
||||
def process_document(file_bytes: bytes, filename: str) -> tuple[str, list[ParsedSection], list[Chunk]]:
|
||||
def _sections_to_markdown(sections: list[ParsedSection]) -> str:
|
||||
"""Собрать секции в markdown-подобный текст — используется как raw_text для PDF/DOCX,
|
||||
чтобы при переиндексации можно было снова пропустить через parse_text."""
|
||||
parts = []
|
||||
for s in sections:
|
||||
if s.heading:
|
||||
parts.append(f"{'#' * max(1, s.heading_level)} {s.heading}")
|
||||
if s.body:
|
||||
parts.append(s.body)
|
||||
return "\n\n".join(parts).strip()
|
||||
|
||||
|
||||
def process_document(
|
||||
file_bytes: bytes, filename: str
|
||||
) -> tuple[str, str, list[ParsedSection], list[Chunk]]:
|
||||
"""Парсит документ, чистит markdown-мусор, режет на чанки.
|
||||
|
||||
Returns: (document_id, raw_text, sections, chunks)
|
||||
raw_text — очищенный текст, пригодный для переиндексации с новыми правилами.
|
||||
"""
|
||||
document_id = str(uuid.uuid4())
|
||||
ext = Path(filename).suffix.lower()
|
||||
|
||||
if ext == ".pdf":
|
||||
sections = parse_pdf(file_bytes)
|
||||
raw_text = _sections_to_markdown(sections)
|
||||
elif ext in (".docx", ".doc"):
|
||||
sections = parse_docx(file_bytes)
|
||||
raw_text = _sections_to_markdown(sections)
|
||||
elif ext == ".md":
|
||||
sections = parse_text(file_bytes, is_markdown=True)
|
||||
raw_text = file_bytes.decode("utf-8", errors="replace")
|
||||
cleaned = clean_markdown_text(raw_text)
|
||||
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True)
|
||||
elif ext == ".txt":
|
||||
sections = parse_text(file_bytes, is_markdown=False)
|
||||
raw_text = file_bytes.decode("utf-8", errors="replace")
|
||||
sections = parse_text(raw_text.encode("utf-8"), is_markdown=False)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file format: {ext}")
|
||||
|
||||
# Страховка — чистим секции, даже если в исходнике уже очищали.
|
||||
for s in sections:
|
||||
s.heading = clean_markdown_text(s.heading) if s.heading else ""
|
||||
s.body = clean_markdown_text(s.body)
|
||||
sections = [s for s in sections if s.heading or s.body.strip()]
|
||||
|
||||
if not sections:
|
||||
logger.warning("No sections found in %s", filename)
|
||||
return document_id, [], []
|
||||
return document_id, raw_text, [], []
|
||||
|
||||
chunks = chunk_sections(sections)
|
||||
logger.info("Processed '%s': %d sections → %d chunks", filename, len(sections), len(chunks))
|
||||
return document_id, sections, chunks
|
||||
# Отбрасываем пустые и совсем мелкие хвосты; переиндексируем.
|
||||
chunks = [c for c in chunks if len(c.text.strip()) >= MIN_CHUNK_TEXT_LENGTH]
|
||||
for i, c in enumerate(chunks):
|
||||
c.chunk_index = i
|
||||
logger.info("Processed '%s': %d sections → %d chunks (cleaned)", filename, len(sections), len(chunks))
|
||||
return document_id, raw_text, sections, chunks
|
||||
|
||||
|
||||
def rechunk_raw_text(raw_text: str) -> list[Chunk]:
|
||||
"""Для переиндексации: режем сохранённый текст с актуальными правилами чистки."""
|
||||
cleaned = clean_markdown_text(raw_text)
|
||||
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True)
|
||||
for s in sections:
|
||||
s.heading = clean_markdown_text(s.heading) if s.heading else ""
|
||||
s.body = clean_markdown_text(s.body)
|
||||
sections = [s for s in sections if s.heading or s.body.strip()]
|
||||
chunks = chunk_sections(sections)
|
||||
chunks = [c for c in chunks if len(c.text.strip()) >= MIN_CHUNK_TEXT_LENGTH]
|
||||
for i, c in enumerate(chunks):
|
||||
c.chunk_index = i
|
||||
return chunks
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""SQLite-слой для raw-текстов документов — для переиндексации."""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def save_document_raw(
|
||||
session: AsyncSession,
|
||||
document_id: str,
|
||||
name: str,
|
||||
file_type: str,
|
||||
raw_text: str,
|
||||
) -> None:
|
||||
"""Сохранить (или перезаписать) исходный текст документа в SQLite."""
|
||||
existing = await session.get(Document, document_id)
|
||||
if existing:
|
||||
existing.name = name
|
||||
existing.file_type = file_type
|
||||
existing.raw_text = raw_text
|
||||
else:
|
||||
session.add(Document(
|
||||
id=document_id,
|
||||
name=name,
|
||||
file_type=file_type,
|
||||
raw_text=raw_text,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_document_raw(session: AsyncSession, document_id: str) -> Document | None:
|
||||
return await session.get(Document, document_id)
|
||||
|
||||
|
||||
async def list_documents_raw(session: AsyncSession) -> list[Document]:
|
||||
stmt = select(Document).order_by(Document.created_at)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def delete_document_raw(session: AsyncSession, document_id: str) -> bool:
|
||||
doc = await session.get(Document, document_id)
|
||||
if doc is None:
|
||||
return False
|
||||
await session.delete(doc)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Чистка wiki-текстов от навигационного и служебного markdown-мусора."""
|
||||
import re
|
||||
|
||||
RETURN_TO_RE = re.compile(r"^\*\*\s*Вернуться на\s*:?\s*\*\*\s*:?$")
|
||||
LINK_ONLY_RE = re.compile(r"^\[[^\]\n]+\]\([^\)\n]+\)$")
|
||||
SOURCE_NOTE_RE = re.compile(r"^_Источник\s*:.*_$")
|
||||
INLINE_LINK_RE = re.compile(r"\[([^\]\n]+)\]\([^\)\n]+\)")
|
||||
MULTI_BLANK_RE = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def clean_markdown_text(text: str) -> str:
|
||||
"""Удаляет навигационный мусор и раскрывает инлайн-ссылки.
|
||||
|
||||
Правила:
|
||||
- Строка `**Вернуться на:**` — выбрасывается.
|
||||
- Строка, целиком состоящая из markdown-ссылки `[x](url)` — выбрасывается (это навигация).
|
||||
- Строка `_Источник: .../file.md_` — выбрасывается.
|
||||
- Инлайн-ссылки в теле `[текст](url)` заменяются на `текст`.
|
||||
- 3+ подряд переносов строк сжимаются до 2.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
lines = text.split("\n")
|
||||
cleaned_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if RETURN_TO_RE.match(stripped):
|
||||
continue
|
||||
if LINK_ONLY_RE.match(stripped):
|
||||
continue
|
||||
if SOURCE_NOTE_RE.match(stripped):
|
||||
continue
|
||||
cleaned_lines.append(line)
|
||||
|
||||
text = "\n".join(cleaned_lines)
|
||||
text = INLINE_LINK_RE.sub(r"\1", text)
|
||||
text = MULTI_BLANK_RE.sub("\n\n", text)
|
||||
return text.strip()
|
||||
Reference in New Issue
Block a user