Skip to content

refactor(memory): consolidate search services and unify model client initialization#916

Open
myhMARS wants to merge 4 commits intodevelopfrom
refactor/memory_search
Open

refactor(memory): consolidate search services and unify model client initialization#916
myhMARS wants to merge 4 commits intodevelopfrom
refactor/memory_search

Conversation

@myhMARS
Copy link
Copy Markdown
Collaborator

@myhMARS myhMARS commented Apr 16, 2026

Summary by Sourcery

重构内存搜索栈以使用统一的枚举和模型客户端,集中管理 Neo4j 全文与向量检索逻辑(包括感知节点),并引入带有查询预处理和结果构建器的新 MemoryService 读取管线。

New Features:

  • 引入 MemoryService 及其配套的读取管线抽象(上下文、模型和查询预处理),以提供统一的内存检索入口。
  • 新增可配置的 Neo4jSearchService,支持关键词/向量混合检索、结果重排序,以及针对不同 Neo4j 节点类型的类型化结果构建器。
  • 新增可复用的提示词管理系统,以及用于基于 LLM 的查询分解的“问题拆分”提示词。

Bug Fixes:

  • 规范处理 Neo4j 搜索查询中缺失或可空字段(如 invalid_atembeddings),以避免运行时错误。

Enhancements:

  • 通过枚举统一 Neo4j 节点及存储/搜索类型,并在搜索、agent 和重排序路径中贯穿使用。
  • 泛化图搜索工具,使其可基于 Neo4j 节点枚举运行,并通过共享的 Cypher 映射支持全文、基于用户以及基于 ID 的查询。
  • 用内存中的余弦相似度辅助函数替代直接的向量索引查询,复用按用户划分的向量和共享的搜索流程。
  • 改进 LLM 客户端工具,新增 StructResponse 辅助类,以便稳健地将 AIMessage 输出解析为 JSON/Pydantic。
  • 简化 Neo4jConnector 的使用,增加异步上下文管理器支持,并对 execute_query 的参数命名进行更清晰的调整。
  • 调整重排序阈值和结果去重逻辑,在各模块间复用共享的 deduplicate_results 辅助函数。
  • 清理已被新内存搜索管线取代的历史搜索策略类和 Celery 任务路由。

Documentation:

  • 明确提示词优化器文档,说明 JSON 输出中 promptdesc 字段必须为字符串类型。
Original summary in English

Summary by Sourcery

Refactor the memory search stack to use unified enums and model clients, centralize Neo4j fulltext and embedding search logic (including perceptual nodes), and introduce a new MemoryService read pipeline with query preprocessing and result builders.

New Features:

  • Introduce MemoryService and supporting read pipeline abstractions (contexts, models, and query preprocessing) to provide a unified entry point for memory retrieval.
  • Add configurable Neo4jSearchService with hybrid keyword/embedding retrieval, result reranking, and type-specific result builders for different Neo4j node types.
  • Add a reusable prompt management system and problem-splitting prompt for LLM-driven query decomposition.

Bug Fixes:

  • Normalize handling of missing or nullable fields such as invalid_at and embeddings in Neo4j search queries to avoid runtime errors.

Enhancements:

  • Unify Neo4j node and storage/search types via enums and propagate them through search, agent, and reranking code paths.
  • Generalize graph search utilities to operate on Neo4j node enums with shared Cypher mappings for fulltext, user-based, and ID-based queries.
  • Replace direct vector index queries with an in-memory cosine similarity helper using per-user embeddings and shared search flows.
  • Improve LLM client utilities with a StructResponse helper for robust JSON/Pydantic parsing of AIMessage outputs.
  • Simplify Neo4jConnector usage with async context manager support and clearer execute_query parameter naming.
  • Adjust reranking thresholds and result deduplication to use a shared deduplicate_results helper across modules.
  • Clean up legacy search strategy classes and Celery task routes that are superseded by the new memory search pipeline.

Documentation:

  • Clarify prompt optimizer documentation to state that JSON outputs must use string fields for prompt and desc.

myhMARS added 2 commits April 13, 2026 14:03
- Replace storage_services/search with new read_services/memory_search structure
- Implement content_search and perceptual_search strategies
- Add query_preprocessor for search optimization
- Create memory_service as unified interface
- Update celery_app and graph_search for new architecture
- Add enums for memory operations
- Implement base_pipeline and memory_read pipeline patterns
@myhMARS myhMARS requested a review from keeees April 16, 2026 05:29
@myhMARS myhMARS self-assigned this Apr 16, 2026
@myhMARS myhMARS added the enhancement New feature or request label Apr 16, 2026
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai bot commented Apr 16, 2026

Reviewer's Guide

在 Neo4j 中重构记忆搜索,使用基于枚举的节点类型和集中式查询映射;新增基于 Neo4j 的搜索/读取流水线与提示词基础设施;统一模型客户端初始化,并替换旧的搜索策略类。

用于快速 Neo4j 记忆搜索读取路径的时序图

sequenceDiagram
    actor Caller
    participant MemoryService
    participant ReadPipeLine
    participant QueryPreprocessor
    participant Neo4jSearchService
    participant Embedder as RedBearEmbeddings
    participant GraphSearch
    participant Neo4jConnector
    participant Neo4jDB

    Caller->>MemoryService: read(query, search_switch=QUICK, limit)
    MemoryService->>ReadPipeLine: run(query, search_switch, limit, includes)

    ReadPipeLine->>QueryPreprocessor: process(query)
    QueryPreprocessor-->>ReadPipeLine: cleaned_query

    alt storage_type == RAG
        ReadPipeLine->>RAGSearchService: search() async
        RAGSearchService-->>ReadPipeLine: MemorySearchResult
        ReadPipeLine-->>MemoryService: MemorySearchResult
        MemoryService-->>Caller: MemorySearchResult
    else storage_type == NEO4J and search_switch == QUICK
        ReadPipeLine->>ModelClientMixin: get_embedding_client(db, embedding_model_id)
        ModelClientMixin-->>ReadPipeLine: embedder_client

        ReadPipeLine->>Neo4jSearchService: Neo4jSearchService(ctx, embedder_client, includes)
        ReadPipeLine->>Neo4jSearchService: search(cleaned_query, limit)

        par keyword search
            Neo4jSearchService->>Neo4jConnector: __aenter__()
            Neo4jConnector-->>Neo4jSearchService: connector

            Neo4jSearchService->>GraphSearch: search_graph(connector, query, end_user_id, limit, includes)
            loop for node_type in includes
                GraphSearch->>Neo4jConnector: execute_query(cypher=FULLTEXT_QUERY_CYPHER_MAPPING[node_type], json_format=True, end_user_id, query, limit)
                Neo4jConnector->>Neo4jDB: execute_query(cypher, params)
                Neo4jDB-->>Neo4jConnector: records
                Neo4jConnector-->>GraphSearch: formatted_records
            end
            GraphSearch-->>Neo4jSearchService: keyword_results per node_type
        and embedding search
            Neo4jSearchService->>Embedder: embed_documents([cleaned_query])
            Embedder-->>Neo4jSearchService: query_embedding

            Neo4jSearchService->>GraphSearch: search_graph_by_embedding(connector, embedder_client, query_text, end_user_id, limit, includes)
            loop for node_type in includes
                GraphSearch->>Neo4jConnector: execute_query(cypher=USER_ID_QUERY_CYPHER_MAPPING[node_type], end_user_id)
                Neo4jConnector->>Neo4jDB: execute_query(cypher, params)
                Neo4jDB-->>Neo4jConnector: embedding_records
                Neo4jConnector-->>GraphSearch: embedding_records

                GraphSearch->>GraphSearch: cosine_similarity_search(query_embedding, vectors, limit)
                GraphSearch->>Neo4jConnector: execute_query(cypher=NODE_ID_QUERY_CYPHER_MAPPING[node_type], ids, json_format=True)
                Neo4jConnector->>Neo4jDB: execute_query(cypher, params)
                Neo4jDB-->>Neo4jConnector: node_records
                Neo4jConnector-->>GraphSearch: node_records with scores
            end
            GraphSearch-->>Neo4jSearchService: embedding_results per node_type
        end

        Neo4jSearchService->>Neo4jConnector: __aexit__()

        loop for node_type in includes
            Neo4jSearchService->>Neo4jSearchService: _rerank(keyword_results[node_type], embedding_results[node_type], limit)
            Neo4jSearchService->>data_builder_factory: data_builder_factory(node_type, record)
            data_builder_factory-->>Neo4jSearchService: builder
            Neo4jSearchService->>Neo4jSearchService: build Memory objects
        end

        Neo4jSearchService-->>ReadPipeLine: MemorySearchResult
        ReadPipeLine-->>MemoryService: MemorySearchResult
        MemoryService-->>Caller: MemorySearchResult
    end
