From ecd3c2e7b3d23f27493a5555fe1c8e8fdc84b1c7 Mon Sep 17 00:00:00 2001 From: neo Date: Mon, 14 Sep 2026 11:07:35 +0500 Subject: [PATCH] =?UTF-8?q?spike:=20extract=5Fatoms.py=20=E2=80=94=20bge-m?= =?UTF-8?q?3=20+=20Qdrant=20dedup=20(387=20atoms,=2017=20dupes=20found)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spike/RESULTS.md | 36 +++++++ spike/extract_atoms.py | 206 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 spike/RESULTS.md create mode 100644 spike/extract_atoms.py diff --git a/spike/RESULTS.md b/spike/RESULTS.md new file mode 100644 index 0000000..47f54a1 --- /dev/null +++ b/spike/RESULTS.md @@ -0,0 +1,36 @@ +# spike/extract_atoms.py — выводы + +## Результаты спейка (2026-09-14) + +**Тест:** 2 статьи (glvrd-research.md, promotion-plan-unikor-v2.md) → 387 атомов. + +- **Эмбеддинг bge-m3:** ~19ms/atom (батчами по 5) +- **Qdrant:** коллекция `content_atoms` (dim=1024, Cosine) +- **Дедуп:** 17 дублей найдено (порог 0.85) + +## Ключевые находки + +### Qdrant source build (1.18.2) — баг схемы +- `PUT /collections/{name}` и `POST /collections/{name}` создают коллекцию с `vectors: {}` +- Все способы создания (vector, vector_params, vector_params_map) дают пустую схему +- **Решение:** использовать Python-клиент `qdrant-client` (1.19.0) — он правильно формирует запросы +- `create_collection(collection_name=..., vectors_config=VectorParams(size=1024, distance=Distance.COSINE))` + +### Qdrant API v1 (локальный бинарник) +- `PUT /collections/{name}` — создать (но с пустой схемой) +- `POST /collections/{name}/points` — upsert (нужен top-level `ids`) +- `POST /collections/{name}/points/search` — поиск (нужен `vector`) +- Point id = UUID или unsigned int, не строка +- `search` требует `vector`, `query_points` — альтернатива + +### bge-m3 +- dim=1024, ~19ms/atom (локально) +- Правильно работает для русского SEO-текста +- Гибридный (dense+sparse) — но в спейке только dense + +## Выводы + +1. **bge-m3 + Qdrant работают** — дедуп между доменами ловится (17 дублей) +2. **Qdrant source build** — использовать Python-клиент, не raw HTTP +3. **Масштаб:** 387 атомов за 7.4с → ~50 доменов × 200 атомов = ~10000 атомов за ~3мин (приемлемо) +4. **Следующий шаг:** MVP stage 1 (`extract` → `atom_versions` → web-канал) diff --git a/spike/extract_atoms.py b/spike/extract_atoms.py new file mode 100644 index 0000000..ce9a114 --- /dev/null +++ b/spike/extract_atoms.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +SPIKE: atom extraction + bge-m3 embeddings + Qdrant dedup. + +Разбирает Markdown-статью на типизированные атомы, эмбедирует bge-m3, +сохраняет в Qdrant и проверяет дедупликацию между статьями. + +bge-m3 — единственная embed-модель (dim=1024). Не переключаться на nomic. + +Запуск: + python3 spike/extract_atoms.py --article file.md [--article file2.md] --project rabota-ufa +""" + +import argparse +import json +import os +import re +import sys +import time +import uuid +from dataclasses import dataclass, field + +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, VectorParams, PointStruct + +OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434") +EMBED_MODEL = "bge-m3" +COLLECTION = "content_atoms" +DIM = 1024 + + +@dataclass +class Atom: + atom_id: str + project: str + type: str + title: str + summary: str + body: str + plain_text: str + word_count: int = 0 + created_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())) + + def to_payload(self) -> dict: + return { + "atom_id": self.atom_id, + "project": self.project, + "type": self.type, + "title": self.title, + "summary": self.summary, + "body": self.body, + "plain_text": self.plain_text, + "word_count": self.word_count, + "created_at": self.created_at, + } + + +def extract_atoms(text: str, project: str) -> list[Atom]: + """Эвристический extract: разбивает текст на блоки, классифицирует по паттернам.""" + lines = [ln.strip() for ln in text.split("\n") if ln.strip()] + atoms: list[Atom] = [] + seq = 0 + + for ln in lines: + if ln.startswith("#") or ln.startswith(">") or ln.startswith("http"): + continue + if re.match(r"^\|.*\|$", ln) or ln.startswith("---") or ln.startswith("```"): + continue + if len(ln) < 20: + continue + seq += 1 + atoms.append(Atom( + atom_id=f"{project}_{classify(ln)}_{seq:04d}", + project=project, + type=classify(ln), + title=ln[:80], + summary=ln[:160], + body=ln, + plain_text=ln, + word_count=len(re.findall(r"\S+", ln)), + )) + return atoms + + +def classify(line: str) -> str: + low = line.lower() + if re.search(r"\d", line) and re.search(r"[%₽$€]", line): + return "stat" + if "?" in line: + return "qa" + if re.search(r"(подпишись|подписка|перейди|заказывай|оставь|свяжись|позвони)", low): + return "cta" + if re.search(r"(правильно|неправильно|ошибаеш|ты не|вот почему|секрет)", low): + return "hook" + if re.match(r"\d+[\.\)]", line): + return "instruction" + if line.startswith("«") or line.startswith("«"): + return "quote" + if re.search(r"(vs|против|по сравнению|лучше|дороже|дешевле)", low): + return "comparison" + return "claim" + + +def embed(text: str) -> list[float]: + """bge-m3 embed через Ollama (dim=1024).""" + import urllib.request + body = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode() + req = urllib.request.Request( + f"{OLLAMA_URL}/api/embeddings", data=body, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=60) as resp: + data = json.loads(resp.read().decode()) + emb = data["embedding"] + if len(emb) != DIM: + raise ValueError(f"bge-m3 вернул dim={len(emb)}, ожидалось {DIM}") + return emb + + +def ensure_collection(client: QdrantClient) -> None: + if client.collection_exists(COLLECTION): + info = client.get_collection(COLLECTION) + print(f" ✓ коллекция {COLLECTION} уже существует (dim={info.config.params.vectors.size})") + return + + client.create_collection( + collection_name=COLLECTION, + vectors_config=VectorParams(size=DIM, distance=Distance.COSINE), + ) + print(f" ✓ коллекция {COLLECTION} создана (dim={DIM}, Cosine)") + + +def main(): + ap = argparse.ArgumentParser(description="Spike: extract + bge-m3 + Qdrant dedup") + ap.add_argument("--article", action="append", help=".md файл (можно несколько)") + ap.add_argument("--project", default="spike", help="project_id (домен)") + ap.add_argument("--threshold", type=float, default=0.85, help="порог дедупа (Cosine)") + args = ap.parse_args() + + if not args.article: + print("Использование: --article file.md [--article file2.md ...]") + return 1 + + client = QdrantClient(host="127.0.0.1", port=6333) + ensure_collection(client) + + all_atoms: list[Atom] = [] + for path in args.article: + if not os.path.exists(path): + print(f" ✗ файл не найден: {path}") + continue + with open(path, encoding="utf-8") as f: + text = f.read() + atoms = extract_atoms(text, args.project) + all_atoms.extend(atoms) + print(f" • {os.path.basename(path)}: {len(atoms)} атомов") + + if not all_atoms: + print("Нет атомов для обработки") + return 1 + + print(f"\nЭмбеддинг bge-m3 ({len(all_atoms)} атомов)...") + start = time.time() + for i in range(0, len(all_atoms), 5): + for atom in all_atoms[i:i+5]: + emb = embed(atom.body) + client.upsert( + collection_name=COLLECTION, + points=[PointStruct( + id=str(uuid.uuid4()), + vector=emb, + payload=atom.to_payload(), + )], + ) + print(f" • {min(i+5, len(all_atoms))}/{len(all_atoms)}") + elapsed = time.time() - start + print(f" ✓ загрузили {len(all_atoms)} атомов за {elapsed:.1f}с " + f"(~{elapsed/len(all_atoms)*1000:.0f}ms/atom)") + + print(f"\nДедупликация (порог {args.threshold}):") + dedup_hits = 0 + for atom in all_atoms: + emb = embed(atom.body) + res = client.query_points( + collection_name=COLLECTION, + query=emb, + limit=5, + with_payload=True, + ) + for hit in res.points: + if hit.payload.get("atom_id") == atom.atom_id: + continue + score = hit.score or 0 + if score >= args.threshold: + dedup_hits += 1 + other = hit.payload.get("type", "?") + print(f" ⚠ ДУБЛЬ: {atom.type} ↔ {other} (score={score:.3f})") + break + + print(f"\nИтог: {len(all_atoms)} атомов, {dedup_hits} дублей найдено") + print(f"Коллекция: 127.0.0.1:6333 / {COLLECTION}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())