
Привет, Хабр!
Базовые RAG-системы уже научились неплохо справляться с прямыми вопросами по тексту. Но только если ответ лежит в одном конкретном абзаце, а вопрос сформулирован почти так же, как сам исходный документ. Попробуйте заставить систему связать факты из трёх разных источников или сделать банальный логический вывод. В большинстве случаев результат будет неутешительным. А уж про поиск скрытых связей я даже спрашивать боюсь.
Сегодня рассмотрим open-source RAG-фреймворк HippoRAG 2. В сфере RAG главным преимуществом данного фреймворка является качество ответов, потому что принципы его работы основаны на реальном человеческом мозге. Давайте разберёмся, откуда он взялся, как устроен изнутри и как его запустить.
Принципы работы
Префикс «Hippo» в названии — это отсылка к гиппокампу, структуре мозга, которая отвечает за формирование и извлечение долговременных воспоминаний.
Разработчики из Ohio State University взяли за основу теорию гиппокампального индексирования Тейлера и Дисченна. Согласно ей, мозг хранит не сами воспоминания целиком, а лишь некоторые связи между ними, и при запросе восстанавливает полную картину через цепочку ассоциаций.
В архитектуре HippoRAG каждый компонент соответствует своему нейробиологическому аналогу:
LLM играет роль неокортекса. Отвечает за извлечение структурированных представлений из текста.
Retrieval-энкодер берёт на себя функцию парагиппокампальных областей мозга, обнаруживая семантические связи.
Knowledge граф вместе с алгоритмом PPR (Personalized PageRank) имитирует сам гиппокамп. Он хранит сеть знаний и фактов и умеет строить в ней ассоциативные цепочки при поиске.
В HippoRAG подобный механизм долговременной памяти позволяет выявить скрытые связи между фактами. Речь идёт о многошаговом рассуждении (multi-hop reasoning). Это процесс, при котором система последовательно сопоставляет факты из разных источников по контексту и выстраивает цепочку связей, чтобы ответить на абстрактный вопрос.

Факты внутри системы называются триплетами. Это структурированное представление знаний в виде «субъект — отношение — объект».
К примеру:
-
(Оливер Бэдмен, является, политик).Или:
(Монтебелло, часть округа, Рокленд Каунти).
За извлечение триплетов отвечает OpenIE (Open Information Extraction) с помощью LLM. При индексации фреймворк отправляет каждый чанк документа в языковую модель с инструкцией извлечь все структурированные утверждения в виде троек. Результаты кэшируются в openie_cache/, чтобы при повторном запуске не тратить токены снова.
Зачем это нужно? Потому что для многошагового вопроса вроде: «В каком округе родился политик, который…» — обычный RAG просто найдёт документ про политика и отдельно про округ, а связь не уловит. А HippoRAG 2 склеивает два триплета через общую сущность (политик → место рождения → округ). Это и есть так называемая «скрытая связь».
Из всех извлечённых триплетов строится knowledge граф. Не такой огромный, как в GraphRAG, а более компактный(HippoRAG на датасете MuSiQue использует около 9 млн токенов — против 115 млн у GraphRAG).
При загрузке документов помимо создания эмбеддингов для чанков проводится извлечение упомянутых триплетов. Из извлечённых фактов строится knowledge-граф. Соответственно, при ответе на вопрос используется как стандартный проход по эмбеддированным документам с косинусным подобием, так и ранжирование фактов по графу. В этот момент задействуется PPR алгоритм. PPR — это вариация PageRank, где вместо случайного блуждания по всему графу, релевантность узлов измеряется относительно конкретного набора начальных (seed) узлов.