Loading

新记忆读取流水线与搜索服务的类图

classDiagram
    class MemoryContext {
      +str end_user_id
      +MemoryConfig memory_config
      +StorageType storage_type
      +str user_rag_memory_id
      +str language
    }

    class Memory {
      +Neo4jNodeType source
      +float score
      +str content
      +dict data
      +str query
      +serialize_source(v)
    }

    class MemorySearchResult {
      +list~Memory~ memories
      +str content
      +int count
    }

    class StorageType {
    <<enumeration>>
      NEO4J
      RAG
    }

    class SearchStrategy {
    <<enumeration>>
      DEEP
      NORMAL
      QUICK
    }

    class Neo4jNodeType {
    <<enumeration>>
      CHUNK
      COMMUNITY
      DIALOGUE
      EXTRACTEDENTITY
      MEMORYSUMMARY
      PERCEPTUAL
      STATEMENT
    }

    class MemoryService {
      -MemoryContext ctx
      +MemoryService(db, config_id, end_user_id, workspace_id, storage_type, user_rag_memory_id, language)
      +write(messages) async
      +read(query, search_switch, limit) async MemorySearchResult
      +forget(max_batch, min_days) async dict
      +reflect() async dict
      +cluster(new_entity_ids) async None
    }

    class BasePipeline {
      +MemoryContext ctx
      +run(args, kwargs) async
    }

    class DBRequiredPipeline {
      +Session db
    }

    class ModelClientMixin {
      +get_llm_client(db, model_id) RedBearLLM
      +get_embedding_client(db, model_id) RedBearEmbeddings
    }

    class ReadPipeLine {
      +run(query, search_switch, limit, includes) async MemorySearchResult
      +_rag_read(query, limit) async MemorySearchResult
      +_deep_read(query, limit, includes) async MemorySearchResult
      +_normal_read(query, limit, includes) async MemorySearchResult
      +_quick_read(query, limit, includes) async MemorySearchResult
    }

    class Neo4jSearchService {
      -MemoryContext ctx
      -RedBearEmbeddings embedder
      -Neo4jConnector connector
      -list~Neo4jNodeType~ includes
      -float alpha
      -float fulltext_score_threshold
      -float cosine_score_threshold
      -float content_score_threshold
      +Neo4jSearchService(ctx, embedder, includes, alpha, fulltext_score_threshold, cosine_score_threshold, content_score_threshold)
      +search(query, limit) async MemorySearchResult
      -_keyword_search(query, limit) async
      -_embedding_search(query, limit) async
      -_rerank(keyword_results, embedding_results, limit) list~dict~
      -_normalize_kw_scores(items) list~dict~
    }

    class RAGSearchService {
      +RAGSearchService(ctx)
      +search() async MemorySearchResult
    }

    class QueryPreprocessor {
      +process(query) str
      +split(query, llm_client) async
      +extension(query, llm_client) async
    }

    class BaseBuilder {
      +dict record
      +data dict
      +content str
      +score float
    }

    class ChunkBuilder {
      +data dict
      +content str
    }

    class StatementBuiler {
      +data dict
      +content str
    }

    class EntityBuilder {
      +data dict
      +content str
    }

    class SummaryBuilder {
      +data dict
      +content str
    }

    class PerceptualBuilder {
      +data dict
      +content str
    }

    class CommunityBuilder {
      +data dict
      +content str
    }

    class data_builder_factory {
      +data_builder_factory(node_type, data) BaseBuilder
    }

    class PromptManager {
      +get(name) str
      +render(name, kwargs) str
      +list_templates() list~str~
    }

    class QueryPreprocessorDependencies {
      RedBearLLM
      AgentMemoryDataset
    }

    MemoryService --> MemoryContext
    MemoryService ..> MemorySearchResult
    MemoryService ..> ReadPipeLine

    BasePipeline <|-- DBRequiredPipeline
    BasePipeline <|-- ReadPipeLine
    ModelClientMixin <|-- ReadPipeLine

    ReadPipeLine --> MemoryContext
    ReadPipeLine --> Neo4jSearchService
    ReadPipeLine --> RAGSearchService
    ReadPipeLine ..> QueryPreprocessor

    Neo4jSearchService --> MemoryContext
    Neo4jSearchService --> Neo4jNodeType
    Neo4jSearchService ..> MemorySearchResult

    RAGSearchService --> MemoryContext

    MemoryContext --> MemoryConfig
    MemoryContext --> StorageType

    MemorySearchResult --> Memory

    BaseBuilder <|-- ChunkBuilder
    BaseBuilder <|-- StatementBuiler
    BaseBuilder <|-- EntityBuilder
    BaseBuilder <|-- SummaryBuilder
    BaseBuilder <|-- PerceptualBuilder
    BaseBuilder <|-- CommunityBuilder

    data_builder_factory ..> BaseBuilder
    data_builder_factory ..> Neo4jNodeType

    QueryPreprocessor ..> PromptManager
    QueryPreprocessor ..> QueryPreprocessorDependencies

    StructResponse ..> RedBearLLM

    class StructResponse {
      +mode
      +model
      +StructResponse(mode, model)
      +__ror__(other)
    }
Loading

重构后的 Neo4j 图搜索工具类图

classDiagram
    class Neo4jConnector {
      +Neo4jConnector()
      +__aenter__() async Neo4jConnector
      +__aexit__(exc_type, exc_val, exc_tb) async
      +close() async
      +execute_query(cypher, json_format, kwargs) async list~dict~
    }

    class GraphSearchModule {
      +cosine_similarity_search(query, vectors, limit) dict~int,float~
      +search_perceptual_by_fulltext(connector, query, end_user_id, limit) async dict
      +search_perceptual_by_embedding(connector, embedder_client, query_text, end_user_id, limit) async dict
      +search_by_fulltext(connector, node_type, end_user_id, query, limit) async list~dict~
      +search_by_embedding(connector, node_type, end_user_id, query_embedding, limit) async list~dict~
      +search_graph(connector, query, end_user_id, limit, include) async dict
      +search_graph_by_embedding(connector, embedder_client, query_text, end_user_id, limit, include) async dict
      +search_graph_community_expand(args) async dict
    }

    class Neo4jNodeType {
    <<enumeration>>
      CHUNK
      COMMUNITY
      DIALOGUE
      EXTRACTEDENTITY
      MEMORYSUMMARY
      PERCEPTUAL
      STATEMENT
    }

    class FULLTEXT_QUERY_CYPHER_MAPPING {
      +STATEMENT
      +EXTRACTEDENTITY
      +CHUNK
      +MEMORYSUMMARY
      +COMMUNITY
      +PERCEPTUAL
    }

    class USER_ID_QUERY_CYPHER_MAPPING {
      +STATEMENT
      +EXTRACTEDENTITY
      +CHUNK
      +MEMORYSUMMARY
      +COMMUNITY
      +PERCEPTUAL
    }

    class NODE_ID_QUERY_CYPHER_MAPPING {
      +STATEMENT
      +EXTRACTEDENTITY
      +CHUNK
      +MEMORYSUMMARY
      +COMMUNITY
      +PERCEPTUAL
    }

    class OpenAIEmbedderClient {
      +response(texts) async list~list~float~~
    }

    class RedBearEmbeddings {
      +embed_documents(texts) list~list~float~~
    }

    class SearchDeduplication {
      +deduplicate_results(items) list~dict~
    }

    GraphSearchModule --> Neo4jConnector
    GraphSearchModule --> Neo4jNodeType
    GraphSearchModule --> FULLTEXT_QUERY_CYPHER_MAPPING
    GraphSearchModule --> USER_ID_QUERY_CYPHER_MAPPING
    GraphSearchModule --> NODE_ID_QUERY_CYPHER_MAPPING

    GraphSearchModule ..> OpenAIEmbedderClient
    GraphSearchModule ..> RedBearEmbeddings

    GraphSearchModule ..> SearchDeduplication
    SearchDeduplication <.. Neo4jSearchService

    FULLTEXT_QUERY_CYPHER_MAPPING --> Neo4jNodeType
    USER_ID_QUERY_CYPHER_MAPPING --> Neo4jNodeType
    NODE_ID_QUERY_CYPHER_MAPPING --> Neo4jNodeType

    Neo4jConnector ..> Neo4jDB
    class Neo4jDB {
      <<external>>
    }
