Более интеллектуальные стратегии поиска, превосходящие плотные графы, — с гибридными конвейерами и более низкой стоимостью
Делиться

В моей предыдущей статье «Действительно ли вам нужен GraphRAG? Руководство для практиков. За пределами шумихи» я изложил основные принципы проектирования GraphRAG и представил расширенный конвейер поиска и генерации, сочетающий поиск по графам с поиском по вектору. Я также рассказал, почему создание идеально полного графа — такого, который охватывает все сущности и связи в корпусе, — может быть чрезмерно сложным, особенно при масштабировании.
В этой статье я подробно излагаю эти идеи на конкретных примерах и коде, демонстрируя практические ограничения, возникающие при построении и выполнении запросов к реальным системам GraphRAG. Я также демонстрирую, как конвейер поиска данных помогает сбалансировать стоимость и сложность реализации, не жертвуя точностью . В частности, мы рассмотрим:
- Построение графика: следует ли извлекать сущности из фрагментов или полных документов, и насколько этот выбор на самом деле важен?
- Запросы к отношениям без плотного графа: можем ли мы вывести значимые отношения, используя итеративную оптимизацию пространства поиска вместо явного кодирования каждого отношения в графе?
- Обработка слабых внедрений: почему буквенно-цифровые сущности мешают поиску векторов и как контекст графа это исправляет.
Конвейер GraphRAG
Как было сказано в предыдущей статье, конвейер встраивания GraphRAG выглядит следующим образом: узел Graph, его связи и их встраивания хранятся в базе данных Graph. Кроме того, фрагменты документов и их встраивания также хранятся в базе данных.

Предлагаемый конвейер поиска и генерации ответов выглядит следующим образом:

Как видно, результат графика не используется напрямую для ответа на запрос пользователя. Вместо этого он используется следующим образом:
- Метаданные узла (в частности, doc_id) действуют как мощный классификатор , помогая идентифицировать релевантные документы перед поиском по вектору. Это критически важно для больших корпусов, где простое определение сходства векторов может быть неточным.
- Обогащение контекста пользовательского запроса для извлечения наиболее релевантных фрагментов. Это критически важно для некоторых типов запросов со слабой векторной семантикой, таких как идентификаторы, номера транспортных средств, даты и числовые строки.
- Итеративная оптимизация пространства поиска , сначала путём выбора наиболее релевантных документов, а затем внутри них — наиболее релевантных фрагментов (с использованием контекстного обогащения). Это позволяет нам сохранять простоту графа, при этом не требуется извлекать в него все связи между сущностями для получения точных ответов на запросы о них.
Для демонстрации этих идей мы будем использовать набор данных из 10 искусственно созданных полицейских отчетов, GPT-4o в качестве LLM и Neo4j в качестве базы данных графов.
Построение графика
Мы построим простой звёздчатый граф с идентификатором отчёта в качестве центрального узла и сущностями, соединёнными с центральным узлом. Запрос на построение будет выглядеть следующим образом:
custom_prompt = ChatPromptTemplate.from_template(«»» Вы — помощник по извлечению информации. Прочитайте текст ниже и определите важные сущности. **Правила извлечения:** — Всегда извлекайте **Идентификатор отчёта** (это центральный узел). — Извлекайте **людей**, **учреждения**, **места**, **даты**, **денежные суммы** и **регистрационные номера транспортных средств** (например, MH12AB1234, PK-02-4567, KA05MG2020). — Не игнорируйте имена людей; извлекайте все, упомянутые в документе, даже если они кажутся незначительными или их роль неясна. Обрабатывайте все типы транспортных средств (например, автомобили, мотоциклы и т. д.) как одну и ту же сущность под названием «Транспортное средство». **Формат вывода:** 1. Перечислите все узлы (уникальные сущности). 2. Определите центральный узел (Идентификатор отчёта). 3. Создайте связи вида: (Отчёт Id)-[HAS_ENTITY]->(Сущность), 4. Не создавайте никаких других типов связей. Текст: {input} Возвращает только структурированные данные, например: Узлы: — Отчёт SYN-REP-2024 — Мотоцикл Honda ABCD1234 — Колледж XYZ, Ченнаи — Колледж NNN, Мумбаи — 1434800 — Г-н Джон Связи: — (Отчёт SYN-REP-2024)-[HAS_ENTITY]->(Мотоцикл Honda ABCD1234) — (Отчёт SYN-REP-2024)-[HAS_ENTITY]->(Колледж XYZ, Ченнаи) — … «»»)
Обратите внимание, что в этом запросе мы не извлекаем из графа какие-либо связи, такие как «обвиняемый», «свидетель» и т. д. Все узлы будут иметь единообразную связь «HAS_ENTITY» с центральным узлом, который является идентификатором отчёта. Я разработал это как крайний случай, чтобы проиллюстрировать, что мы можем отвечать на запросы о связях между сущностями даже с помощью этого минимального графа, основываясь на конвейере поиска, изображённом в предыдущем разделе. Если вы хотите включить несколько важных связей, запрос можно изменить, включив в него такие предложения, как:
3. Для сущностей лиц связь должна быть основана на их роли в отчете (например, истец, обвиняемый, свидетель, следователь и т. д.). Например: (Идентификатор отчета) — [Обвиняемый]-> (Имя лица) 4. Для всех остальных создайте связи в форме: (Идентификатор отчета)-[HAS_ENTITY]->(Сущность), llm_transformer = LLMGraphTransformer( llm=llm, # allowed_relationships=[«HAS_ENTITY»], prompt= custom_prompt, )
Далее мы создадим граф для каждого документа, создав документ Langchain из полного текста и предоставив его Neo4j.
# Прочитать весь файл (без разбиения на части) с помощью open(file_path, «r», encoding=»utf-8″) as f: text_content = f.read() # Создать документ LangChain document = Document( page_content=text_content, metadata={ «doc_id»: doc_id, «source»: filename, «file_path»: file_path }, ) try: # Преобразовать в граф (весь документ) graph_docs = llm_transformer.convert_to_graph_documents([document]) print(f»✅ Извлечено {len(graph_docs[0].nodes)} узлов и {len(graph_docs[0].relationships)} связей.») for gdoc in graph_docs: for node in gdoc.nodes: node.properties[«doc_id»] = doc_id original_id = node.properties.get(«id») или getattr(node, «id», None) if original_id: node.properties[«entity_id»] = original_id # Добавить в Neo4j graph.add_graph_documents( graph_docs, baseEntityLabel=True, include_source=False ) except: …
Это создает граф, состоящий из 10 кластеров, как показано ниже:

Ключевые наблюдения
- Количество извлекаемых узлов варьируется в зависимости от используемой модели LLM и даже для разных запусков одной и той же модели LLM. При использовании gpt-4o каждое выполнение извлекает от 15 до 30 узлов (в зависимости от размера документа) для каждого документа, что в сумме составляет от 200 до 250 узлов. Поскольку каждый из них представляет собой звёздчатый граф, количество связей на единицу меньше количества узлов для каждого документа.
- Длинные документы приводят к рассеиванию внимания студентов магистратуры права, в результате чего они не могут вспомнить и извлечь все указанные сущности (лица, места и т. д.), присутствующие в документе.
Чтобы увидеть, насколько значим этот эффект, давайте взглянем на график одного из документов (SYN-REPORT-0008). Документ содержит около 4000 слов. Результирующий график содержит 22 узла и выглядит следующим образом:

Теперь попробуем сгенерировать граф для этого документа, разбив его на части, а затем извлекая сущности из каждой части и объединяя их, используя следующую логику:
- Запрос на извлечение сущностей остался прежним, за исключением того, что мы просим извлечь сущности, отличные от идентификатора отчета.
- Сначала извлеките идентификатор отчета из документа, используя эту подсказку.
report_id_prompt = ChatPromptTemplate.from_template(«»» Извлечь ТОЛЬКО идентификатор отчёта из текста. Идентификаторы отчётов обычно выглядят так: — SYN-REP-2024 Возвращает строго одну строку: Отчёт:
Затем извлеките сущности из каждого фрагмента, используя подсказку по сущностям.
def extract_entities_by_chunk(llm, text, chunk_size=2000, overlap=200): splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=overlap ) chunks = splitter.split_text(text) all_entities = [] for i, chunk in enumerate(chunks): print(f»🔍 Обработка куска {i+1}/{len(chunks)}») raw = run_prompt(llm, entity_prompt, chunk) pairs = re.findall(r»- (.*?)s*|s*(w+)», raw) all_entities.extend([(e.strip(), t.strip()) for e, t in pairs]) return all_entities
c. Удалить дубликаты сущностей
г. Постройте граф, подключив все сущности к центральному узлу Report Id.
Эффект весьма впечатляющий. Граф SYN-REPORT-0008 теперь выглядит следующим образом. Он содержит 78 узлов, что в 3 раза больше, чем раньше. Компромиссом при построении такого плотного графа являются время и ресурсы, затрачиваемые на итерации извлечения фрагментов.

Каковы последствия?
Влияние изменения плотности графа заключается в возможности отвечать на вопросы, связанные с сущностями, напрямую и точно; т. е. если сущность или отношение отсутствует в графе, на связанный с ней запрос нельзя ответить из графа.
Подходом к минимизации этого эффекта в нашем разреженном звездном графе было бы создание запроса таким образом, чтобы в нем содержалась ссылка на известную связанную сущность, которая, скорее всего, присутствует в графе.
Например, в полицейском отчёте следователь упоминается относительно реже, чем город, и вероятность того, что город будет присутствовать в графе, выше, чем сам следователь. Поэтому, чтобы найти следователя, вместо вопроса «В каких отчётах следователь указан как Рави Шарма?» можно задать вопрос «В каких отчётах из Мумбаи следователь указан как Рави Шарма?», если известно, что этот сотрудник работает в полицейском управлении Мумбаи. Наш конвейер извлечёт из графа отчёты, связанные с Мумбаи, и найдёт в этих документах фрагменты, содержащие точное имя этого сотрудника. Это будет продемонстрировано в следующих разделах.
Обработка слабых внедрений
Рассмотрим следующие похожие запросы, которые, вероятно, будут часто задаваться в отношении этих данных.
«Расскажите мне об инциденте с участием Person_3»
«Расскажите мне об инциденте в отчете SYN-REPORT-0008»
Подробную информацию об инциденте, указанном в отчете, невозможно найти в графе, поскольку он содержит только сущности и связи, и, следовательно, ответ необходимо вывести из поиска сходства векторов.
Можно ли в этом случае игнорировать график?
Если вы выполните эти запросы, первый запрос, скорее всего, вернёт правильный ответ для относительно небольшого корпуса, такого как наш тестовый набор данных, тогда как второй — нет. Причина в том, что LLM обладают врожденным пониманием имён и слов благодаря обучению, но им сложно приписать какое-либо семантическое значение буквенно-цифровым строкам, таким как report_id, номера транспортных средств, суммы, даты и т. д. Следовательно, встраивание имени человека гораздо сильнее, чем встраивание буквенно-цифровых строк. Таким образом, фрагменты данных, полученные в случае буквенно-цифровых строк с использованием векторного сходства, слабо коррелируют с запросом пользователя, что приводит к неверному ответу.
Здесь на помощь приходит обогащение контекста с помощью Graph. Для запроса типа «Расскажите мне об инциденте в SYN-REPORT-0008» мы получаем все данные из звёздного графа центрального узла SYN-REPORT-0008 с помощью сгенерированного шифра, а затем LLM использует его для генерации контекста (интерпретации ответа JSON на естественном языке). Контекст также содержит источники узлов, которые в данном случае возвращают два документа, один из которых — корректный документ SYN-REPORT-0008. Второй — SYN-REPORT-00010, поскольку один из прикреплённых узлов — город (Mumbai) — общий для обоих отчётов.
Теперь, когда пространство поиска сужено до двух документов, фрагменты извлекаются из обоих документов, используя этот контекст вместе с запросом пользователя. Поскольку в контексте графа упоминаются лица, места, суммы и другие детали, представленные в первом отчёте, но не во втором, это позволяет LLM на этапе синтеза ответа легко определить, что правильными фрагментами являются фрагменты, извлечённые из SYN-REPORT-0008, а не из 0010. И ответ формируется правильно. Ниже представлен лог запроса графа, ответ JSON и контекст естественного языка, отображающий это.
Журнал обработки Сгенерированный шифр: cypher MATCH (r:`__Entity__`:Report) WHERE toLower(r.id) CONTAINS toLower(«SYN-REPORT-0008») OPTIONAL MATCH (r)-[]-(e) RETURN DISTINCT r.id AS report_id, r.doc_id AS report_doc_id, labels(e) AS entity_labels, e.id AS entity_id, e.doc_id AS entity_doc_id Ответ JSON: [{'report_id': 'Syn-Report-0008', 'report_doc_id': 'SYN-REPORT-0008', 'entity_labels': ['__Entity__', 'Person'], 'entity_id': 'Mr. Person_12', 'entity_doc_id': 'SYN-REPORT-0008'}, {'report_id': 'Syn-Report-0008', 'report_doc_id': 'SYN-REPORT-0008', 'entity_labels': ['__Entity__', 'Место'], 'entity_id': 'Нью-Дели', 'entity_doc_id': 'SYN-REPORT-0008'}, {'report_id': 'Syn-Report-0008', 'report_doc_id': 'SYN-REPORT-0008', 'entity_labels': ['__Entity__', 'Место'], 'entity_id': 'Коттаям', 'entity_doc_id': 'SYN-REPORT-0008'}, {'report_id': 'Syn-Report-0008', 'report_doc_id': 'SYN-REPORT-0008', 'entity_labels': ['__Entity__', 'Person'], 'entity_id': 'Person_4', 'entity_doc_id': 'SYN-REPORT-0008'}, {'report_id': 'Syn-Report-0008', 'report_doc_id': 'SYN-REPORT-0008', 'entity_labels':… усечено Контекст естественного языка: Контекст описывает инцидент с участием нескольких сущностей, включая отдельных лиц, места, денежные суммы и даты. Извлекаются следующие данные: 1. **Участники**: Упоминаются несколько лиц, в том числе «Г-н Person_12», «Person_4», «Person_11», «Person_8», «Person_5», «Person_6», «Person_3», «Person_7», «Person_10» и «Person_9». 2. **Упоминаемые места**: Упоминаются «Нью-Дели», «Коттаям», «Дели» и «Мумбаи». 3. **Денежные суммы**: Указаны две денежные суммы: «0,5 миллиона» и «43 тысячи». 4. **Даты**: Упоминаются две конкретные даты: «07.11.2024» и «04.02.2025». Источники: [SYN-REPORT-0008, SYN-REPORT-00010]
Можно ли успешно найти взаимосвязи?
Что насчёт поиска связей между сущностями? Мы проигнорировали все специфические связи в нашем графе и упростили его, оставив только одну связь «HAS_ENTITY» между центральным узлом report_id и остальными сущностями. Это означало бы, что запросы к сущностям, отсутствующим в графе, и связям между сущностями невозможны. Давайте протестируем наш конвейер итеративной поисковой оптимизации на различных подобных запросах. Для этого теста мы рассмотрим два отчёта из Калькутты и следующие запросы.

- Если указанная связь отсутствует в графе. Например: «Кто следователь в SYN-REPORT-0006?» или «Кто обвиняемые в SYN-REPORT-0006?»
- Связь между двумя сущностями, представленными в графе. Например: «Есть ли связь между Рави Вермой и Ракешем Прасадом Вермой?»
- Связь между любыми сущностями, связанными с третьей сущностью. Например: «Есть ли братья в отчётах из Калькутты?»
- Многопрофильные отношения: «Кто является следователем в отчетах, в которых обвиняются братья из Калькутты?»
Используя наш конвейер, все вышеперечисленные запросы дают точные результаты. Давайте рассмотрим процесс выполнения последнего многоадресного запроса, который является самым сложным. Здесь шифр не даёт никакого результата, поэтому поток возвращается к семантическому сопоставлению узлов. Сущности (место: Калькутта) извлекаются из пользовательского запроса, а затем сопоставляются для получения ссылок на все отчёты, связанные с Калькуттой, в данном случае это SYN-REPORT-0005 и SYN-REPORT-0006. Исходя из контекста, в котором пользовательский запрос запрашивает информацию о братьях и следователях, из обоих документов извлекаются наиболее релевантные фрагменты. Результирующий ответ успешно извлекает следователей для обоих отчётов.
Вот ответ:
Следователем по отчётам, где обвиняются братья из Калькутты (г-н Ракеш Прасад Верма, г-н Рави Прасад Верма и г-н Виджой Кумар Варма), является Аджай Кумар Трипати, инспектор полиции Центрального бюро расследований (CBI), Центрального бюро расследований (ACB), Калькутта, как указано в отчёте SYN-REPORT-0006. Кроме того, в отчёте SYN-REPORT-0005 в качестве следователя указан Правин Кумар, заместитель суперинтенданта полиции, EOB Калькутта.
Источники: [SYN-REPORT-0005, SYN-REPORT-0006]”
Вы можете просмотреть журнал обработки здесь > Ввод новой цепочки GraphCypherQAChain… 2025-12-05 17:08:27 — HTTP-запрос: … Вызван LLM Сгенерированный шифр: cypher MATCH (p:`__Entity__`:Person)-[:HAS_ENTITY]-(r:`__Entity__`:Report)-[:HAS_ENTITY]-(pl:`__Entity__`:Place) WHERE toLower(pl.id) СОДЕРЖИТ toLower(«kolkata») AND toLower(p.id) СОДЕРЖИТ toLower(«brother») OPTIONAL MATCH (r)-[:HAS_ENTITY]-(officer:`__Entity__`:Person) WHERE toLower(officer.id) СОДЕРЖИТ toLower(«investigating Officer») RETURN DISTINCT r.id AS report_id, r.doc_id AS report_doc_id, Officer.id AS Officer_id, Officer.doc_id AS Officer_doc_id Ответ Cypher: [] 2025-12-05 17:08:27 — HTTP-запрос: …LLM вызван > Завершенная цепочка. is_empty: True ❌ Cypher не выдал достоверный результат. 🔎 Запуск поиска семантического узла… 📋 Обнаруженные метки: ['Место', 'Лицо', 'Учреждение', 'Дата', 'Транспортное средство', 'Денежная сумма', 'Фрагмент', 'GraphNode', 'Отчет'] Запрос пользователя для поиска узла: следователь в отчетах, где обвиняются братья из Калькутты 2025-12-05 17:08:29 — HTTP-запрос: …вызван LLM 🔍 Извлеченные сущности: ['Калькутта'] 2025-12-05 17:08:30 — HTTP-запрос: …вызван LLM 📌 Попаданий для сущности 'Калькутта': [Document(metadata={'labels': ['Место'], 'node_id': '4:5b11b2a8-045c-4499-9df0-7834359d3713:41'}, page_content='TYPE: PlacenCONTENT: KolkatanDOC: SYN-REPORT-0006')] 📚 Полученные попадания узла: [Document(metadata={'labels': ['Place'], 'node_id': '4:5b11b2a8-045c-4499-9df0-7834359d3713:41'}, page_content='TYPE: PlacenCONTENT: KolkatanDOC: SYN-REPORT-0006')] Расширенный контекст узла: [Node] Это узел __Place__. Он представляет «ТИП: Место СОДЕРЖИМОЕ: Калькутта ДОК: SYN-REPORT-0006» (doc_id=N/A). [Отчет Syn-Report-0005 (doc_id=SYN-REPORT-0005)] —(HAS_ENTITY)—> __Entity__, Учреждение: Г-жа Шри Баладжи Форест Продукт Частное Лимитед (doc_id=SYN-REPORT-0005) [Отчет Syn-Report-0005 (doc_id=SYN-REPORT-0005)] —(HAS_ENTITY)—> __Entity__, Дата: 2014 (doc_id=SYN-REPORT-0005) [Отчет Syn-Report-0005 (doc_id=SYN-REPORT-0005)] —(HAS_ENTITY)—> __Entity__, Лицо: Г-н Паллаб Бисвас (doc_id=SYN-REPORT-0005) [Отчет Syn-Report-0005 (doc_id=SYN-REPORT-0005)] —(HAS_ENTITY)—> __Entity__, Дата: 2005 (doc_id=SYN-REPORT-0005).. усеченный [Отчет Syn-Report-0006 (doc_id=SYN-REPORT-0006)] —(HAS_ENTITY)—> __Entity__, Учреждение: M/S Jkjs & Co. (doc_id=SYN-REPORT-0006) [Отчет Syn-Report-0006 (doc_id=SYN-REPORT-0006)] —(HAS_ENTITY)—> __Entity__, Лицо: Б. Мишра (doc_id=SYN-REPORT-0006) [Отчет Syn-Report-0006 (doc_id=SYN-REPORT-0006)] —(HAS_ENTITY)—> __Entity__, Учреждение: Vishal Engineering Pvt. Ltd. (doc_id=SYN-REPORT-0006).. сокращено
Ключевые выводы
- Вам не нужен идеальный граф. Минимально структурированный граф, даже звёздчатый, может поддерживать сложные запросы в сочетании с итеративным уточнением пространства поиска.
- Разделение на фрагменты повышает полноту, но увеличивает стоимость. Извлечение на уровне фрагментов позволяет охватить гораздо больше сущностей, чем извлечение всего документа, но требует больше вызовов LLM. Используйте этот метод выборочно, исходя из длины и важности документа.
- Графовый контекст исправляет слабые вложения. Такие типы сущностей, как идентификаторы, даты и числа, имеют слабые семантические вложения; обогащение векторного поиска графовым контекстом необходимо для точного извлечения.
- Поиск семантических узлов — мощный резервный вариант, к которому следует подходить с осторожностью. Даже если запросы Cypher не срабатывают (из-за отсутствующих связей), семантическое сопоставление позволяет определить релевантные узлы и надёжно сузить область поиска.
- Гибридный поиск обеспечивает точный ответ на основе связей без плотного графа. Сочетание фильтрации документов на основе графа с поиском векторных фрагментов позволяет получать точные ответы даже при отсутствии явных связей в графе.
Заключение
Создание одновременно точной и экономичной системы GraphRAG требует признания практических ограничений построения графов на основе LLM. Большие документы рассеивают внимание, извлечение сущностей никогда не бывает идеальным, а кодирование каждой связи быстро становится дорогим и нестабильным.
Однако, как показано в этой статье, мы можем добиться высокой точности поиска без полностью детализированного графа знаний. Простая структура графа в сочетании с итеративной оптимизацией пространства поиска, семантическим поиском узлов и контекстно-обогащённым векторным поиском может превзойти более сложные и дорогостоящие решения.
Этот подход смещает акцент с предварительного извлечения всех данных из графа на извлечение экономически эффективных, быстро извлекаемых и необходимых данных, позволяя конвейеру извлечения заполнить пробелы. Конвейер обеспечивает баланс между функциональностью, масштабируемостью и стоимостью , сохраняя при этом возможность сложных многоадресных запросов к неструктурированным реальным данным.
Подробнее о принципах разработки GraphRAG, лежащих в основе представленных здесь концепций, можно узнать в статье «Действительно ли вам нужен GraphRAG? Руководство для практиков. За пределами шумихи».
Свяжитесь со мной и поделитесь своими комментариями на www.linkedin.com/in/partha-sarkar-lets-talk-AI
Все изображения и данные, использованные в этой статье, созданы искусственно. Рисунки и код созданы мной.
Источник: towardsdatascience.com