Установка и первый запуск
Установить фреймворк можно через pip или клонировав репозиторий.
conda create -n hipporag python=3.10 conda activate hipporag pip install hipporag
Далее — пара экспортов (API-ключи и пути к кэшу) из env:
export OPENAI_API_KEY="sk-..." export HF_HOME="/путь/к/кэшу"
Допустим, у нас есть три документа:
from hipporag import HippoRAG docs = [ "Oliver Badman is a politician.", "Montebello is a part of Rockland County.", "Erik Hort's birthplace is Montebello." ] hipporag = HippoRAG( save_dir="my_rag_memory", # сюда упадёт всё: эмбеддинги, граф, кэш llm_model_name="gpt-4o-mini", llm_api_key = OPENAI_API_KEY, llm_base_url = OPENAI_BASE_URL, embedding_model_name="nvidia/NV-Embed-v2", embedding_api_key = EMBEDDING_API_KEY, embedding_base_url = EMBEDDING_BASE_URL )
Кстати говоря, фреймворк позволяет переопределять base_url для работы с локальными серверами. То есть ничто не мешает поднять собственные FastAPI-серверы с локальными LLM и моделями эмбеддингов или в Google Colab, используя библиотеку transformers, и затем просто указать эндпоинты с их выводом в openai-совместимом формате.
Пример эмбеддинг-модели
Ниже — самописный FastAPI-сервер для эмбеддингов.
import torch from fastapi import FastAPI, HTTPException from pydantic import BaseModel from transformers import AutoTokenizer, AutoModel from typing import List import uvicorn class EmbeddingRequest(BaseModel): input: List[str] | str model: str encoding_format: str = "float" class EmbeddingResponse(BaseModel): object: str = "list" data: List[dict] model: str usage: dict MODEL_NAME = "название вашей модели" device = "cuda" if torch.cuda.is_available() else "cpu" tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) model = AutoModel.from_pretrained(MODEL_NAME).to(device) model.eval() def embed_texts(texts: List[str]) -> List[List[float]]: inputs = tokenizer( texts, padding=True, truncation=True, return_tensors="pt", max_length=512 ).to(device) with torch.no_grad(): outputs = model(**inputs) embeddings = outputs.last_hidden_state.mean(dim=1) return embeddings.cpu().numpy().tolist() app = FastAPI(title="Local Embedding Server (OpenAI-compatible)") @app.get("/v1/models") async def list_models(): return { "object": "list", "data": [ { "id": MODEL_NAME, "object": "model", "owned_by": "local", "permission": [] } ] } @app.post("/v1/embeddings") async def create_embedding(request: EmbeddingRequest): texts = [request.input] if isinstance(request.input, str) else request.input if not texts: raise HTTPException(status_code=400, detail="Input text list is empty") try: embeddings = embed_texts(texts) except Exception as e: raise HTTPException(status_code=500, detail=f"Embedding failed: {str(e)}") response_data = [] for idx, emb in enumerate(embeddings): response_data.append({ "object": "embedding", "index": idx, "embedding": emb }) return EmbeddingResponse( data=response_data, model=MODEL_NAME, usage={ "prompt_tokens": sum(len(t) for t in texts), "total_tokens": sum(len(t) for t in texts) } ) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8001)
Теперь этот сервер можно передать в embedding_base_url="http://localhost:8001/v1".
Более простой и производительный вариант — использовать SentenceTransformer. Сервер остаётся полностью совместимым с OpenAI API.
import asyncio from contextlib import asynccontextmanager from typing import List, Union from fastapi import FastAPI, HTTPException from pydantic import BaseModel from sentence_transformers import SentenceTransformer import uvicorn class EmbeddingRequest(BaseModel): input: Union[str, List[str]] model: str encoding_format: str = "float" class EmbeddingResponse(BaseModel): object: str = "list" data: List[dict] model: str usage: dict @asynccontextmanager async def lifespan(app: FastAPI): app.state.model = SentenceTransformer("intfloat/e5-large-v2", device="cuda") yield app.state.model = None app = FastAPI(title="Local Embedding Server (OpenAI-compatible)", lifespan=lifespan) @app.get("/v1/models") async def list_models(): return { "object": "list", "data": [ { "id": "local-embedding-model", "object": "model", "owned_by": "local", "permission": [] } ] } @app.post("/v1/embeddings") async def create_embedding(request: EmbeddingRequest): texts = [request.input] if isinstance(request.input, str) else request.input if not texts: raise HTTPException(status_code=400, detail="Input text list is empty") model: SentenceTransformer = app.state.model try: embeddings = model.encode(texts, normalize_embeddings=True) except Exception as e: raise HTTPException(status_code=500, detail=f"Embedding failed: {str(e)}") response_data = [ { "object": "embedding", "index": idx, "embedding": emb.tolist() } for idx, emb in enumerate(embeddings) ] return EmbeddingResponse( data=response_data, model=request.model, usage={ "prompt_tokens": sum(len(text.split()) for text in texts), "total_tokens": sum(len(text.split()) for text in texts) } ) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8001)
Индексация документов
Индексация запускается одной командой:
hipporag.index(docs=docs)
Что происходит внутри?
Документы режутся на чанки (стандартно — по предложениям).
Каждый чанк эмбеддится.
Одновременно LLM обрабатывает каждый чанк через OpenIE и извлекает из него все смысловые триплеты.
Триплеты превращаются в узлы и рёбра knowledge-графа.
Эмбеддинги узлов графа тоже вычисляются (чтобы потом искать похожие факты).
Где хранится?
В папке my_rag_memory (которую мы передали в save_dir) создаётся структура:
embeddings/— плоские файлы с эмбеддингами чанков и узлов графа (обычно.npyили черезfaissиндекс).graph/— сериализованный граф (узлы, рёбра, веса).openie_cache/— результаты извлечения триплетов, чтобы при повторном запуске не жечь токены заново.
На данном этапе фреймворк хранит эмбеддинги в формате .parquet. Но ничего не мешает дописать совместимость с векторными БД, особенно в наше время.
Важный нюанс: если вы перезапускаете индекс с теми же документами, HippoRAG 2 не будет заново дёргать OpenIE и LLM, а проверит кэш по хешу текста. Так что за токены, хотя бы тут, можете не переживать.
Что возвращает каждая функция?
retrieve — поиск без генерации ответа:
results = hipporag.retrieve( queries=["What county is Erik Hort's birthplace a part of?"], num_to_retrieve=2 ) print(results)
На выходе — список списков. Для каждого запроса:
[ [ {"text": "Montebello is a part of Rockland County.", "score": 0.92, "type": "chunk"}, {"text": "Erik Hort's birthplace is Montebello.", "score": 0.87, "type": "chunk"} ] ]
Важный момент: type может быть "chunk" (найденный чанк) или "fact" (найденный триплет из графа) — зависит от того, что победило в ранжировании.
rag_qa — выполняет полный цикл: поиск → передача найденного контекста в LLM → генерация ответа
answers = hipporag.rag_qa( queries=["What county is Erik Hort's birthplace a part of?"] ) print(answers)
Вернёт список строк с ответами, например: ["Rockland County"]. В ответе также возвращается список использованных чанков.
Оценка с gold-данными
Один из самых приятных моментов, это когда у вас есть золотые ответы и поддерживающие документы для оценки:
gold_answers = [["Rockland County"]] gold_docs = [ ["Montebello is a part of Rockland County.", "Erik Hort's birthplace is Montebello."] ] eval_results = hipporag.rag_qa( queries=queries, gold_docs=gold_docs, gold_answers=gold_answers )
Тогда в eval_results упадёт словарь с метриками:
"retrieval_hit_rate"(попали ли нужные чанки в топ-N),"answer_accuracy"(точность ответа, часто через F1 или EM),"latency_seconds"— чтобы потом бенчмаркать.
Удаление и добавление документов
Если понадобится удалить документ, есть hipporag.delete_docs(doc_indices=[0,2]).
Граф перестраивается инкрементально, не с нуля. Для добавления новых данных вызывается hipporag.index(docs=new_docs). Система сама определит, что уже проиндексировано, а что нет.
Заключение
Признаюсь, я обожаю HippoRAG 2. Для меня это максимально удобный инструмент, который ещё и справляется лучше своих аналогов. Естественно, он не универсальная затычка для любой проблемы, но в задачах контекста, рассыпанного по множеству документов, ему нет равных.
Я советую обратить внимание на фреймворк уже за то, что авторам удалось решить задачу ассоциативного рассуждения без многократного роста стоимости запросов. Это ли не чудо?
© 2026 ООО «МТ ФИНАНС»
Комментарии (13)