Loading

文件级变更

Change Details Files
使用映射字典统一和泛化 Neo4j 搜索 Cypher 查询,并支持在各节点类型上按用户 / ID / 全文检索。
  • 移除若干硬编码的向量及全文搜索查询,并按功能(全文 / 按用户 ID / 按节点 ID)重新分组引入。
  • 为 Statements、Chunks、Entities、MemorySummaries、Perceptuals 和 Communities 引入 SEARCH_BY_USER_ID 与 SEARCH_BY_IDS 查询。
  • 新增 FULLTEXT_QUERY_CYPHER_MAPPING、USER_ID_QUERY_CYPHER_MAPPING 与 NODE_ID_QUERY_CYPHER_MAPPING,并以 Neo4jNodeType 作为键。
  • 在 cypher_queries 中导入 Neo4jNodeType 以支持基于枚举的映射键。
api/app/repositories/neo4j/cypher_queries.py
重构 graph_search,使用基于枚举的 include 列表、共享的余弦相似度 + 通用向量/全文搜索辅助函数,并返回带去重和激活更新的结构化结果。
  • 新增基于 NumPy 的 cosine_similarity_search 工具,用于在内存向量上打分。
  • 新增 search_by_fulltext 和 search_by_embedding 辅助函数,通过映射字典分发,并使用新的 *_BY_USER_ID / *_BY_IDS 查询。
  • 更改 search_graph 和 search_graph_by_embedding 的签名,使其接受/包含 Neo4jNodeType 枚举而非字符串键,并在遍历 include 时通过辅助函数调度任务。
  • 更新激活更新逻辑和去重逻辑,以 Neo4jNodeType 作为键,并使用重命名后的 deduplicate_results 函数。
  • 重构感知(perceptual)搜索函数以使用新的辅助函数,并移除基于 PERCEPTUAL_EMBEDDING_SEARCH 的遗留实现。
  • 更新调用方(perceptual_retrieve_node, search_service),改为调用 search_perceptual_by_fulltext,并使用基于 Neo4jNodeType 的类别。
api/app/repositories/neo4j/graph_search.py
api/app/core/memory/agent/services/search_service.py
api/app/core/memory/agent/langgraph_graph/nodes/perceptual_retrieve_node.py
api/app/core/memory/src/search.py
引入围绕 MemoryContext 的新记忆读取/搜索流水线抽象,在快速策略下使用 Neo4jSearchService,并为 RAG / deep / normal 策略打好基础。
  • 新增 MemoryContext、Memory 和 MemorySearchResult 模型,用于封装记忆配置、搜索结果和序列化。
  • 新增 StorageType、SearchStrategy、Neo4jStorageStrategy 和 Neo4jNodeType 枚举,以标准化存储/搜索模式和节点类型。
  • 实现 BasePipeline/DBRequiredPipeline,并配合 ModelClientMixin,用于通过 RedBear 模型和现有模型 API 统一初始化 LLM/Embedding 客户端。
  • 新增 Neo4jSearchService,通过 search_graph/search_graph_by_embedding 并行执行 Neo4j 的关键词和向量搜索,使用 alpha 加权合并得分、归一化关键词得分,并通过构建器类将记录转换为 Memory 对象。
  • 新增 ReadPipeLine,根据 SearchStrategy 和 StorageType 在 Neo4J 快速搜索、normal/deep(占位)以及未来的 RAG 搜索之间进行选择,并使用 QueryPreprocessor 清洗查询。
  • 引入 RAGSearchService 和检索摘要脚手架,作为未来实现的占位。
api/app/core/memory/memory_service.py
api/app/core/memory/pipelines/base_pipeline.py
api/app/core/memory/pipelines/memory_read.py
api/app/core/memory/models/service_models.py
api/app/core/memory/read_services/content_search.py
api/app/core/memory/read_services/result_builder.py
api/app/core/memory/read_services/query_preprocessor.py
api/app/core/memory/read_services/retrieval_summary.py
api/app/core/memory/enums.py
新增集中式提示管理和问题拆分的 Jinja2 提示模板,以及用于解析 LLM JSON 输出的类结构化响应辅助工具。
  • 新增 PromptManager 单例,加载 Jinja2 模板,暴露 list/get/render,出错时通过 PromptRenderError 处理。
  • 新增 problem_split.jinja2,定义了关于问题类型分类、拆分以及仅输出 JSON 的详细指令,包括指代消解规则。
  • 扩展 llm_utils,引入 StructResponse,使用 json_repair 将 AIMessage 内容解析为 JSON 或 Pydantic 模型,并清理 MemoryClientFactory 的辅助方法。
  • 将 QueryPreprocessor.split 接入 prompt_manager 的 problem_split,并通过 StructResponse 获取结构化子查询。
api/app/core/memory/prompt/__init__.py
api/app/core/memory/prompt/problem_split.jinja2
api/app/core/memory/utils/llm/llm_utils.py
api/app/core/memory/read_services/query_preprocessor.py
清理废弃的记忆搜索/存储抽象以及一些基础设施调整。
  • 移除旧的关键字/语义/混合搜索策略类和接口(位于 storage_services/search),这些已经被新流水线/搜索服务取代。
  • 移除 KeywordSearchStrategy 及相关用法,并相应调整 import 和调用点。
  • 为 Neo4jConnector 添加异步上下文管理器支持,以便与 async with 配合使用,并将 execute_query 的参数名从 query 重命名为 cypher 以提高清晰度。
  • 修复若干小问题:变更 FileType 的导入路径,调整 prompt_optimizer_system 中的提示文案,进行少量格式调整,并移除未使用的 write_perceptual_memory Celery 任务路由。
api/app/core/memory/storage_services/search/keyword_search.py
api/app/core/memory/storage_services/search/__init__.py
api/app/core/memory/storage_services/search/hybrid_search.py
api/app/core/memory/storage_services/search/search_strategy.py
api/app/core/memory/storage_services/search/semantic_search.py
api/app/repositories/neo4j/neo4j_connector.py
api/app/models/memory_perceptual_model.py
api/app/services/prompt/prompt_optimizer_system.jinja2
api/app/celery_app.py
api/app/core/memory/llm_tools/chunker_client.py
api/app/core/memory/agent/langgraph_graph/read_graph.py
api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py

Tips and commands

Interacting with Sourcery

  • 触发新的 Review: 在 Pull Request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的 Review 评论。
  • 从 Review 评论生成 GitHub Issue: 在某条 Review 评论下回复,请 Sourcery 从该评论创建一个 Issue。你也可以直接回复 @sourcery-ai issue,从该评论生成 Issue。
  • 生成 Pull Request 标题: 在 Pull Request 标题的任意位置写上 @sourcery-ai,即可随时生成标题。也可以在 Pull Request 中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成 Pull Request 摘要: 在 Pull Request 正文任意位置写上 @sourcery-ai summary,即可在对应位置生成 PR 摘要。也可以评论 @sourcery-ai summary 来在任意时间(重新)生成摘要。
  • 生成 Reviewer's Guide: 在 Pull Request 中评论 @sourcery-ai guide,即可随时(重新)生成 Reviewer's Guide。
  • 一次性解决所有 Sourcery 评论: 在 Pull Request 中评论 @sourcery-ai resolve,即可将所有 Sourcery 评论标记为已解决。对已经处理完评论且不想再看到它们时很有用。
  • 清除所有 Sourcery Review: 在 Pull Request 中评论 @sourcery-ai dismiss,即可清除所有现有的 Sourcery Review。特别适合想从头开始新的 Review——别忘了之后再评论 @sourcery-ai review 触发新一轮 Review!

Customizing Your Experience

访问你的 控制面板 可以:

  • 启用或禁用 Sourcery 生成的 Pull Request 摘要、Reviewer's Guide 等 Review 功能。
  • 修改 Review 语言。
  • 添加、删除或编辑自定义 Review 说明。
  • 调整其他 Review 设置。

Getting Help

Original review guide in English

Reviewer's Guide

Refactors memory search across Neo4j to use enum-based node types and centralized query mappings, adds a new Neo4j-based search/read pipeline and prompt infrastructure, and unifies model client initialization while replacing old search strategy classes.

