Знаниеви атоми: автоматично разбиване на текст на самостоятелни единици
Среща, статия или имейл съдържат десетки отделни мисли, смесени в един поток. Знаниеви атом е една такава мисъл — решение, факт, метод — записана така, че да се разбира сама. Тук строим малък локален конвейер, който ги изважда автоматично, пази ги като обикновени файлове с метаданни и не допуска дубликати. Моделът върви на твоята машина, затова текстовете не напускат мрежата ти.
/api/embeddings с текущия /api/embed, а търсенето на скоби в отговора на модела — със структуриран изход (format със схема). Поправихме грешките в стария код: новите атоми никога не влизаха в индекса, името на файла получаваше двоен префикс, а флагът „прегледан“ беше обърнат. Добавихме: стъпка за сверяване с изходния текст (оценка за вярност), папка за атоми, чакащи човек, и честна бележка, че праговете 0,97 и 0,92 са отправна точка, не измерен факт.01Какво ще научиш
- Какво е знаниеви атом и по какво се различава от откъс от текст.
- Как да накараш локален модел да връща строго структуриран резултат (JSON по схема), вместо да търсиш скоби в свободен текст.
- Как bge-m3 и Chroma засичат близки по смисъл атоми и предлагат връзки между тях.
- Защо оценката от езиков модел е само триаж и къде трябва да влезе човек.
- Как да избереш и сверяваш прагове за сходство върху свои данни.
02Преди да започнеш
- Машина от класа NVIDIA GB10 (например ASUS Ascent GX10 или DGX Spark) или друг компютър с Python 3.10+ и Ollama.
- Ollama, инсталиран и пуснат (
ollama --version). Запомни: по подразбиране слуша само на127.0.0.1— така е правилно. ffmpeg, ако ще транскрибираш аудио (на Ubuntu:sudo apt install ffmpeg). Без аудио стъпката не ти трябва.- Място на диска:
llama3.1:8bе около 4,9 GB,bge-m3около 1,2 GB (по ollama.com), плюс теглата на Whisper при аудио. - Текст, който имаш право да обработваш. Запис на среща съдържа гласове и лични данни: нужни са основание и информиране на участниците. Урокът е технически, не е правна консултация.
03Стъпки
-
Идеята: какво е атом
Атомът е едно твърдение, което може да се цитира отделно и пак да има смисъл. Не: „Обсъдихме много теми.“ Да: „На прегледа на спринта екипът реши седмичният отчет да се праща в понеделник, защото повечето отговори от клиенти идват през почивните дни.“ Втората версия казва кой, кога и какво. Ориентир: 50–250 думи, но смисълът е важен, не броят.
Поле За какво служи idстабилен идентификатор (ATM-0001…) за връзки title,typeкратко заглавие и вид (решение, факт, метод…) linksидентификатори на близки атоми, предложени от сходството qualityгруб триаж от езиковия модел; не е истина reviewedдали човек го е прегледал -
Конвейерът: пет стъпки
Текст → (по избор) транскрипция → разбиване на атоми → сравнение с вече познатите → предложения за връзки → оценка и запис. Всяка стъпка е отделна функция, затова можеш да я тестваш и подменяш поотделно. Защо в такъв ред: дубликатите се отсяват преди оценката, за да не плащаш време на модела за атом, който и без това ще отпадне.
-
Подготви средата
Виртуална среда и два модела:
bge-m3дава вектори (1024 измерения, над 100 езика, до 8192 токена вход, лиценз MIT — по картата му в Hugging Face), аllama3.1:8bе примерен езиков модел. Можеш да подадеш друг през променливатаATOM_MODEL. ⚠️ Качеството на 8B модел върху български не сме мерили.bash · на машинатаmkdir -p ~/atoms-lab && cd ~/atoms-lab python3 -m venv .venv && . .venv/bin/activate pip install chromadb httpx pyyaml openai-whisper ollama pull bge-m3 ollama pull llama3.1:8b ollama listℹ️Незадължително: аудиоПакетътopenai-whisperизискваffmpeg. Името на моделаlarge-v3е валидно в библиотеката;turboе по-бърза негова оптимизация, но не превежда. На GB10 е нужна версия на PyTorch с поддръжка на CUDA за Arm — ⚠️ не сме проверявали как се инсталира; без нея транскрипцията върви на процесора и е бавна. -
Кодът: един файл
Целият конвейер е в един файл. Забележи четири решения. (1)
formatсъс схема принуждава Ollama да върне валиден JSON — не търсим скоби в текста. (2)options.num_ctxе зададено явно, защото дълъг вход иначе може да бъде отрязан; текстът се реже на части от по 900 думи. (3) В косинусово пространство Chroma връща разстояние = 1 − сходство, затова сходството е1 − d. (4) Атом отива в индекса само ако е приет; чакащите човек стоят в_review/. Коментарите и подканите в кода са на английски — моделите ги следват надеждно.python · atoms.py# atoms.py - text -> self-contained knowledge atoms (one file, five stages) import hashlib, json, os, re from datetime import date from pathlib import Path import chromadb, httpx, yaml OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434") LLM = os.environ.get("ATOM_MODEL", "llama3.1:8b") # any local instruct model EMB = "bge-m3" GRAPH = Path("atoms") REVIEW = GRAPH / "_review" INDEX = GRAPH / "_index" SKIP_AT, REVIEW_AT, PASS_SCORE = 0.97, 0.92, 6 # starting points - calibrate! ATOM_TYPES = ["concept", "decision", "fact", "method", "tool", "event", "insight", "question"] # ---------- stage 1: audio -> text (skip if you already have text) ---------- _whisper = None def transcribe(path, language="bg"): global _whisper if _whisper is None: import whisper # pip install openai-whisper; needs ffmpeg _whisper = whisper.load_model("large-v3") return _whisper.transcribe(path, language=language)["text"] # ---------- stage 2: text -> candidate atoms (structured output) ---------- ATOM_SCHEMA = {"type": "object", "required": ["atoms"], "properties": {"atoms": { "type": "array", "items": {"type": "object", "required": ["title", "content", "type"], "properties": {"title": {"type": "string"}, "content": {"type": "string"}, "type": {"type": "string", "enum": ATOM_TYPES}, "tags": {"type": "array", "items": {"type": "string"}}}}}}} PROMPT = """You split a text into knowledge atoms. Rules: one atom = one statement, decision or fact that still makes sense when read alone; 50-250 words; keep the language of the text; do not invent anything that is not in the text; write who/when/what explicitly instead of 'we' or 'it'. TEXT: {text}""" def parts(text, words=900): w = text.split() for i in range(0, len(w), words): yield " ".join(w[i:i + words]) def extract(text): found = [] for part in parts(text): r = httpx.post(f"{OLLAMA}/api/generate", timeout=300, json={ "model": LLM, "prompt": PROMPT.format(text=part), "stream": False, "format": ATOM_SCHEMA, "options": {"temperature": 0.1, "num_ctx": 8192}}) r.raise_for_status() found += json.loads(r.json()["response"]).get("atoms", []) return found # ---------- stage 3 and 4: duplicates and link candidates ---------- client = chromadb.PersistentClient(path=str(INDEX)) col = client.get_or_create_collection("atoms", configuration={"hnsw": {"space": "cosine"}}) def embed(text): r = httpx.post(f"{OLLAMA}/api/embed", json={"model": EMB, "input": text}, timeout=120) r.raise_for_status() return r.json()["embeddings"][0] def nearest(vec, k=3): n = col.count() if n == 0: return [] res = col.query(query_embeddings=[vec], n_results=min(k, n), include=["metadatas", "distances"]) # in a cosine space Chroma returns distance = 1 - cosine similarity return [{"id": i, "title": m["title"], "similarity": round(1 - d, 4)} for i, m, d in zip(res["ids"][0], res["metadatas"][0], res["distances"][0])] def verdict(top_similarity): if top_similarity >= SKIP_AT: return "skip" # practically the same atom if top_similarity >= REVIEW_AT: return "review" # similar: a person decides return "new" # ---------- stage 5: quality triage ---------- SCORE_SCHEMA = {"type": "object", "properties": {k: {"type": "integer", "minimum": 0, "maximum": 2} for k in ["atomicity", "completeness", "specificity", "faithfulness", "linkability"]}, "required": ["atomicity", "completeness", "specificity", "faithfulness", "linkability"]} def score(atom, source_text): prompt = ("Score the atom from 0 to 2 on: atomicity (one statement), completeness (understandable alone), " "specificity (concrete), faithfulness (supported by the source text), linkability.\n" f"SOURCE:\n{source_text[:3000]}\n\nATOM:\n{atom['title']}\n{atom['content']}") r = httpx.post(f"{OLLAMA}/api/generate", timeout=120, json={ "model": LLM, "prompt": prompt, "stream": False, "format": SCORE_SCHEMA, "options": {"temperature": 0}}) r.raise_for_status() s = json.loads(r.json()["response"]) return sum(s.values()), s # ---------- writing atoms ---------- def next_id(): nums = [int(m.group(1)) for p in GRAPH.rglob("ATM-*.md") if (m := re.match(r"ATM-(\d+)", p.name))] return f"ATM-{max(nums, default=0) + 1:04d}" def save(atom, links, total, scores, source, needs_review): aid = next_id() folder = REVIEW if needs_review else GRAPH folder.mkdir(parents=True, exist_ok=True) meta = {"id": aid, "title": atom["title"], "type": atom["type"], "created": date.today().isoformat(), "source": source, "links": links, "tags": atom.get("tags", []), "quality": total, "quality_scores": scores, "auto_generated": True, "reviewed": False} text = "---\n" + yaml.safe_dump(meta, allow_unicode=True, sort_keys=False) + "---\n\n" text += f"# {atom['title']}\n\n{atom['content']}\n" (folder / f"{aid}.md").write_text(text, encoding="utf-8") if not needs_review: index(aid, atom) return aid def index(aid, atom): body = f"{atom['title']}\n\n{atom['content']}" col.upsert(ids=[aid], embeddings=[embed(body)], documents=[body], metadatas=[{"title": atom["title"]}]) # ---------- the whole run ---------- def process(text, source): stats = {"saved": 0, "skipped": 0, "to_review": 0} for atom in extract(text): vec = embed(f"{atom['title']}\n\n{atom['content']}") near = nearest(vec) v = verdict(near[0]["similarity"] if near else 0.0) if v == "skip": stats["skipped"] += 1 continue total, scores = score(atom, text) links = [n["id"] for n in near if 0.35 <= n["similarity"] < REVIEW_AT] needs_review = v == "review" or total < PASS_SCORE save(atom, links, total, scores, source, needs_review) stats["to_review" if needs_review else "saved"] += 1 return stats if __name__ == "__main__": import sys path = sys.argv[1] text = transcribe(path) if path.lower().endswith((".mp3", ".wav", ".m4a", ".flac")) else Path(path).read_text(encoding="utf-8") print(process(text, source=Path(path).name))Пусни само проверка на синтаксиса, без модели:
python -m py_compile atoms.py. -
Прагове: откъде идват и как да ги сверяваш
Кодът пази два прага: 0,97 — „практически същото, пропусни“ и 0,92 — „много близко, реши човек“. Това са отправни стойности, а не измерен факт: сходството зависи от модела, езика и дължината на атомите. Как да ги настроиш: събери 30–50 двойки атоми от свои данни, раздели ги на „същото“, „близко, но различно“ и „различно“, отпечатай сходствата им и сложи праговете между групите. Ако често губиш различни атоми, вдигни прага; ако дубликати минават, свали го.
-
Пробен запуск
Запиши измислен текст във файл и го пусни. Очакваш няколко атома в
atoms/илиatoms/_review/и ред със статистика.text · note.md (измислен пример)# note.md - an invented example text (the lesson's test input) In the sprint review the team decided to move the weekly report from Friday to Monday, because most customer replies arrive over the weekend. Maria will own the report template. Open question: should the report include the support backlog or only closed tickets? The team also agreed to try a shared checklist for onboarding new colleagues; the first version is due in two weeks.bashpython atoms.py note.md ls atoms atoms/_review sed -n 1,40p atoms/ATM-0001.mdПусни същия файл втори път: атомите трябва да отпаднат като „skipped“ — това е проверката, че дубликатите работят. Прегледай поне няколко ръчно: излязъл ли е „Мария отговаря за шаблона“ като отделен атом и с името в текста, или като „тя“?
-
Преглед от човек
Оценката 0–10 от модел е триаж, не присъда: малък модел не усеща добре собствените си грешки. Затова нищо не се смята за „истина“, докато човек не го отвори. Работен ред: прегледай папката
_review/; за приет атом сложиreviewed: true, премести го вatoms/и извикайindex(id, atom)(или пусни пак индексирането); за отхвърлен — архивирай го, не го трий. Веднъж седмично извади случайни 10 приети атома и ги провери срещу изходния текст: така виждаш реалния процент грешки.⚖️Лични данни и правилаАко текстът съдържа лични данни (имена, гласове, договори), прилагат се правилата за защита на личните данни (GDPR) и договорните задължения. Атомите са производни на текста и носят същия риск. Урокът е технически и не е правна консултация; за реален случай поискай юрист.
04Проверка
python -m py_compile atoms.pyминава без грешка.ollama listпоказваbge-m3и избрания езиков модел.- Пробният текст дава поне един атом с
id,typeиlinksвъв файла. - Вторият запуск със същия текст не добавя нови атоми.
- Знаеш как се сменят праговете и защо са отправни, а не „правилни“.
- Имаш процес за преглед на атомите от
_review/.
Тест
1. Кое прави текст „атом“?
2. В косинусово пространство Chroma връща разстояние 0,05. Колко е сходството?
3. Защо оценката от езиков модел е само триаж?
4. Праговете 0,97 и 0,92 в урока са:
05Какво следва
Следващата стъпка е да закачиш индекса към търсене: тогава връзките между атомите стават полезни. Подобна идея за търсене по смисъл и по думи е в урока за хибридно търсене.
06Източници
- BAAI/bge-m3: карта на модела — 1024 измерения, до 8192 токена, над 100 езика, MIT.
- bge-m3 в Ollama 🔒 локално — размер около 1,2 GB; API: /api/embed.
- Ollama API: /api/generate —
format(JSON или схема),options,num_ctx. - llama3.1 🔒 локално — размери на модела (8B около 4,9 GB).
- Chroma: настройка на колекции —
configurationсhnsw.space, косинусово разстояние = 1 − сходство. - openai/whisper — инсталация,
ffmpeg, модели, лиценз MIT.