Annsky
24.04.2026 10:24Я правильно понимаю, что скрытые факты находятся по цепочкам, то есть если я задам запрос В каком городе жила Алиса - оно найдет цепочку В городе А живет Боб, Боб живет с Кларой, Клара часто видится с Алисой, значит Алиса живет в городе А?

rRenegat Автор
24.04.2026 10:24Да, вы правильно поняли. При ранжировании фактов из графа он найдет связанную между собой цепочку фактов, и в результате выдаст топ наиболее семантически близких к запросу

Annsky
24.04.2026 10:24Это потрясающе! Но как? Там идет волновой поиск по графу?

rRenegat Автор
24.04.2026 10:24Да, по сути это и есть волновой поиск. При поиске ответа алгоритм PPR начинает обход графа с ключевых узлов и, проходя по всей сети связей, ранжирует их по релевантности запросу.
Вместо простого перебора соседей система ранжирует узлы по количеству и силе путей, ведущих к ним от исходного. Так и находятся скрытые связи. Узел Город А получит высокий ранг, если к нему ведет несколько ассоциативных цепочек, даже если в исходном тексте нет прямого утверждения о связи Алисы с этим городом.

SabMakc
24.04.2026 10:24Документы режутся на чанки (стандартно — по предложениям).
Одновременно LLM обрабатывает каждый чанк через OpenIE и извлекает из него все смысловые триплеты.
А как оно с предлогами работает? Извлечет “Петр является строителем” из “Петр родился в Москве. Он был строителем.”? Если с предложениями работает - то передается как-то дополнительный контекст о предыдущем тексте между чанками?