Sequence diagram for quick Neo4j memory search read path

sequenceDiagram
    actor Caller
    participant MemoryService
    participant ReadPipeLine
    participant QueryPreprocessor
    participant Neo4jSearchService
    participant Embedder as RedBearEmbeddings
    participant GraphSearch
    participant Neo4jConnector
    participant Neo4jDB

    Caller->>MemoryService: read(query, search_switch=QUICK, limit)
    MemoryService->>ReadPipeLine: run(query, search_switch, limit, includes)

    ReadPipeLine->>QueryPreprocessor: process(query)
    QueryPreprocessor-->>ReadPipeLine: cleaned_query

    alt storage_type == RAG
        ReadPipeLine->>RAGSearchService: search() async
        RAGSearchService-->>ReadPipeLine: MemorySearchResult
        ReadPipeLine-->>MemoryService: MemorySearchResult
        MemoryService-->>Caller: MemorySearchResult
    else storage_type == NEO4J and search_switch == QUICK
        ReadPipeLine->>ModelClientMixin: get_embedding_client(db, embedding_model_id)
        ModelClientMixin-->>ReadPipeLine: embedder_client

        ReadPipeLine->>Neo4jSearchService: Neo4jSearchService(ctx, embedder_client, includes)
        ReadPipeLine->>Neo4jSearchService: search(cleaned_query, limit)

        par keyword search
            Neo4jSearchService->>Neo4jConnector: __aenter__()
            Neo4jConnector-->>Neo4jSearchService: connector

            Neo4jSearchService->>GraphSearch: search_graph(connector, query, end_user_id, limit, includes)
            loop for node_type in includes
                GraphSearch->>Neo4jConnector: execute_query(cypher=FULLTEXT_QUERY_CYPHER_MAPPING[node_type], json_format=True, end_user_id, query, limit)
                Neo4jConnector->>Neo4jDB: execute_query(cypher, params)
                Neo4jDB-->>Neo4jConnector: records
                Neo4jConnector-->>GraphSearch: formatted_records
            end
            GraphSearch-->>Neo4jSearchService: keyword_results per node_type
        and embedding search
            Neo4jSearchService->>Embedder: embed_documents([cleaned_query])
            Embedder-->>Neo4jSearchService: query_embedding

            Neo4jSearchService->>GraphSearch: search_graph_by_embedding(connector, embedder_client, query_text, end_user_id, limit, includes)
            loop for node_type in includes
                GraphSearch->>Neo4jConnector: execute_query(cypher=USER_ID_QUERY_CYPHER_MAPPING[node_type], end_user_id)
                Neo4jConnector->>Neo4jDB: execute_query(cypher, params)
                Neo4jDB-->>Neo4jConnector: embedding_records
                Neo4jConnector-->>GraphSearch: embedding_records

                GraphSearch->>GraphSearch: cosine_similarity_search(query_embedding, vectors, limit)
                GraphSearch->>Neo4jConnector: execute_query(cypher=NODE_ID_QUERY_CYPHER_MAPPING[node_type], ids, json_format=True)
                Neo4jConnector->>Neo4jDB: execute_query(cypher, params)
                Neo4jDB-->>Neo4jConnector: node_records
                Neo4jConnector-->>GraphSearch: node_records with scores
            end
            GraphSearch-->>Neo4jSearchService: embedding_results per node_type
        end

        Neo4jSearchService->>Neo4jConnector: __aexit__()

        loop for node_type in includes
            Neo4jSearchService->>Neo4jSearchService: _rerank(keyword_results[node_type], embedding_results[node_type], limit)
            Neo4jSearchService->>data_builder_factory: data_builder_factory(node_type, record)
            data_builder_factory-->>Neo4jSearchService: builder
            Neo4jSearchService->>Neo4jSearchService: build Memory objects
        end

        Neo4jSearchService-->>ReadPipeLine: MemorySearchResult
        ReadPipeLine-->>MemoryService: MemorySearchResult
        MemoryService-->>Caller: MemorySearchResult
    end
Loading

Class diagram for the new memory read pipeline and search services

classDiagram
    class MemoryContext {
      +str end_user_id
      +MemoryConfig memory_config
      +StorageType storage_type
      +str user_rag_memory_id
      +str language
    }

    class Memory {
      +Neo4jNodeType source
      +float score
      +str content
      +dict data
      +str query
      +serialize_source(v)
    }

    class MemorySearchResult {
      +list~Memory~ memories
      +str content
      +int count
    }

    class StorageType {
    <<enumeration>>
      NEO4J
      RAG
    }

    class SearchStrategy {
    <<enumeration>>
      DEEP
      NORMAL
      QUICK
    }

    class Neo4jNodeType {
    <<enumeration>>
      CHUNK
      COMMUNITY
      DIALOGUE
      EXTRACTEDENTITY
      MEMORYSUMMARY
      PERCEPTUAL
      STATEMENT
    }

    class MemoryService {
      -MemoryContext ctx
      +MemoryService(db, config_id, end_user_id, workspace_id, storage_type, user_rag_memory_id, language)
      +write(messages) async
      +read(query, search_switch, limit) async MemorySearchResult
      +forget(max_batch, min_days) async dict
      +reflect() async dict
      +cluster(new_entity_ids) async None
    }

    class BasePipeline {
      +MemoryContext ctx
      +run(args, kwargs) async
    }

    class DBRequiredPipeline {
      +Session db
    }

    class ModelClientMixin {
      +get_llm_client(db, model_id) RedBearLLM
      +get_embedding_client(db, model_id) RedBearEmbeddings
    }

    class ReadPipeLine {
      +run(query, search_switch, limit, includes) async MemorySearchResult
      +_rag_read(query, limit) async MemorySearchResult
      +_deep_read(query, limit, includes) async MemorySearchResult
      +_normal_read(query, limit, includes) async MemorySearchResult
      +_quick_read(query, limit, includes) async MemorySearchResult
    }

    class Neo4jSearchService {
      -MemoryContext ctx
      -RedBearEmbeddings embedder
      -Neo4jConnector connector
      -list~Neo4jNodeType~ includes
      -float alpha
      -float fulltext_score_threshold
      -float cosine_score_threshold
      -float content_score_threshold
      +Neo4jSearchService(ctx, embedder, includes, alpha, fulltext_score_threshold, cosine_score_threshold, content_score_threshold)
      +search(query, limit) async MemorySearchResult
      -_keyword_search(query, limit) async
      -_embedding_search(query, limit) async
      -_rerank(keyword_results, embedding_results, limit) list~dict~
      -_normalize_kw_scores(items) list~dict~
    }

    class RAGSearchService {
      +RAGSearchService(ctx)
      +search() async MemorySearchResult
    }

    class QueryPreprocessor {
      +process(query) str
      +split(query, llm_client) async
      +extension(query, llm_client) async
    }

    class BaseBuilder {
      +dict record
      +data dict
      +content str
      +score float
    }

    class ChunkBuilder {
      +data dict
      +content str
    }

    class StatementBuiler {
      +data dict
      +content str
    }

    class EntityBuilder {
      +data dict
      +content str
    }

    class SummaryBuilder {
      +data dict
      +content str
    }

    class PerceptualBuilder {
      +data dict
      +content str
    }

    class CommunityBuilder {
      +data dict
      +content str
    }

    class data_builder_factory {
      +data_builder_factory(node_type, data) BaseBuilder
    }

    class PromptManager {
      +get(name) str
      +render(name, kwargs) str
      +list_templates() list~str~
    }

    class QueryPreprocessorDependencies {
      RedBearLLM
      AgentMemoryDataset
    }

    MemoryService --> MemoryContext
    MemoryService ..> MemorySearchResult
    MemoryService ..> ReadPipeLine

    BasePipeline <|-- DBRequiredPipeline
    BasePipeline <|-- ReadPipeLine
    ModelClientMixin <|-- ReadPipeLine

    ReadPipeLine --> MemoryContext
    ReadPipeLine --> Neo4jSearchService
    ReadPipeLine --> RAGSearchService
    ReadPipeLine ..> QueryPreprocessor

    Neo4jSearchService --> MemoryContext
    Neo4jSearchService --> Neo4jNodeType
    Neo4jSearchService ..> MemorySearchResult

    RAGSearchService --> MemoryContext

    MemoryContext --> MemoryConfig
    MemoryContext --> StorageType

    MemorySearchResult --> Memory

    BaseBuilder <|-- ChunkBuilder
    BaseBuilder <|-- StatementBuiler
    BaseBuilder <|-- EntityBuilder
    BaseBuilder <|-- SummaryBuilder
    BaseBuilder <|-- PerceptualBuilder
    BaseBuilder <|-- CommunityBuilder

    data_builder_factory ..> BaseBuilder
    data_builder_factory ..> Neo4jNodeType

    QueryPreprocessor ..> PromptManager
    QueryPreprocessor ..> QueryPreprocessorDependencies

    StructResponse ..> RedBearLLM

    class StructResponse {
      +mode
      +model
      +StructResponse(mode, model)
      +__ror__(other)
    }
