[{"data":1,"prerenderedAt":1188},["ShallowReactive",2],{"article-holographic-memory-potato-vps":3},{"id":4,"title":5,"body":6,"date":1178,"description":1179,"extension":1180,"meta":1181,"navigation":327,"path":1182,"readingTime":1183,"seo":1184,"stem":1185,"tags":1186,"__hash__":1187},"articles\u002Fholographic-memory-potato-vps.md","Голографическая память для AI-агента на Potato VPS",{"type":7,"value":8,"toc":1149},"minimark",[9,13,17,20,28,33,36,116,127,130,134,144,147,151,156,159,163,166,170,173,177,180,184,187,280,283,286,297,301,465,472,476,515,518,522,525,597,603,609,612,616,619,702,709,738,741,745,748,773,776,780,783,812,815,819,822,876,879,886,890,893,963,966,970,974,977,983,987,994,1003,1006,1063,1067,1070,1074,1079,1083,1089,1114,1118,1121,1124,1127,1130,1133,1136,1145],[10,11,5],"h1",{"id":12},"голографическая-память-для-ai-агента-на-potato-vps",[14,15,16],"p",{},"У меня есть так называемый Potato VPS — дешёвый сервер с минимумом оперативки. На нём крутится AI-агент — Hermes — который должен помнить контекст между сессиями: факты о пользователях, конфигурации проектов, предпочтения, решения. Не просто «записать в файл и grep'нуть», а смысловой поиск с пониманием перефразировок и мультиязычности.",[14,18,19],{},"Проблема: ChromaDB жрёт 400 МБ просто на старте. Pinecone — это SaaS, а я хочу локально. FAISS — без индексации по ключевым словам. Мне нужен гибрид: полнотекстовый поиск + векторная семантика + композиционная алгебра. И всё это на скромном сервере, включая саму модель эмбеддингов.",[14,21,22,23,27],{},"Решение — плагин ",[24,25,26],"code",{},"holographic-memory",", четыре стратегии поиска в одном SQLite-файле. Вот как это устроено и почему.",[29,30,32],"h2",{"id":31},"архитектура-четыре-стратегии-один-запрос","Архитектура: четыре стратегии, один запрос",[14,34,35],{},"Гибридный скоринг — не «векторный поиск с fallback на FTS», а четыре независимых канала, результаты которых складываются с весами:",[37,38,39,58],"table",{},[40,41,42],"thead",{},[43,44,45,49,52,55],"tr",{},[46,47,48],"th",{},"Стратегия",[46,50,51],{},"Вес",[46,53,54],{},"Что делает",[46,56,57],{},"Технология",[59,60,61,76,90,103],"tbody",{},[43,62,63,67,70,73],{},[64,65,66],"td",{},"FTS5",[64,68,69],{},"0.3",[64,71,72],{},"Кандидаты по ключевым словам (BM25)",[64,74,75],{},"SQLite FTS5",[43,77,78,81,84,87],{},[64,79,80],{},"Jaccard",[64,82,83],{},"0.2",[64,85,86],{},"Пересечение токенов",[64,88,89],{},"Python sets",[43,91,92,95,97,100],{},[64,93,94],{},"HRR",[64,96,83],{},[64,98,99],{},"Композиционная алгебра (probe\u002Frelated\u002Freason)",[64,101,102],{},"SHA-256 phase vectors, 1024d",[43,104,105,108,110,113],{},[64,106,107],{},"Semantic",[64,109,69],{},[64,111,112],{},"Смысловое сходство",[64,114,115],{},"fastembed MiniLM-L12-v2, 384d",[14,117,118,119,122,123,126],{},"Финальный скор: ",[24,120,121],{},"relevance × trust_score × temporal_decay",", где ",[24,124,125],{},"relevance = fts×0.3 + jaccard×0.2 + hrr×0.2 + semantic×0.3",".",[14,128,129],{},"Зачем четыре, если можно одним векторным поиском обойтись? Потому что векторный поиск плохо работает на коротких точных запросах («порядок деплоя nginx»), а FTS5 не понимает перефразировок («как выкатить nginx на прод»). HRR даёт алгебраические операции — probe по сущности, связь между фактами, мульти-сущностные JOIN'ы. Jaccard — дешёвый фильтр мусора.",[29,131,133],{"id":132},"запуск-поиска-пайплайн","Запуск поиска: пайплайн",[135,136,141],"pre",{"className":137,"code":139,"language":140},[138],"language-text","1. FTS5 MATCH → limit×3 кандидатов (AND-семантика: все термы обязательны)\n2. Если FTS5 пустой + semantic доступен → _semantic_candidates() (полный cosine scan)\n3. Предвычисление эмбеддинга запроса (~46 мс)\n4. Реранжирование: relevance = fts×0.3 + jaccard×0.2 + hrr×0.2 + semantic×0.3\n5. Финал: score = relevance × trust × temporal_decay\n","text",[24,142,139],{"__ignoreMap":143},"",[14,145,146],{},"Ключевой момент: если FTS5 возвращает 0 (запрос на русском, а факты записаны по-английски, или перефразировка), пайплайн автоматически переключается на чистый семантический поиск. Semantic — не роскошь, а load-bearing компонент для мультиязычности.",[29,148,150],{"id":149},"почему-не-готовые-решения","Почему не готовые решения",[152,153,155],"h3",{"id":154},"chromadb","ChromaDB",[14,157,158],{},"Два процесса (Chroma + Hermes), ~800 МБ до того, как агент хоть что-то запомнил. На Potato VPS — это половина RAM. Плюс Chroma тянет за собой HNSW, который строит индекс в памяти. Для фактов (сотни, может тысячи записей) — это стрельба из пушки по воробьям.",[152,160,162],{"id":161},"pinecone","Pinecone",[14,164,165],{},"SaaS. Требует API-ключ, интернет, и доверие к третьей стороне с данными агента. Для хобби-проекта на дешёвом VPS — overkill и vendor lock-in.",[152,167,169],{"id":168},"faiss","FAISS",[14,171,172],{},"Отличная библиотека для векторного поиска, но без полнотекстовой индексации. Придётся поверх городить FTS5 отдельно. А если уж использовать SQLite и для текста, и для векторов — зачем FAISS?",[152,174,176],{"id":175},"своё-решение","Своё решение",[14,178,179],{},"SQLite + FTS5 + WAL — это уже есть в Python stdlib (sqlite3). Добавляем fastembed для эмбеддингов и numpy для HRR-алгебры. Один файл базы, один процесс, zero infrastructure.",[29,181,183],{"id":182},"выбор-модели-mpnet-vs-minilm","Выбор модели: mpnet vs MiniLM",[14,185,186],{},"Я тестировал две модели multilingual fastembed:",[37,188,189,201],{},[40,190,191],{},[43,192,193,195,198],{},[46,194],{},[46,196,197],{},"mpnet-base-v2",[46,199,200],{},"MiniLM-L12-v2",[59,202,203,214,225,236,247,258,269],{},[43,204,205,208,211],{},[64,206,207],{},"Размерность",[64,209,210],{},"768",[64,212,213],{},"384",[43,215,216,219,222],{},[64,217,218],{},"RSS (загружена)",[64,220,221],{},"1440 МБ",[64,223,224],{},"680 МБ",[43,226,227,230,233],{},[64,228,229],{},"RSS (residual после выгрузки)",[64,231,232],{},"693 МБ",[64,234,235],{},"481 МБ",[43,237,238,241,244],{},[64,239,240],{},"Время эмбеддинга",[64,242,243],{},"65 мс",[64,245,246],{},"46 мс",[43,248,249,252,255],{},[64,250,251],{},"Перезагрузка после выгрузки",[64,253,254],{},"20–25 с",[64,256,257],{},"1.33 с",[43,259,260,263,266],{},[64,261,262],{},"sim(\"компактный формат\" ↔ \"лаконичные сообщения\")",[64,264,265],{},"0.625",[64,267,268],{},"0.511",[43,270,271,274,277],{},[64,272,273],{},"sim(\"компактный формат\" ↔ \"погода для прогулки\")",[64,275,276],{},"0.225",[64,278,279],{},"0.008",[14,281,282],{},"MiniLM: 680 МБ RSS, 481 МБ residual. mpnet: 1440 МБ RSS, 693 МБ residual. На Potato VPS mpnet не влезает — после загрузки модели + Hermes + системы остаётся ~300 МБ, и OOM-killer стучится.",[14,284,285],{},"MiniLM даёт 0.511 для семантически схожих фраз и 0.008 для несвязанных — separation достаточная. Не идеальная (mpnet лучше на ~20%), но работоспособная.",[14,287,288,292,293,296],{},[289,290,291],"strong",{},"Residual"," — это ONNX Runtime, который не освобождает пуллы памяти даже после ",[24,294,295],{},"del model + gc.collect()",". 481 МБ — плата за один вызов fastembed за жизнь процесса. Поэтому стратегия: lazy-load при первом запросе, держать в памяти до shutdown.",[29,298,300],{"id":299},"lazy-loading-и-lifecycle-модели","Lazy-loading и lifecycle модели",[135,302,306],{"className":303,"code":304,"language":305,"meta":143,"style":143},"language-python shiki shiki-themes github-dark","_model = None\n_available: Optional[bool] = None\n\ndef is_available() -> bool:\n    \"\"\"Проверка без загрузки модели.\"\"\"\n    if _available is not None:\n        return _available\n    try:\n        import fastembed\n        _available = True\n    except ImportError:\n        _available = False\n    return _available\n\ndef _get_model():\n    \"\"\"Lazy-load при первом вызове embed_text().\"\"\"\n    global _model\n    if _model is None:\n        from fastembed import TextEmbedding\n        _model = TextEmbedding(\"sentence-transformers\u002Fparaphrase-multilingual-MiniLM-L12-v2\")\n    return _model\n\ndef embed_text(text: str) -> np.ndarray:\n    model = _get_model()\n    vec = list(model.embed([text]))[0]\n    return vec \u002F np.linalg.norm(vec)  # normalize → unit vector\n","python",[24,307,308,316,322,329,335,341,347,353,359,365,371,377,383,389,394,400,406,412,418,424,430,436,441,447,453,459],{"__ignoreMap":143},[309,310,313],"span",{"class":311,"line":312},"line",1,[309,314,315],{},"_model = None\n",[309,317,319],{"class":311,"line":318},2,[309,320,321],{},"_available: Optional[bool] = None\n",[309,323,325],{"class":311,"line":324},3,[309,326,328],{"emptyLinePlaceholder":327},true,"\n",[309,330,332],{"class":311,"line":331},4,[309,333,334],{},"def is_available() -> bool:\n",[309,336,338],{"class":311,"line":337},5,[309,339,340],{},"    \"\"\"Проверка без загрузки модели.\"\"\"\n",[309,342,344],{"class":311,"line":343},6,[309,345,346],{},"    if _available is not None:\n",[309,348,350],{"class":311,"line":349},7,[309,351,352],{},"        return _available\n",[309,354,356],{"class":311,"line":355},8,[309,357,358],{},"    try:\n",[309,360,362],{"class":311,"line":361},9,[309,363,364],{},"        import fastembed\n",[309,366,368],{"class":311,"line":367},10,[309,369,370],{},"        _available = True\n",[309,372,374],{"class":311,"line":373},11,[309,375,376],{},"    except ImportError:\n",[309,378,380],{"class":311,"line":379},12,[309,381,382],{},"        _available = False\n",[309,384,386],{"class":311,"line":385},13,[309,387,388],{},"    return _available\n",[309,390,392],{"class":311,"line":391},14,[309,393,328],{"emptyLinePlaceholder":327},[309,395,397],{"class":311,"line":396},15,[309,398,399],{},"def _get_model():\n",[309,401,403],{"class":311,"line":402},16,[309,404,405],{},"    \"\"\"Lazy-load при первом вызове embed_text().\"\"\"\n",[309,407,409],{"class":311,"line":408},17,[309,410,411],{},"    global _model\n",[309,413,415],{"class":311,"line":414},18,[309,416,417],{},"    if _model is None:\n",[309,419,421],{"class":311,"line":420},19,[309,422,423],{},"        from fastembed import TextEmbedding\n",[309,425,427],{"class":311,"line":426},20,[309,428,429],{},"        _model = TextEmbedding(\"sentence-transformers\u002Fparaphrase-multilingual-MiniLM-L12-v2\")\n",[309,431,433],{"class":311,"line":432},21,[309,434,435],{},"    return _model\n",[309,437,439],{"class":311,"line":438},22,[309,440,328],{"emptyLinePlaceholder":327},[309,442,444],{"class":311,"line":443},23,[309,445,446],{},"def embed_text(text: str) -> np.ndarray:\n",[309,448,450],{"class":311,"line":449},24,[309,451,452],{},"    model = _get_model()\n",[309,454,456],{"class":311,"line":455},25,[309,457,458],{},"    vec = list(model.embed([text]))[0]\n",[309,460,462],{"class":311,"line":461},26,[309,463,464],{},"    return vec \u002F np.linalg.norm(vec)  # normalize → unit vector\n",[14,466,467,468,471],{},"Первый вызов ",[24,469,470],{},"embed_text()"," — ~1.3 с (загрузка ONNX-модели). Все последующие — ~46 мс. Модель живёт в памяти до shutdown.",[152,473,475],{"id":474},"выгрузка-при-shutdown","Выгрузка при shutdown",[135,477,479],{"className":303,"code":478,"language":305,"meta":143,"style":143},"# В __init__.py плагина, на shutdown hook:\nimport embedder, gc\n\ndef on_shutdown():\n    embedder._model = None\n    gc.collect()\n    # Освобождает ~200 МБ (веса модели), но ONNX residual (481 МБ) остаётся\n",[24,480,481,486,491,495,500,505,510],{"__ignoreMap":143},[309,482,483],{"class":311,"line":312},[309,484,485],{},"# В __init__.py плагина, на shutdown hook:\n",[309,487,488],{"class":311,"line":318},[309,489,490],{},"import embedder, gc\n",[309,492,493],{"class":311,"line":324},[309,494,328],{"emptyLinePlaceholder":327},[309,496,497],{"class":311,"line":331},[309,498,499],{},"def on_shutdown():\n",[309,501,502],{"class":311,"line":337},[309,503,504],{},"    embedder._model = None\n",[309,506,507],{"class":311,"line":343},[309,508,509],{},"    gc.collect()\n",[309,511,512],{"class":311,"line":349},[309,513,514],{},"    # Освобождает ~200 МБ (веса модели), но ONNX residual (481 МБ) остаётся\n",[14,516,517],{},"На практике: после shutdown RSS падает с ~1.1 ГБ до ~620 МБ. ONNX Runtime не отдаёт память — это известная особенность. 620 МБ — рабочий baseline для Potato VPS.",[29,519,521],{"id":520},"хранение-два-вектора-на-факт","Хранение: два вектора на факт",[14,523,524],{},"Каждый факт в SQLite хранит два вектора:",[135,526,530],{"className":527,"code":528,"language":529,"meta":143,"style":143},"language-sql shiki shiki-themes github-dark","CREATE TABLE facts (\n    fact_id INTEGER PRIMARY KEY,\n    content TEXT NOT NULL,\n    category TEXT DEFAULT 'general',\n    tags TEXT DEFAULT '',\n    trust_score REAL DEFAULT 0.5,\n    retrieval_count INTEGER DEFAULT 0,\n    helpful_count INTEGER DEFAULT 0,\n    hrr_vector BLOB,        -- 1024 × float64 = 8192 bytes\n    semantic_vector BLOB,   -- 384 × float32 = 1536 bytes\n    created_at TEXT,\n    updated_at TEXT\n);\n","sql",[24,531,532,537,542,547,552,557,562,567,572,577,582,587,592],{"__ignoreMap":143},[309,533,534],{"class":311,"line":312},[309,535,536],{},"CREATE TABLE facts (\n",[309,538,539],{"class":311,"line":318},[309,540,541],{},"    fact_id INTEGER PRIMARY KEY,\n",[309,543,544],{"class":311,"line":324},[309,545,546],{},"    content TEXT NOT NULL,\n",[309,548,549],{"class":311,"line":331},[309,550,551],{},"    category TEXT DEFAULT 'general',\n",[309,553,554],{"class":311,"line":337},[309,555,556],{},"    tags TEXT DEFAULT '',\n",[309,558,559],{"class":311,"line":343},[309,560,561],{},"    trust_score REAL DEFAULT 0.5,\n",[309,563,564],{"class":311,"line":349},[309,565,566],{},"    retrieval_count INTEGER DEFAULT 0,\n",[309,568,569],{"class":311,"line":355},[309,570,571],{},"    helpful_count INTEGER DEFAULT 0,\n",[309,573,574],{"class":311,"line":361},[309,575,576],{},"    hrr_vector BLOB,        -- 1024 × float64 = 8192 bytes\n",[309,578,579],{"class":311,"line":367},[309,580,581],{},"    semantic_vector BLOB,   -- 384 × float32 = 1536 bytes\n",[309,583,584],{"class":311,"line":373},[309,585,586],{},"    created_at TEXT,\n",[309,588,589],{"class":311,"line":379},[309,590,591],{},"    updated_at TEXT\n",[309,593,594],{"class":311,"line":385},[309,595,596],{},");\n",[14,598,599,602],{},[289,600,601],{},"HRR-вектор"," (8 КБ на факт) — SHA-256 phase encoding. Токены контента → бандл атомов → bind с ROLE_CONTENT и ROLE_ENTITY. Используется для алгебраических операций: probe («все факты про X»), related («что связано с X»), reason («что общего у X, Y, Z»). Работает на numpy, без модели.",[14,604,605,608],{},[289,606,607],{},"Semantic-вектор"," (1.5 КБ на факт) — выход fastembed. Нормализованный unit vector для cosine similarity. Используется в гибридном скоринге.",[14,610,611],{},"Итого: ~9.7 КБ на факт. Для 1000 фактов — ~10 МБ. Пустяк.",[29,613,615],{"id":614},"hrr-композиционная-алгебра-без-нейросетей","HRR: композиционная алгебра без нейросетей",[14,617,618],{},"HRR (Holographic Reduced Representations) — это способ кодировать структуру в фиксированный вектор. Вместо обучения — SHA-256 хеши от токенов, преобразованные в phase vectors.",[135,620,622],{"className":303,"code":621,"language":305,"meta":143,"style":143},"def encode_atom(token: str, dim: int = 1024) -> np.ndarray:\n    \"\"\"Токен → единичный вектор в phase space.\"\"\"\n    h = hashlib.sha256(token.encode()).digest()\n    rng = np.frombuffer(h, dtype=np.uint8).astype(np.float64)\n    # Фазовый код: cos + j*sin → unit vector в комплексном пространстве\n    phases = rng[:dim] \u002F 255.0 * 2 * np.pi\n    return np.cos(phases) + 1j * np.sin(phases)\n\ndef bind(a, b):\n    \"\"\"Связывание: circular convolution в frequency domain.\"\"\"\n    return np.fft.ifft(np.fft.fft(a) * np.fft.fft(b))\n\ndef bundle(vectors):\n    \"\"\"Бандлинг: поэлементная сумма + нормализация.\"\"\"\n    result = np.sum(vectors, axis=0)\n    return result \u002F np.linalg.norm(result)\n",[24,623,624,629,634,639,644,649,654,659,663,668,673,678,682,687,692,697],{"__ignoreMap":143},[309,625,626],{"class":311,"line":312},[309,627,628],{},"def encode_atom(token: str, dim: int = 1024) -> np.ndarray:\n",[309,630,631],{"class":311,"line":318},[309,632,633],{},"    \"\"\"Токен → единичный вектор в phase space.\"\"\"\n",[309,635,636],{"class":311,"line":324},[309,637,638],{},"    h = hashlib.sha256(token.encode()).digest()\n",[309,640,641],{"class":311,"line":331},[309,642,643],{},"    rng = np.frombuffer(h, dtype=np.uint8).astype(np.float64)\n",[309,645,646],{"class":311,"line":337},[309,647,648],{},"    # Фазовый код: cos + j*sin → unit vector в комплексном пространстве\n",[309,650,651],{"class":311,"line":343},[309,652,653],{},"    phases = rng[:dim] \u002F 255.0 * 2 * np.pi\n",[309,655,656],{"class":311,"line":349},[309,657,658],{},"    return np.cos(phases) + 1j * np.sin(phases)\n",[309,660,661],{"class":311,"line":355},[309,662,328],{"emptyLinePlaceholder":327},[309,664,665],{"class":311,"line":361},[309,666,667],{},"def bind(a, b):\n",[309,669,670],{"class":311,"line":367},[309,671,672],{},"    \"\"\"Связывание: circular convolution в frequency domain.\"\"\"\n",[309,674,675],{"class":311,"line":373},[309,676,677],{},"    return np.fft.ifft(np.fft.fft(a) * np.fft.fft(b))\n",[309,679,680],{"class":311,"line":379},[309,681,328],{"emptyLinePlaceholder":327},[309,683,684],{"class":311,"line":385},[309,685,686],{},"def bundle(vectors):\n",[309,688,689],{"class":311,"line":391},[309,690,691],{},"    \"\"\"Бандлинг: поэлементная сумма + нормализация.\"\"\"\n",[309,693,694],{"class":311,"line":396},[309,695,696],{},"    result = np.sum(vectors, axis=0)\n",[309,698,699],{"class":311,"line":402},[309,700,701],{},"    return result \u002F np.linalg.norm(result)\n",[14,703,704,705,708],{},"Зачем это нужно, если есть fastembed? Потому что HRR поддерживает ",[289,706,707],{},"алгебраические операции",", которые векторные модели не умеют:",[710,711,712,719,725],"ul",{},[713,714,715,718],"li",{},[289,716,717],{},"probe(entity)"," — «дай все факты, привязанные к сущности X». Bind\u002Funbind с entity-атомом.",[713,720,721,724],{},[289,722,723],{},"related(entity)"," — «что структурно соседствует с X». Через HRR similarity.",[713,726,727,734,735,126],{},[289,728,729,730,733],{},"reason(",[309,731,732],{},"e1, e2, e3",")"," — «что общего у нескольких сущностей». Multi-entity JOIN, ",[24,736,737],{},"min(scores)",[14,739,740],{},"Semantic-модель даёт «похожесть по смыслу», HRR — «связанность по структуре». Это разные оси.",[29,742,744],{"id":743},"jaccard-дешёвый-фильтр","Jaccard: дешёвый фильтр",[14,746,747],{},"Jaccard — пересечение токенов запроса и факта, поделённое на объединение. Стоимость: O(n) по токенам, ноль выделений памяти. Работает как грубый фильтр: если запрос и факт не пересекаются ни одним токеном — штраф.",[135,749,751],{"className":303,"code":750,"language":305,"meta":143,"style":143},"def jaccard(query_tokens: set, fact_tokens: set) -> float:\n    if not query_tokens or not fact_tokens:\n        return 0.0\n    return len(query_tokens & fact_tokens) \u002F len(query_tokens | fact_tokens)\n",[24,752,753,758,763,768],{"__ignoreMap":143},[309,754,755],{"class":311,"line":312},[309,756,757],{},"def jaccard(query_tokens: set, fact_tokens: set) -> float:\n",[309,759,760],{"class":311,"line":318},[309,761,762],{},"    if not query_tokens or not fact_tokens:\n",[309,764,765],{"class":311,"line":324},[309,766,767],{},"        return 0.0\n",[309,769,770],{"class":311,"line":331},[309,771,772],{},"    return len(query_tokens & fact_tokens) \u002F len(query_tokens | fact_tokens)\n",[14,774,775],{},"Вес 0.2 — не главный канал, но отсекает шум. На практике: запрос «деплой nginx» и факт «настройка DNS» получают jaccard=0, и это правильно.",[29,777,779],{"id":778},"fts5-полнотекстовая-индексация","FTS5: полнотекстовая индексация",[14,781,782],{},"SQLite FTS5 — встроенная полнотекстовая индексация. AND-семантика: все слова запроса должны присутствовать в факте. Это жёстко, но предсказуемо.",[135,784,786],{"className":527,"code":785,"language":529,"meta":143,"style":143},"CREATE VIRTUAL TABLE facts_fts USING fts5(content, content=facts, content_rowid=fact_id);\n\n-- Поиск:\nSELECT fact_id, rank FROM facts_fts WHERE facts_fts MATCH 'деплой nginx'\nORDER BY rank LIMIT 30;\n",[24,787,788,793,797,802,807],{"__ignoreMap":143},[309,789,790],{"class":311,"line":312},[309,791,792],{},"CREATE VIRTUAL TABLE facts_fts USING fts5(content, content=facts, content_rowid=fact_id);\n",[309,794,795],{"class":311,"line":318},[309,796,328],{"emptyLinePlaceholder":327},[309,798,799],{"class":311,"line":324},[309,800,801],{},"-- Поиск:\n",[309,803,804],{"class":311,"line":331},[309,805,806],{},"SELECT fact_id, rank FROM facts_fts WHERE facts_fts MATCH 'деплой nginx'\n",[309,808,809],{"class":311,"line":337},[309,810,811],{},"ORDER BY rank LIMIT 30;\n",[14,813,814],{},"Проблема FTS5: не понимает перефразировок. «Как выкатить nginx на прод» не матчит «деплой nginx через CI\u002FCD». Поэтому fallback на semantic search при пустом FTS5 — критичен.",[29,816,818],{"id":817},"стоимость-на-potato-vps","Стоимость на Potato VPS",[14,820,821],{},"Типичный memory footprint при работающем агенте:",[37,823,824,834],{},[40,825,826],{},[43,827,828,831],{},[46,829,830],{},"Компонент",[46,832,833],{},"RSS",[59,835,836,844,852,860,868],{},[43,837,838,841],{},[64,839,840],{},"Hermes core (Python)",[64,842,843],{},"~380 МБ",[43,845,846,849],{},[64,847,848],{},"fastembed (загружена)",[64,850,851],{},"+300 МБ",[43,853,854,857],{},[64,855,856],{},"ONNX Runtime residual",[64,858,859],{},"(включено выше)",[43,861,862,865],{},[64,863,864],{},"SQLite + FTS5 index",[64,866,867],{},"~5 МБ",[43,869,870,873],{},[64,871,872],{},"Итого",[64,874,875],{},"~680 МБ",[14,877,878],{},"Остаётся достаточно для системы, swap, и других процессов. Комфортно.",[14,880,881,882,885],{},"При shutdown модель выгружается, RSS падает до ~620 МБ. ONNX residual не освобождается — это цена одного ",[24,883,884],{},"import fastembed"," за жизнь процесса.",[29,887,889],{"id":888},"weight-auto-redistribution","Weight auto-redistribution",[14,891,892],{},"Если fastembed недоступна (не установлена, или numpy отсутствует), веса перераспределяются автоматически:",[135,894,896],{"className":303,"code":895,"language":305,"meta":143,"style":143},"def _redistribute_weights(self):\n    if not embedder.is_available():\n        # Semantic недоступен: 0.3 → FTS +0.15, Jaccard +0.1, HRR +0.05\n        self.fts_weight = 0.45\n        self.jaccard_weight = 0.30\n        self.hrr_weight = 0.25\n        self.semantic_weight = 0.0\n    elif not _HAS_NUMPY:\n        # HRR недоступен: 0.2 → FTS +0.1, Semantic +0.1\n        self.fts_weight = 0.40\n        self.jaccard_weight = 0.20\n        self.hrr_weight = 0.0\n        self.semantic_weight = 0.40\n",[24,897,898,903,908,913,918,923,928,933,938,943,948,953,958],{"__ignoreMap":143},[309,899,900],{"class":311,"line":312},[309,901,902],{},"def _redistribute_weights(self):\n",[309,904,905],{"class":311,"line":318},[309,906,907],{},"    if not embedder.is_available():\n",[309,909,910],{"class":311,"line":324},[309,911,912],{},"        # Semantic недоступен: 0.3 → FTS +0.15, Jaccard +0.1, HRR +0.05\n",[309,914,915],{"class":311,"line":331},[309,916,917],{},"        self.fts_weight = 0.45\n",[309,919,920],{"class":311,"line":337},[309,921,922],{},"        self.jaccard_weight = 0.30\n",[309,924,925],{"class":311,"line":343},[309,926,927],{},"        self.hrr_weight = 0.25\n",[309,929,930],{"class":311,"line":349},[309,931,932],{},"        self.semantic_weight = 0.0\n",[309,934,935],{"class":311,"line":355},[309,936,937],{},"    elif not _HAS_NUMPY:\n",[309,939,940],{"class":311,"line":361},[309,941,942],{},"        # HRR недоступен: 0.2 → FTS +0.1, Semantic +0.1\n",[309,944,945],{"class":311,"line":367},[309,946,947],{},"        self.fts_weight = 0.40\n",[309,949,950],{"class":311,"line":373},[309,951,952],{},"        self.jaccard_weight = 0.20\n",[309,954,955],{"class":311,"line":379},[309,956,957],{},"        self.hrr_weight = 0.0\n",[309,959,960],{"class":311,"line":385},[309,961,962],{},"        self.semantic_weight = 0.40\n",[14,964,965],{},"Graceful degradation: система работает и без эмбеддингов (FTS + Jaccard), и без HRR (FTS + Jaccard + Semantic). Но полная четвёрка — оптимальный режим.",[29,967,969],{"id":968},"практические-грабли","Практические грабли",[152,971,973],{"id":972},"_1-fts5-and-слишком-строго","1. FTS5 AND — слишком строго",[14,975,976],{},"Запрос «компактный формат сообщений» требует наличия всех трёх слов. Если факт записан как «лаконичные ответы» — FTS5 молчит. Semantic спасает, но только если модель загружена.",[14,978,979,982],{},[289,980,981],{},"Решение:"," не полагаться на FTS5 как единственный канал. Всегда держать semantic включённым.",[152,984,986],{"id":985},"_2-missing-semantic-vectors-после-обновления","2. Missing semantic vectors после обновления",[14,988,989,990,993],{},"Агент обновился, но работает старый процесс. Новые факты записываются без ",[24,991,992],{},"semantic_vector",". Симптом: русские запросы возвращают пустой результат.",[135,995,997],{"className":527,"code":996,"language":529,"meta":143,"style":143},"SELECT COUNT(*) FROM facts WHERE semantic_vector IS NULL;\n",[24,998,999],{"__ignoreMap":143},[309,1000,1001],{"class":311,"line":312},[309,1002,996],{},[14,1004,1005],{},"Если > 0 — запустить backfill:",[135,1007,1009],{"className":303,"code":1008,"language":305,"meta":143,"style":143},"import embedder, sqlite3\n\nconn = sqlite3.connect(db_path)  # путь к вашей базе\nrows = conn.execute('SELECT fact_id, content FROM facts WHERE semantic_vector IS NULL').fetchall()\n\nfor fid, content in rows:\n    vec = embedder.embed_text(content)\n    conn.execute('UPDATE facts SET semantic_vector = ? WHERE fact_id = ?',\n                 (embedder.vector_to_bytes(vec), fid))\n\nconn.commit()\n",[24,1010,1011,1016,1020,1025,1030,1034,1039,1044,1049,1054,1058],{"__ignoreMap":143},[309,1012,1013],{"class":311,"line":312},[309,1014,1015],{},"import embedder, sqlite3\n",[309,1017,1018],{"class":311,"line":318},[309,1019,328],{"emptyLinePlaceholder":327},[309,1021,1022],{"class":311,"line":324},[309,1023,1024],{},"conn = sqlite3.connect(db_path)  # путь к вашей базе\n",[309,1026,1027],{"class":311,"line":331},[309,1028,1029],{},"rows = conn.execute('SELECT fact_id, content FROM facts WHERE semantic_vector IS NULL').fetchall()\n",[309,1031,1032],{"class":311,"line":337},[309,1033,328],{"emptyLinePlaceholder":327},[309,1035,1036],{"class":311,"line":343},[309,1037,1038],{},"for fid, content in rows:\n",[309,1040,1041],{"class":311,"line":349},[309,1042,1043],{},"    vec = embedder.embed_text(content)\n",[309,1045,1046],{"class":311,"line":355},[309,1047,1048],{},"    conn.execute('UPDATE facts SET semantic_vector = ? WHERE fact_id = ?',\n",[309,1050,1051],{"class":311,"line":361},[309,1052,1053],{},"                 (embedder.vector_to_bytes(vec), fid))\n",[309,1055,1056],{"class":311,"line":367},[309,1057,328],{"emptyLinePlaceholder":327},[309,1059,1060],{"class":311,"line":373},[309,1061,1062],{},"conn.commit()\n",[152,1064,1066],{"id":1065},"_3-fastembed-pooling-change","3. fastembed pooling change",[14,1068,1069],{},"Версия 0.8.0+ меняет pooling с CLS на mean. Старые векторы несовместимы с новыми. Решение: полный re-embed всех фактов после обновления fastembed.",[152,1071,1073],{"id":1072},"_4-onnx-memory-leak-это-не-leak","4. ONNX memory leak (это не leak)",[14,1075,1076,1078],{},[24,1077,295],{}," освобождает веса, но не ONNX Runtime pools. Это не утечка — так устроен ONNX. 481 МБ residual — норма. Не пытайтесь «починить».",[152,1080,1082],{"id":1081},"_5-rss-used-memory","5. RSS ≠ used memory",[14,1084,1085,1088],{},[24,1086,1087],{},"ru_maxrss"," показывает пик, а не текущее потребление. Для точного измерения:",[135,1090,1092],{"className":303,"code":1091,"language":305,"meta":143,"style":143},"def current_rss_mb():\n    with open('\u002Fproc\u002Fself\u002Fstatm') as f:\n        pages = int(f.read().split()[1])\n    return pages * 4096 \u002F 1024 \u002F 1024\n",[24,1093,1094,1099,1104,1109],{"__ignoreMap":143},[309,1095,1096],{"class":311,"line":312},[309,1097,1098],{},"def current_rss_mb():\n",[309,1100,1101],{"class":311,"line":318},[309,1102,1103],{},"    with open('\u002Fproc\u002Fself\u002Fstatm') as f:\n",[309,1105,1106],{"class":311,"line":324},[309,1107,1108],{},"        pages = int(f.read().split()[1])\n",[309,1110,1111],{"class":311,"line":331},[309,1112,1113],{},"    return pages * 4096 \u002F 1024 \u002F 1024\n",[29,1115,1117],{"id":1116},"web-интерфейс-для-просмотра-фактов","Web-интерфейс для просмотра фактов",[14,1119,1120],{},"Для отладки я сделал standalone htmx-приложение на stdlib http.server. Тёмная тема, моноширинный шрифт, FTS5-поиск, inline-редактирование, кнопки feedback, цветовые бейджи по категориям. Zero dependencies — только Python stdlib. Запускается одной командой, слушает на локальном порту.",[29,1122,872],{"id":1123},"итого",[14,1125,1126],{},"Четыре стратегии поиска в одном SQLite-файле. 680 МБ RSS при работающей модели. Lazy-load, graceful degradation, выгрузка при shutdown. Никаких внешних сервисов, никаких docker-compose, никаких Pinecone API-ключей.",[14,1128,1129],{},"Для хобби-проекта на Potato VPS — это единственный адекватный вариант. Не потому что «лучше ChromaDB», а потому что ChromaDB не влезает в скромный объём RAM, а Pinecone — это чужой компьютер.",[14,1131,1132],{},"Свой плагин на SQLite — это контроль. Контроль над памятью, над индексацией, над lifecycle модели. И когда в 3 часа ночи OOM-killer стучится — ты точно знаешь, кто виноват и что делать.",[1134,1135],"hr",{},[14,1137,1138],{},[1139,1140,1141,1142,1144],"em",{},"Плагин ",[24,1143,26],{}," — часть проекта Hermes Agent.",[1146,1147,1148],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":143,"searchDepth":318,"depth":318,"links":1150},[1151,1152,1153,1159,1160,1163,1164,1165,1166,1167,1168,1169,1176,1177],{"id":31,"depth":318,"text":32},{"id":132,"depth":318,"text":133},{"id":149,"depth":318,"text":150,"children":1154},[1155,1156,1157,1158],{"id":154,"depth":324,"text":155},{"id":161,"depth":324,"text":162},{"id":168,"depth":324,"text":169},{"id":175,"depth":324,"text":176},{"id":182,"depth":318,"text":183},{"id":299,"depth":318,"text":300,"children":1161},[1162],{"id":474,"depth":324,"text":475},{"id":520,"depth":318,"text":521},{"id":614,"depth":318,"text":615},{"id":743,"depth":318,"text":744},{"id":778,"depth":318,"text":779},{"id":817,"depth":318,"text":818},{"id":888,"depth":318,"text":889},{"id":968,"depth":318,"text":969,"children":1170},[1171,1172,1173,1174,1175],{"id":972,"depth":324,"text":973},{"id":985,"depth":324,"text":986},{"id":1065,"depth":324,"text":1066},{"id":1072,"depth":324,"text":1073},{"id":1081,"depth":324,"text":1082},{"id":1116,"depth":318,"text":1117},{"id":1123,"depth":318,"text":872},"2026-05-24","Как запустить гибридный поиск (FTS5 + Jaccard + HRR + fastembed MiniLM-L12-v2) на дешёвом VPS. Lazy-loading модели, ~680 MB RSS, выгрузка при shutdown. Почему не ChromaDB и не Pinecone — а свой плагин на SQLite.","md",{},"\u002Fholographic-memory-potato-vps",null,{"title":5,"description":1179},"holographic-memory-potato-vps",[1185],"s12YtL-Ec4OonqILge01fTqBhCWP8c39NAx6mx-FmO4",1779661627949]