Saveliy2
24.04.2026 10:24Спасибо! Это здорово! Тоже прикрутил к агенту в termux , proot-distro, ubuntu. В облегчённом виде конечно ( потому что компактная SQlite с некоторыми гибридными возможностями векторов и графов с рефлективностью уже была до). Мне- начинающему вайбкодеру, только к ночи удалось запустить всё, в работающем виде.
Эти ваши триплеты- вещь!

Xom
24.04.2026 10:24Не очень понятно, как он найдет "скрытые факты", если 1 запрос выражен немного иначе чем в триплетах. Предлагаемый фреймворк предполагает поиск по графу в токенах запроса, насколько я понял, и может ничего не найти. 2. Как в графе будут представлены более сложные факты, например, таблицы, сложноподчинённые предложения, сложные факты как 10 кг зелёных яблок сорта Победа в ящиках. Тут очень много вопросов.
И само решение не ново, излечением триплетов для построения "всеобъемлющей" базы знаний кто только не занимался: CYC, Wolfram, Kompreno. И все прогорели. Надо извлекать уроки.

Saveliy2
24.04.2026 10:24Неожиданно. Лично потестил только одиночными понятиями действительно. Масштабированием банально вопрос не решается/решается ?На смарте особо не напроверяешься , хотя-а через апи к хорошей модели...надо когда нибудь попробовать , поставлю в планы.) Спасибо.
Возможно сам автор делал бенчи и посерьёзней.

VBDUnit
24.04.2026 10:24Когда узнал про принцип работы RAG представлял его себе как большое многомерное пространство с точками‑смыслами, и когда LLM хочет оттуда что‑то дернуть она вычленяет общие смыслы того что надо, и по их координатам дергает ближайшие точки из этого пространства и сцепленные с ними цитаты.
И была идея воткнуть в это пространство смыслов кротовые норы, которые бы отражали логические связи между смыслами. Логические связи по задумке надо было обнаруживать на этапе формирования БД. Идея в том, что если два смысла имеют логическую связь, но при этом находятся на большом декартовом расстоянии, то добавление такого вот портала рядом с ними создавало бы аномалию с существенно меньшим расстоянием между ними.

Типо вот Но идея уперлась в то, что такое искривленное неравномерное пространство считать гораздо сложнее, чем прямое. Ну то есть наверно можно засунуть RAG в какое‑нибудь гиперболическое пространство (Пуанкаре‑эмбеддинги, привет) но при решении в лоб это будет жрать столько, что все плюсы будут нивелироваться, плюс поверх этого отвалятся стандартные плюшки вроде индексов faiss/hnsw. Хотя можно вместо искривлённого пространства просто насовать туда точек‑телепортов, наподобие входов в пв‑туннели у Буджолд. Только тут вместо пятимерности пятитысячемерность, которая немножечко более ресурсоёмка.
И вот эта штука из статьи имхо решает эту задачу, и решает круто, красиво и изящно.


avshkol
Хорошие сапоги, надо брать!