Loading

Class diagram for refactored Neo4j graph search utilities

classDiagram
    class Neo4jConnector {
      +Neo4jConnector()
      +__aenter__() async Neo4jConnector
      +__aexit__(exc_type, exc_val, exc_tb) async
      +close() async
      +execute_query(cypher, json_format, kwargs) async list~dict~
    }

    class GraphSearchModule {
      +cosine_similarity_search(query, vectors, limit) dict~int,float~
      +search_perceptual_by_fulltext(connector, query, end_user_id, limit) async dict
      +search_perceptual_by_embedding(connector, embedder_client, query_text, end_user_id, limit) async dict
      +search_by_fulltext(connector, node_type, end_user_id, query, limit) async list~dict~
      +search_by_embedding(connector, node_type, end_user_id, query_embedding, limit) async list~dict~
      +search_graph(connector, query, end_user_id, limit, include) async dict
      +search_graph_by_embedding(connector, embedder_client, query_text, end_user_id, limit, include) async dict
      +search_graph_community_expand(args) async dict
    }

    class Neo4jNodeType {
    <<enumeration>>
      CHUNK
      COMMUNITY
      DIALOGUE
      EXTRACTEDENTITY
      MEMORYSUMMARY
      PERCEPTUAL
      STATEMENT
    }

    class FULLTEXT_QUERY_CYPHER_MAPPING {
      +STATEMENT
      +EXTRACTEDENTITY
      +CHUNK
      +MEMORYSUMMARY
      +COMMUNITY
      +PERCEPTUAL
    }

    class USER_ID_QUERY_CYPHER_MAPPING {
      +STATEMENT
      +EXTRACTEDENTITY
      +CHUNK
      +MEMORYSUMMARY
      +COMMUNITY
      +PERCEPTUAL
    }

    class NODE_ID_QUERY_CYPHER_MAPPING {
      +STATEMENT
      +EXTRACTEDENTITY
      +CHUNK
      +MEMORYSUMMARY
      +COMMUNITY
      +PERCEPTUAL
    }

    class OpenAIEmbedderClient {
      +response(texts) async list~list~float~~
    }

    class RedBearEmbeddings {
      +embed_documents(texts) list~list~float~~
    }

    class SearchDeduplication {
      +deduplicate_results(items) list~dict~
    }

    GraphSearchModule --> Neo4jConnector
    GraphSearchModule --> Neo4jNodeType
    GraphSearchModule --> FULLTEXT_QUERY_CYPHER_MAPPING
    GraphSearchModule --> USER_ID_QUERY_CYPHER_MAPPING
    GraphSearchModule --> NODE_ID_QUERY_CYPHER_MAPPING

    GraphSearchModule ..> OpenAIEmbedderClient
    GraphSearchModule ..> RedBearEmbeddings

    GraphSearchModule ..> SearchDeduplication
    SearchDeduplication <.. Neo4jSearchService

    FULLTEXT_QUERY_CYPHER_MAPPING --> Neo4jNodeType
    USER_ID_QUERY_CYPHER_MAPPING --> Neo4jNodeType
    NODE_ID_QUERY_CYPHER_MAPPING --> Neo4jNodeType

    Neo4jConnector ..> Neo4jDB
    class Neo4jDB {
      <<external>>
    }
Loading

File-Level Changes

Change Details Files
Consolidate and generalize Neo4j search Cypher queries with mapping dictionaries and support for user/id/fulltext lookups across node types.
  • Remove several hard-coded embedding and fulltext search queries and reintroduce them grouped by function (fulltext, by-user-id, by-node-ids).
  • Introduce SEARCH_BY_USER_ID and SEARCH_BY_IDS queries for Statements, Chunks, Entities, MemorySummaries, Perceptuals, and Communities.
  • Add FULLTEXT_QUERY_CYPHER_MAPPING, USER_ID_QUERY_CYPHER_MAPPING, and NODE_ID_QUERY_CYPHER_MAPPING keyed by Neo4jNodeType.
  • Import Neo4jNodeType into cypher_queries to support enum-based mapping keys.
api/app/repositories/neo4j/cypher_queries.py
Refactor graph_search to use enum-based include lists, shared cosine similarity + generic embedding/fulltext search helpers, and return structured results with deduplication and activation updates.
  • Add cosine_similarity_search utility using NumPy to score in-memory vectors.
  • Add search_by_fulltext and search_by_embedding helpers that dispatch via mapping dicts and use new *_BY_USER_ID / *_BY_IDS queries.
  • Change search_graph and search_graph_by_embedding to accept/include Neo4jNodeType enums instead of string keys, iterating over include to schedule tasks via the helpers.
  • Update activation-update logic and deduplication to use Neo4jNodeType keys and the renamed deduplicate_results function.
  • Refactor perceptual search functions to use new helpers and remove legacy implementations based on PERCEPTUAL_EMBEDDING_SEARCH.
  • Update consumers (perceptual_retrieve_node, search_service) to call search_perceptual_by_fulltext and use Neo4jNodeType-based categories.
api/app/repositories/neo4j/graph_search.py
api/app/core/memory/agent/services/search_service.py
api/app/core/memory/agent/langgraph_graph/nodes/perceptual_retrieve_node.py
api/app/core/memory/src/search.py
Introduce a new memory read/search pipeline abstraction around MemoryContext, using Neo4jSearchService for quick strategy and laying groundwork for RAG/deep/normal strategies.
  • Add MemoryContext, Memory, and MemorySearchResult models to encapsulate memory configuration, search results, and serialization.
  • Add StorageType, SearchStrategy, Neo4jStorageStrategy, and Neo4jNodeType enums to standardize storage/search modes and node types.
  • Implement BasePipeline/DBRequiredPipeline with ModelClientMixin for consistent LLM/embedding client initialization via RedBear models and existing model APIs.
  • Add Neo4jSearchService that runs parallel keyword and embedding search over Neo4j via search_graph/search_graph_by_embedding, merges scores (alpha-weighted), normalizes keyword scores, and converts records to Memory objects via builder classes.
  • Add ReadPipeLine that chooses between Neo4J quick search, normal/deep (placeholders), and future RAG-based search based on SearchStrategy and StorageType, using a QueryPreprocessor to clean queries.
  • Introduce RAGSearchService and retrieval summary scaffolding as placeholders for future implementation.
api/app/core/memory/memory_service.py
api/app/core/memory/pipelines/base_pipeline.py
api/app/core/memory/pipelines/memory_read.py
api/app/core/memory/models/service_models.py
api/app/core/memory/read_services/content_search.py
api/app/core/memory/read_services/result_builder.py
api/app/core/memory/read_services/query_preprocessor.py
api/app/core/memory/read_services/retrieval_summary.py
api/app/core/memory/enums.py
Add centralized prompt management and a problem-splitting Jinja2 prompt template, plus a struct-like response helper for parsing LLM JSON outputs.
  • Add PromptManager singleton that loads Jinja2 templates, exposes list/get/render, and handles errors via PromptRenderError.
  • Add problem_split.jinja2 defining detailed instructions for question type classification, splitting, and JSON-only output, including coreference rules.
  • Extend llm_utils with StructResponse to parse AIMessage content into JSON or Pydantic models using json_repair, and add MemoryClientFactory helper methods cleanups.
  • Wire QueryPreprocessor.split to use problem_split via prompt_manager and StructResponse to obtain structured sub-queries.
api/app/core/memory/prompt/__init__.py
api/app/core/memory/prompt/problem_split.jinja2
api/app/core/memory/utils/llm/llm_utils.py
api/app/core/memory/read_services/query_preprocessor.py
Clean up obsolete memory search/storage abstractions and minor infra adjustments.
  • Remove old keyword/semantic/hybrid search strategy classes and interfaces under storage_services/search now superseded by the new pipeline/search services.
  • Remove KeywordSearchStrategy and related usage; adjust imports and call sites accordingly.
  • Add async context manager support to Neo4jConnector for use with async with and rename execute_query parameter from query to cypher for clarity.
  • Fix various minor issues: change FileType import path, adjust prompt wording in prompt_optimizer_system, minor formatting adjustments, and remove unused Celery task routing for write_perceptual_memory.
api/app/core/memory/storage_services/search/keyword_search.py
api/app/core/memory/storage_services/search/__init__.py
api/app/core/memory/storage_services/search/hybrid_search.py
api/app/core/memory/storage_services/search/search_strategy.py
api/app/core/memory/storage_services/search/semantic_search.py
api/app/repositories/neo4j/neo4j_connector.py
api/app/models/memory_perceptual_model.py
api/app/services/prompt/prompt_optimizer_system.jinja2
api/app/celery_app.py
api/app/core/memory/llm_tools/chunker_client.py
api/app/core/memory/agent/langgraph_graph/read_graph.py
api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 8 个问题,并给出了一些高层次的反馈:

  • 从基于字符串的类型键迁移到 Neo4jNodeType 枚举在多个地方不一致(例如 search_graph.search_graphsearch_graph_by_embeddingexecute_hybrid_searchrerank_with_activation):结果字典和 Cypher 映射仍然使用字符串键(node_type.value),而 include/循环变量使用的是枚举,因此像 key in includecategory in answer 这样的成员检查现在会始终失败,从而跳过激活更新和结果聚合——需要统一键(要么全部用枚举,要么全部用字符串),或者在读/写时做一次规范化。
  • SearchService.extract_content_from_result 中,你将 if 'statement' in result 改成了 if Neo4jNodeType.STATEMENT in result,但结果字典仍然是来自 Cypher 的 'statement' 字符串键;这个条件永远不会为真,从而导致 statement 内容被丢弃——要么保留字符串键,要么先把字典键映射到基于枚举的结构后再访问。
  • cosine_similarity_search 没有对零范数向量(查询或存储的 embedding)做保护,这会在退化或未初始化的 embedding 情况下导致除零和 NaN;建议在归一化前过滤掉零向量,或者在范数上加一个 epsilon。
给 AI Agent 的提示
Please address the comments from this code review:

## Overall Comments
- 从基于字符串的类型键迁移到 Neo4jNodeType 枚举在多个地方不一致(例如 search_graph.search_graph、search_graph_by_embedding、execute_hybrid_search、rerank_with_activation):结果字典和 Cypher 映射仍然使用字符串键(node_type.value),而 include/循环变量使用的是枚举,因此像 `key in include``category in answer` 这样的成员检查现在会始终失败,从而跳过激活更新和结果聚合——需要统一键(要么全部用枚举,要么全部用字符串),或者在读/写时做一次规范化。
- 在 SearchService.extract_content_from_result 中,你将 `if 'statement' in result` 改成了 `if Neo4jNodeType.STATEMENT in result`,但结果字典仍然是来自 Cypher 的 'statement' 字符串键;这个条件永远不会为真,从而导致 statement 内容被丢弃——要么保留字符串键,要么先把字典键映射到基于枚举的结构后再访问。
- cosine_similarity_search 没有对零范数向量(查询或存储的 embedding)做保护,这会在退化或未初始化的 embedding 情况下导致除零和 NaN;建议在归一化前过滤掉零向量,或者在范数上加一个 epsilon。

## Individual Comments

### Comment 1
<location path="api/app/repositories/neo4j/graph_search.py" line_range="38-47" />
<code_context>
 logger = logging.getLogger(__name__)


+def cosine_similarity_search(
+        query: list[float],
+        vectors: list[list[float]],
+        limit: int
+) -> dict[int, float]:
+    if not vectors:
+        return {}
+    vectors: np.ndarray = np.array(vectors, dtype=np.float32)
+    vectors_norm = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
+    query: np.ndarray = np.array(query, dtype=np.float32)
+    query_norm = query / np.linalg.norm(query)
+
+    similarities = vectors_norm @ query_norm
+    similarities = np.clip(similarities, 0, 1)
+    top_k = min(limit, similarities.shape[0])
</code_context>
<issue_to_address>
**issue:** 在 cosine_similarity_search 中需要对零范数向量做保护,以避免除零和 NaN。

在 `cosine_similarity_search` 中,`vectors_norm = vectors / np.linalg.norm(...``query_norm = query / np.linalg.norm(query)` 都会在向量或查询全为 0 时发生除零,从而产生 NaN 并导致排序无效。请显式处理零范数情况,例如通过过滤掉零范数行,或在范数上加一个下限 epsilon(`norm = np.where(norm == 0, 1, norm)`),并在查询范数为 0 时提前返回。
</issue_to_address>

### Comment 2
<location path="api/app/repositories/neo4j/graph_search.py" line_range="358-362" />
<code_context>
+        limit: int = 10,
+) -> list[dict[str, Any]]:
+    try:
+        records = await connector.execute_query(
+            USER_ID_QUERY_CYPHER_MAPPING[node_type],
+            end_user_id=end_user_id,
+        )
+        records = [record for record in records if record if record["embedding"] is not None]
+        ids = [item['id'] for item in records]
+        vectors = [item['embedding'] for item in records]
</code_context>
<issue_to_address>
**issue (bug_risk):** 修正列表推导式的过滤逻辑,避免在 records 包含 None 或缺少 'embedding' 时崩溃。

当前推导式 `records = [record for record in records if record if record["embedding"] is not None]` 存在两个问题:
- `if record if record[...]` 的语法很怪异,而且仍然会在 `record` 为 falsy 时访问 `record["embedding"]`,如果 `record``None` 会失败。
- 直接访问 `record["embedding"]` 在键缺失时会抛出 `KeyError`。
可以改成:
```python
records = [
    r for r in records
    if r is not None and r.get("embedding") is not None
]
```
</issue_to_address>

### Comment 3
<location path="api/app/repositories/neo4j/graph_search.py" line_range="169-175" />
<code_context>
     knowledge_node_types = {
         'statements': 'Statement',
         'entities': 'ExtractedEntity',
-        'summaries': 'MemorySummary'
+        'summaries': 'MemorySummary',
+        Neo4jNodeType.STATEMENT: Neo4jNodeType.STATEMENT.value,
+        Neo4jNodeType.EXTRACTEDENTITY: Neo4jNodeType.EXTRACTEDENTITY.value,
+        Neo4jNodeType.MEMORYSUMMARY: Neo4jNodeType.MEMORYSUMMARY.value,
     }

</code_context>
<issue_to_address>
**issue (bug_risk):** 在 knowledge_node_types 中混用字符串键和 Neo4jNodeType 键会导致激活更新永远不会触发。

在 `_update_search_results_activation` 中,`knowledge_node_types` 现在混用了字符串键(如 `'statements'`)和枚举键(如 `Neo4jNodeType.STATEMENT`)。但 `needs_activation_update` 的检查是:
```python
needs_activation_update = any(
    key in include and key in results and results[key]
    for key in ['statements', 'entities', 'chunks']
)
```
`include``List[Neo4jNodeType]`,而 `results` 的键是 `node_type.value`(例如 `'Statement'`),因此这些字符串字面量永远不会匹配,激活更新这条路径实际上被废掉了。需要通过采用单一的键方案来修复(例如到处都使用 `Neo4jNodeType`),并且始终通过 `node_type.value` 来索引 `results`。
</issue_to_address>

### Comment 4
<location path="api/app/repositories/neo4j/graph_search.py" line_range="431-433" />
<code_context>
-            limit=limit,
-        ))
-        task_keys.append("communities")
+    for node_type in include:
+        tasks.append(search_by_fulltext(connector, node_type, end_user_id, escaped_query, limit))
+        task_keys.append(node_type.value)

     # Execute all queries in parallel
</code_context>
<issue_to_address>
**issue (bug_risk):** 结果字典使用字符串作为键,但后续激活逻辑仍然期望枚举键。

由于 `task_keys` 使用的是 `node_type.value``results` 最终会得到字符串键,但后续逻辑使用 `Neo4jNodeType` 值来做成员检查。这个不匹配意味着像 `key in include and key in results` 这样的条件永远不会成立,因此激活更新代码块会被跳过。要么始终一致地使用枚举(用 `Neo4jNodeType` 作为 `results` 的键),要么始终使用字符串(把 `include` 转换成 `node_type.value`)。
</issue_to_address>

### Comment 5
<location path="api/app/core/memory/agent/services/search_service.py" line_range="114-115" />
<code_context>
         content_parts = []

         # Statements: extract statement field
-        if 'statement' in result and result['statement']:
-            content_parts.append(result['statement'])
+        if Neo4jNodeType.STATEMENT in result and result[Neo4jNodeType.STATEMENT]:
+            content_parts.append(result[Neo4jNodeType.STATEMENT])

</code_context>
<issue_to_address>
**issue (bug_risk):** 使用 Neo4jNodeType 枚举键访问 result 字段会失败;底层结果字典仍然使用字符串字段名。

`result` 是一个使用字符串键的普通字典(例如 `'statement'`),因此使用 `Neo4jNodeType.STATEMENT` 作为键不会匹配,要么跳过内容,要么抛出 `KeyError`。这里应继续使用基于字符串的访问,例如:
```python
if result.get("statement"):
    content_parts.append(result["statement"])
```
</issue_to_address>

### Comment 6
<location path="api/app/core/memory/agent/services/search_service.py" line_range="232" />
<code_context>
                 reranked_results = answer.get('reranked_results', {})

                 # Priority order: summaries first (most contextual), then communities, statements, chunks, entities
</code_context>
<issue_to_address>
**issue (bug_risk):** 当前的优先级 / include 处理在混用枚举和值为字符串的键,会导致某些类别被跳过。

在 `execute_hybrid_search` 中,`include` 现在是 `List[Neo4jNodeType]`,但 `reranked_results``answer` 仍然以 `'summaries'``'communities'` 等字符串作为键。因此,这段循环:
```python
for category in priority_order:
    if category in include and category in reranked_results:
        ...
```
永远不会匹配,因为 `category` 是枚举,而字典键是字符串。要么将所有内容统一为字符串(包括 `include``priority_order`),要么在构建和索引 `reranked_results`/`answer` 时一致地使用 `category.value`。
</issue_to_address>

### Comment 7
<location path="api/app/core/memory/src/search.py" line_range="242-244" />
<code_context>

     reranked: Dict[str, List[Dict[str, Any]]] = {}

-    for category in ["statements", "chunks", "entities", "summaries", "communities"]:
+    for category in [Neo4jNodeType.STATEMENT, Neo4jNodeType.CHUNK, Neo4jNodeType.EXTRACTEDENTITY, Neo4jNodeType.MEMORYSUMMARY, Neo4jNodeType.COMMUNITY]:
         keyword_items = keyword_results.get(category, [])
         embedding_items = embedding_results.get(category, [])
</code_context>
<issue_to_address>
**issue (bug_risk):** 在输入仍然以字符串为键时使用 Neo4jNodeType 作为字典键,会破坏 reranking。

`keyword_results``embedding_results` 仍然使用字符串类别名作为键(例如 `'statements'``'chunks'`),但循环现在使用的是 `Neo4jNodeType` 值。这意味着:
```python
keyword_items = keyword_results.get(category, [])
embedding_items = embedding_results.get(category, [])
```
将总是返回 `[]`。要么改用 `category.value` 来索引,要么继续遍历字符串键,并在流水线更早的阶段转换为枚举。
</issue_to_address>

### Comment 8
<location path="api/app/core/memory/read_services/content_search.py" line_range="155-157" />
<code_context>
+            logger.warning(f"[MemorySearch] embedding search error: {emb_results}")
+            emb_results = {}
+
+        memories = []
+        for node_type in self.includes:
+            reranked = self._rerank(
+                kw_results.get(node_type, []),
+                emb_results.get(node_type, []),
</code_context>
<issue_to_address>
**issue (bug_risk):** Neo4jSearchService 在 reranking 时把枚举键和以字符串为键的结果字典混在一起使用。

在 `search` 中,`self.includes` 包含 `Neo4jNodeType` 枚举,但 `search_graph` / `search_graph_by_embedding` 返回的字典是以 `node_type.value`(字符串)为键的。这意味着 `kw_results.get(node_type, [])``emb_results.get(node_type, [])` 总是取不到值,从而没有任何内容被 rerank 或返回。应改为使用枚举的 value 作为键:
```python
for node_type in self.includes:
    key = node_type.value
    reranked = self._rerank(
        kw_results.get(key, []),
        emb_results.get(key, []),
        limit,
    )
```
这样才能实际使用这些结果。
</issue_to_address>

Sourcery 对开源项目免费——如果你喜欢我们的评审,请考虑分享它们 ✨
帮我变得更有用!请对每条评论点 👍 或 👎,我会根据这些反馈改进后续评审。
Original comment in English

Hey - I've found 8 issues, and left some high level feedback:

  • The migration from string-based type keys to Neo4jNodeType enums is inconsistent in several places (e.g. search_graph.search_graph, search_graph_by_embedding, execute_hybrid_search, rerank_with_activation): results dicts and Cypher mappings still use string keys (node_type.value) while include/loop variables use enums, so membership checks like key in include and category in answer will now always fail and skip activation updates / result aggregation—align keys (all enums or all strings) or normalize when reading/writing.
  • In SearchService.extract_content_from_result you changed if 'statement' in result to if Neo4jNodeType.STATEMENT in result, but the result dicts still have a 'statement' string key from Cypher; this condition will never be true and statement content will be dropped—keep the string key or map the dict keys to enum-based structure first.
  • cosine_similarity_search does not guard against zero-norm vectors (either query or stored embeddings), which will cause division-by-zero and NaNs for degenerate or uninitialized embeddings; consider filtering out zero vectors or adding an epsilon when normalizing.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The migration from string-based type keys to Neo4jNodeType enums is inconsistent in several places (e.g. search_graph.search_graph, search_graph_by_embedding, execute_hybrid_search, rerank_with_activation): results dicts and Cypher mappings still use string keys (node_type.value) while include/loop variables use enums, so membership checks like `key in include` and `category in answer` will now always fail and skip activation updates / result aggregation—align keys (all enums or all strings) or normalize when reading/writing.
- In SearchService.extract_content_from_result you changed `if 'statement' in result` to `if Neo4jNodeType.STATEMENT in result`, but the result dicts still have a 'statement' string key from Cypher; this condition will never be true and statement content will be dropped—keep the string key or map the dict keys to enum-based structure first.
- cosine_similarity_search does not guard against zero-norm vectors (either query or stored embeddings), which will cause division-by-zero and NaNs for degenerate or uninitialized embeddings; consider filtering out zero vectors or adding an epsilon when normalizing.

## Individual Comments

### Comment 1
<location path="api/app/repositories/neo4j/graph_search.py" line_range="38-47" />
<code_context>
 logger = logging.getLogger(__name__)


+def cosine_similarity_search(
+        query: list[float],
+        vectors: list[list[float]],
+        limit: int
+) -> dict[int, float]:
+    if not vectors:
+        return {}
+    vectors: np.ndarray = np.array(vectors, dtype=np.float32)
+    vectors_norm = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
+    query: np.ndarray = np.array(query, dtype=np.float32)
+    query_norm = query / np.linalg.norm(query)
+
+    similarities = vectors_norm @ query_norm
+    similarities = np.clip(similarities, 0, 1)
+    top_k = min(limit, similarities.shape[0])
</code_context>
<issue_to_address>
**issue:** Guard against zero‑norm vectors in cosine_similarity_search to avoid division by zero and NaNs.

In `cosine_similarity_search`, both `vectors_norm = vectors / np.linalg.norm(...` and `query_norm = query / np.linalg.norm(query)` will divide by zero for all‑zero vectors or query, producing NaNs and invalid rankings. Please handle zero‑norm cases explicitly, e.g. by filtering out zero‑norm rows or applying an epsilon floor (`norm = np.where(norm == 0, 1, norm)`) and early‑returning when the query norm is 0.
</issue_to_address>

### Comment 2
<location path="api/app/repositories/neo4j/graph_search.py" line_range="358-362" />
<code_context>
+        limit: int = 10,
+) -> list[dict[str, Any]]:
+    try:
+        records = await connector.execute_query(
+            USER_ID_QUERY_CYPHER_MAPPING[node_type],
+            end_user_id=end_user_id,
+        )
+        records = [record for record in records if record if record["embedding"] is not None]
+        ids = [item['id'] for item in records]
+        vectors = [item['embedding'] for item in records]
</code_context>
<issue_to_address>
**issue (bug_risk):** Fix list comprehension filtering to avoid crashing when records contain None or missing 'embedding'.

The current comprehension `records = [record for record in records if record if record["embedding"] is not None]` has two issues:
- `if record if record[...]` is syntactically odd and still evaluates `record["embedding"]` even when `record` is falsy, which will fail if `record` is `None`.
- Accessing `record["embedding"]` directly will raise `KeyError` when the key is missing.
Consider instead:
```python
records = [
    r for r in records
    if r is not None and r.get("embedding") is not None
]
```
</issue_to_address>

### Comment 3
<location path="api/app/repositories/neo4j/graph_search.py" line_range="169-175" />
<code_context>
     knowledge_node_types = {
         'statements': 'Statement',
         'entities': 'ExtractedEntity',
-        'summaries': 'MemorySummary'
+        'summaries': 'MemorySummary',
+        Neo4jNodeType.STATEMENT: Neo4jNodeType.STATEMENT.value,
+        Neo4jNodeType.EXTRACTEDENTITY: Neo4jNodeType.EXTRACTEDENTITY.value,
+        Neo4jNodeType.MEMORYSUMMARY: Neo4jNodeType.MEMORYSUMMARY.value,
     }

</code_context>
<issue_to_address>
**issue (bug_risk):** Mixing string keys and Neo4jNodeType keys in knowledge_node_types causes activation updates to never trigger.

In `_update_search_results_activation`, `knowledge_node_types` now mixes string keys (e.g. `'statements'`) and enum keys (e.g. `Neo4jNodeType.STATEMENT`). But `needs_activation_update` checks:
```python
needs_activation_update = any(
    key in include and key in results and results[key]
    for key in ['statements', 'entities', 'chunks']
)
```
`include` is a `List[Neo4jNodeType]` and `results` is keyed by `node_type.value` (e.g. `'Statement'`), so these string literals never match and the activation update path is effectively dead. This needs to be fixed by using a single key scheme (e.g. `Neo4jNodeType` everywhere) and consistently indexing `results` via `node_type.value`.
</issue_to_address>

### Comment 4
<location path="api/app/repositories/neo4j/graph_search.py" line_range="431-433" />
<code_context>
-            limit=limit,
-        ))
-        task_keys.append("communities")
+    for node_type in include:
+        tasks.append(search_by_fulltext(connector, node_type, end_user_id, escaped_query, limit))
+        task_keys.append(node_type.value)

     # Execute all queries in parallel
</code_context>
<issue_to_address>
**issue (bug_risk):** Results dictionaries are keyed by strings while later activation logic still expects enum keys.

Since `task_keys` uses `node_type.value`, `results` ends up with string keys, but later logic checks membership using `Neo4jNodeType` values. This mismatch means conditions like `key in include and key in results` never pass, so the activation update block is skipped. Use either enums consistently (key `results` by `Neo4jNodeType`) or strings consistently (convert `include` to `node_type.value`).
</issue_to_address>

### Comment 5
<location path="api/app/core/memory/agent/services/search_service.py" line_range="114-115" />
<code_context>
         content_parts = []

         # Statements: extract statement field
-        if 'statement' in result and result['statement']:
-            content_parts.append(result['statement'])
+        if Neo4jNodeType.STATEMENT in result and result[Neo4jNodeType.STATEMENT]:
+            content_parts.append(result[Neo4jNodeType.STATEMENT])

</code_context>
<issue_to_address>
**issue (bug_risk):** Accessing result fields with Neo4jNodeType enum keys will fail; underlying result dictionaries still use string field names.

`result` is a plain dict with string keys (e.g. `'statement'`), so using `Neo4jNodeType.STATEMENT` as a key will not match and will either skip the content or raise a `KeyError`. Keep string-based access here, e.g.:
```python
if result.get("statement"):
    content_parts.append(result["statement"])
```
</issue_to_address>

### Comment 6
<location path="api/app/core/memory/agent/services/search_service.py" line_range="232" />
<code_context>
                 reranked_results = answer.get('reranked_results', {})

                 # Priority order: summaries first (most contextual), then communities, statements, chunks, entities
</code_context>
<issue_to_address>
**issue (bug_risk):** Priority / include handling now mixes enums and string keys, which will cause categories to be skipped.

In `execute_hybrid_search`, `include` is now `List[Neo4jNodeType]`, but `reranked_results` and `answer` are still keyed by strings like `'summaries'` and `'communities'`. As a result, the loop:
```python
for category in priority_order:
    if category in include and category in reranked_results:
        ...
```
will never match, since `category` is an enum and the dict keys are strings. Either standardize everything on strings (including `include` and `priority_order`) or use `category.value` consistently when building and indexing `reranked_results`/`answer`.
</issue_to_address>

### Comment 7
<location path="api/app/core/memory/src/search.py" line_range="242-244" />
<code_context>

     reranked: Dict[str, List[Dict[str, Any]]] = {}

-    for category in ["statements", "chunks", "entities", "summaries", "communities"]:
+    for category in [Neo4jNodeType.STATEMENT, Neo4jNodeType.CHUNK, Neo4jNodeType.EXTRACTEDENTITY, Neo4jNodeType.MEMORYSUMMARY, Neo4jNodeType.COMMUNITY]:
         keyword_items = keyword_results.get(category, [])
         embedding_items = embedding_results.get(category, [])
</code_context>
<issue_to_address>
**issue (bug_risk):** Using Neo4jNodeType as dict keys where the inputs are keyed by strings will break reranking.

`keyword_results` and `embedding_results` are still keyed by string category names (e.g. `'statements'`, `'chunks'`), but the loop now uses `Neo4jNodeType` values. This means:
```python
keyword_items = keyword_results.get(category, [])
embedding_items = embedding_results.get(category, [])
```
will always return `[]`. Either index with `category.value` or keep iterating over string keys and convert to enums earlier in the pipeline.
</issue_to_address>

### Comment 8
<location path="api/app/core/memory/read_services/content_search.py" line_range="155-157" />
<code_context>
+            logger.warning(f"[MemorySearch] embedding search error: {emb_results}")
+            emb_results = {}
+
+        memories = []
+        for node_type in self.includes:
+            reranked = self._rerank(
+                kw_results.get(node_type, []),
+                emb_results.get(node_type, []),
</code_context>
<issue_to_address>
**issue (bug_risk):** Neo4jSearchService mixes enum keys with string-keyed result dicts when reranking.

In `search`, `self.includes` contains `Neo4jNodeType` enums, but `search_graph` / `search_graph_by_embedding` return dicts keyed by `node_type.value` (strings). That means `kw_results.get(node_type, [])` and `emb_results.get(node_type, [])` will always miss, so nothing is reranked or returned. Use the enum value as the key instead:
```python
for node_type in self.includes:
    key = node_type.value
    reranked = self._rerank(
        kw_results.get(key, []),
        emb_results.get(key, []),
        limit,
    )
```
so the results are actually used.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread api/app/repositories/neo4j/graph_search.py
Comment thread api/app/repositories/neo4j/graph_search.py Outdated
Comment thread api/app/repositories/neo4j/graph_search.py
Comment thread api/app/repositories/neo4j/graph_search.py
Comment thread api/app/core/memory/agent/services/search_service.py
Comment thread api/app/core/memory/agent/services/search_service.py
Comment thread api/app/core/memory/src/search.py
Comment thread api/app/core/memory/read_services/content_search.py
… client handling

- Consolidate memory search services by removing separate content_search.py and perceptual_search.py
- Update model client handling in base_pipeline.py to use ModelApiKeyService for LLM client initialization
- Add new prompt files and modify existing services to support consolidated search architecture
- Refactor memory read pipeline and related services to use updated model client approach
@myhMARS myhMARS force-pushed the refactor/memory_search branch from 5777f7b to a01525e Compare April 16, 2026 05:43
… client handling

- Consolidate memory search services by removing separate content_search.py and perceptual_search.py
- Update model client handling in base_pipeline.py to use ModelApiKeyService for LLM client initialization
- Add new prompt files and modify existing services to support consolidated search architecture
- Refactor memory read pipeline and related services to use updated model client approach
@myhMARS myhMARS force-pushed the refactor/memory_search branch from 3f05549 to 33bfe88 Compare April 16, 2026 07:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant