feat(memory): implement quick search pipeline with Neo4j integration

This commit is contained in:
Eternity
2026-04-15 12:18:23 +08:00
parent dca3173ed9
commit 2716a55c7f
19 changed files with 899 additions and 574 deletions

View File

@@ -15,7 +15,7 @@ from app.core.logging_config import get_agent_logger
from app.core.memory.agent.utils.llm_tools import ReadState
from app.core.memory.utils.data.text_utils import escape_lucene_query
from app.repositories.neo4j.graph_search import (
search_perceptual,
search_perceptual_by_fulltext,
search_perceptual_by_embedding,
)
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
@@ -152,7 +152,7 @@ class PerceptualSearchService:
if not escaped.strip():
return []
try:
r = await search_perceptual(
r = await search_perceptual_by_fulltext(
connector=connector, query=escaped,
end_user_id=self.end_user_id,
limit=limit * 5, # 多查一些以提高命中率
@@ -177,7 +177,7 @@ class PerceptualSearchService:
escaped = escape_lucene_query(kw)
if not escaped.strip():
return []
r = await search_perceptual(
r = await search_perceptual_by_fulltext(
connector=connector, query=escaped,
end_user_id=self.end_user_id, limit=limit,
)

View File

@@ -19,6 +19,7 @@ from app.core.memory.agent.utils.llm_tools import (
from app.core.memory.agent.utils.redis_tool import store
from app.core.memory.agent.utils.session_tools import SessionService
from app.core.memory.agent.utils.template_tools import TemplateService
from app.core.memory.enums import Neo4jNodeType
from app.core.rag.nlp.search import knowledge_retrieval
from app.db import get_db_context
@@ -338,7 +339,7 @@ async def Input_Summary(state: ReadState) -> ReadState:
"end_user_id": end_user_id,
"question": data,
"return_raw_results": True,
"include": ["summaries", "communities"] # MemorySummary 和 Community 同为高维度概括节点
"include": [Neo4jNodeType.MEMORYSUMMARY, Neo4jNodeType.COMMUNITY] # MemorySummary 和 Community 同为高维度概括节点
}
try:

View File

@@ -1,15 +1,14 @@
#!/usr/bin/env python3
import logging
from contextlib import asynccontextmanager
from langchain_core.messages import HumanMessage
from langgraph.constants import START, END
from langgraph.graph import StateGraph
from app.db import get_db
from app.services.memory_config_service import MemoryConfigService
from app.core.memory.agent.utils.llm_tools import ReadState
from app.core.memory.agent.langgraph_graph.nodes.data_nodes import content_input_node
from app.core.memory.agent.langgraph_graph.nodes.perceptual_retrieve_node import (
perceptual_retrieve_node,
)
from app.core.memory.agent.langgraph_graph.nodes.problem_nodes import (
Split_The_Problem,
Problem_Extension,
@@ -17,9 +16,6 @@ from app.core.memory.agent.langgraph_graph.nodes.problem_nodes import (
from app.core.memory.agent.langgraph_graph.nodes.retrieve_nodes import (
retrieve_nodes,
)
from app.core.memory.agent.langgraph_graph.nodes.perceptual_retrieve_node import (
perceptual_retrieve_node,
)
from app.core.memory.agent.langgraph_graph.nodes.summary_nodes import (
Input_Summary,
Retrieve_Summary,
@@ -32,6 +28,9 @@ from app.core.memory.agent.langgraph_graph.routing.routers import (
Retrieve_continue,
Verify_continue,
)
from app.core.memory.agent.utils.llm_tools import ReadState
logger = logging.getLogger(__name__)
@asynccontextmanager
@@ -51,7 +50,7 @@ async def make_read_graph():
"""
try:
# Build workflow graph
workflow = StateGraph(ReadState)
workflow = StateGraph(ReadState)
workflow.add_node("content_input", content_input_node)
workflow.add_node("Split_The_Problem", Split_The_Problem)
workflow.add_node("Problem_Extension", Problem_Extension)

View File

@@ -7,6 +7,7 @@ and deduplication.
from typing import List, Tuple, Optional
from app.core.logging_config import get_agent_logger
from app.core.memory.enums import Neo4jNodeType
from app.core.memory.src.search import run_hybrid_search
from app.core.memory.utils.data.text_utils import escape_lucene_query
@@ -111,13 +112,13 @@ class SearchService:
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])
# Community 节点:有 member_count 或 core_entities 字段,或 node_type 明确指定
# 用 "[主题:{name}]" 前缀区分,让 LLM 知道这是主题级摘要
is_community = (
node_type == "community"
node_type == Neo4jNodeType.COMMUNITY
or 'member_count' in result
or 'core_entities' in result
)
@@ -204,7 +205,7 @@ class SearchService:
raw_results is None if return_raw_results=False
"""
if include is None:
include = ["statements", "chunks", "entities", "summaries", "communities"]
include = [Neo4jNodeType.STATEMENT, Neo4jNodeType.CHUNK, Neo4jNodeType.EXTRACTEDENTITY, Neo4jNodeType.MEMORYSUMMARY, Neo4jNodeType.COMMUNITY]
# Clean query
cleaned_query = self.clean_query(question)
@@ -231,7 +232,7 @@ class SearchService:
reranked_results = answer.get('reranked_results', {})
# Priority order: summaries first (most contextual), then communities, statements, chunks, entities
priority_order = ['summaries', 'communities', 'statements', 'chunks', 'entities']
priority_order = [Neo4jNodeType.STATEMENT, Neo4jNodeType.CHUNK, Neo4jNodeType.EXTRACTEDENTITY, Neo4jNodeType.MEMORYSUMMARY, Neo4jNodeType.COMMUNITY]
for category in priority_order:
if category in include and category in reranked_results:
@@ -241,7 +242,7 @@ class SearchService:
else:
# For keyword or embedding search, results are directly in answer dict
# Apply same priority order
priority_order = ['summaries', 'communities', 'statements', 'chunks', 'entities']
priority_order = [Neo4jNodeType.STATEMENT, Neo4jNodeType.CHUNK, Neo4jNodeType.EXTRACTEDENTITY, Neo4jNodeType.MEMORYSUMMARY, Neo4jNodeType.COMMUNITY]
for category in priority_order:
if category in include and category in answer:
@@ -250,11 +251,11 @@ class SearchService:
answer_list.extend(category_results)
# 对命中的 community 节点展开其成员 statements路径 "0"/"1" 需要,路径 "2" 不需要)
if expand_communities and "communities" in include:
if expand_communities and Neo4jNodeType.COMMUNITY in include:
community_results = (
answer.get('reranked_results', {}).get('communities', [])
answer.get('reranked_results', {}).get(Neo4jNodeType.COMMUNITY.value, [])
if search_type == "hybrid"
else answer.get('communities', [])
else answer.get(Neo4jNodeType.COMMUNITY.value, [])
)
cleaned_stmts, new_texts = await expand_communities_to_statements(
community_results=community_results,
@@ -266,7 +267,7 @@ class SearchService:
content_list = []
for ans in answer_list:
# community 节点有 member_count 或 core_entities 字段
ntype = "community" if ('member_count' in ans or 'core_entities' in ans) else ""
ntype = Neo4jNodeType.COMMUNITY if ('member_count' in ans or 'core_entities' in ans) else ""
content_list.append(self.extract_content_from_result(ans, node_type=ntype))
# Filter out empty strings and join with newlines

View File

@@ -16,3 +16,14 @@ class SearchStrategy(StrEnum):
DEEP = "0"
NORMAL = "1"
QUICK = "2"
class Neo4jNodeType(StrEnum):
CHUNK = "Chunk"
COMMUNITY = "Community"
DIALOGUE = "Dialogue"
EXTRACTEDENTITY = "ExtractedEntity"
MEMORYSUMMARY = "MemorySummary"
PERCEPTUAL = "Perceptual"
STATEMENT = "Statement"

View File

@@ -21,6 +21,7 @@ from chonkie import (
from app.core.memory.models.config_models import ChunkerConfig
from app.core.memory.models.message_models import DialogData, Chunk
try:
from app.core.memory.llm_tools.openai_client import OpenAIClient
except Exception:
@@ -32,6 +33,7 @@ logger = logging.getLogger(__name__)
class LLMChunker:
"""LLM-based intelligent chunking strategy"""
def __init__(self, llm_client: OpenAIClient, chunk_size: int = 1000):
self.llm_client = llm_client
self.chunk_size = chunk_size
@@ -46,7 +48,8 @@ class LLMChunker:
"""
messages = [
{"role": "system", "content": "You are a professional text analysis assistant, skilled at splitting long texts into semantically coherent paragraphs."},
{"role": "system",
"content": "You are a professional text analysis assistant, skilled at splitting long texts into semantically coherent paragraphs."},
{"role": "user", "content": prompt}
]
@@ -311,7 +314,7 @@ class ChunkerClient:
f.write("=" * 60 + "\n\n")
for i, chunk in enumerate(dialogue.chunks):
f.write(f"Chunk {i+1}:\n")
f.write(f"Chunk {i + 1}:\n")
f.write(f"Size: {len(chunk.content)} characters\n")
if hasattr(chunk, 'metadata') and 'start_index' in chunk.metadata:
f.write(f"Position: {chunk.metadata.get('start_index')}-{chunk.metadata.get('end_index')}\n")

View File

@@ -1,21 +1,12 @@
from sqlalchemy.orm import Session
from pydantic import BaseModel, ConfigDict
from app.core.memory.enums import StorageType
from app.schemas import MemoryConfig
from app.core.memory.enums import StorageType, SearchStrategy
from app.core.memory.models.service_models import Memory, MemoryContext
from app.core.memory.pipelines.memory_read import ReadPipeLine
from app.db import get_db_context
from app.services.memory_config_service import MemoryConfigService
class MemoryContext(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
end_user_id: str
memory_config: MemoryConfig
storage_type: StorageType = StorageType.NEO4J
user_rag_memory_id: str | None = None
language: str = "zh"
class MemoryService:
def __init__(
self,
@@ -44,8 +35,9 @@ class MemoryService:
async def write(self, messages: list[dict]) -> str:
raise NotImplementedError
async def read(self, query: str, history: list, search_switch: str) -> dict:
raise NotImplementedError
async def read(self, query: str, history: list, search_switch: SearchStrategy) -> list[Memory]:
with get_db_context() as db:
return await ReadPipeLine(self.ctx, db).run(query, search_switch, limit=10)
async def forget(self, max_batch: int = 100, min_days: int = 30) -> dict:
raise NotImplementedError

View File

@@ -0,0 +1,26 @@
from pydantic import BaseModel, Field, field_serializer, ConfigDict
from app.core.memory.enums import Neo4jNodeType, StorageType
from app.schemas.memory_config_schema import MemoryConfig
class MemoryContext(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
end_user_id: str
memory_config: MemoryConfig
storage_type: StorageType = StorageType.NEO4J
user_rag_memory_id: str | None = None
language: str = "zh"
class Memory(BaseModel):
source: Neo4jNodeType = Field(...)
score: float = Field(default=0.0)
content: str = Field(default="")
data: dict = Field(default_factory=dict)
query: str = Field(...)
@field_serializer("source")
def serialize_source(self, v) -> str:
return v.value

View File

@@ -1,22 +1,32 @@
# -*- coding: UTF-8 -*-
# Author: Eternity
# @Email: 1533512157@qq.com
# @Time : 2026/4/3 11:44
import uuid
from abc import ABC, abstractmethod
from typing import Any
from sqlalchemy.orm import Session
from demo.memory_alpha import MemoryContext
from app.core.memory.llm_tools import OpenAIEmbedderClient
from app.core.memory.models.service_models import MemoryContext
from app.core.models import RedBearModelConfig
from app.services.memory_config_service import MemoryConfigService
class ModelClientMixin(ABC):
def get_llm_client(self, db: Session, model_id: uuid.UUID):
@staticmethod
def get_llm_client(db: Session, model_id: uuid.UUID):
pass
def get_embedding_client(self, db: Session, model_id: uuid.UUID):
pass
@staticmethod
def get_embedding_client(db: Session, model_id: uuid.UUID) -> OpenAIEmbedderClient:
config_service = MemoryConfigService(db)
embedder_client_config = config_service.get_embedder_config(str(model_id))
return OpenAIEmbedderClient(
RedBearModelConfig(
model_name=embedder_client_config["model_name"],
provider=embedder_client_config["provider"],
api_key=embedder_client_config["api_key"],
base_url=embedder_client_config["base_url"],
)
)
class BasePipeline(ABC):

View File

@@ -1,10 +1,11 @@
from app.core.memory.enums import SearchStrategy
from app.core.memory.pipelines.base_pipeline import BasePipeline
from app.core.memory.pipelines.base_pipeline import BasePipeline, ModelClientMixin
from app.core.memory.read_services.content_search import Neo4jSearchService
from app.core.memory.read_services.query_preprocessor import QueryPreprocessor
class ReadPipeLine(BasePipeline):
async def run(self, query, search_switch, memory_config):
class ReadPipeLine(ModelClientMixin, BasePipeline):
async def run(self, query: str, search_switch: SearchStrategy, limit: int = 10):
query = QueryPreprocessor.process(query)
match search_switch:
case SearchStrategy.DEEP:
@@ -12,7 +13,7 @@ class ReadPipeLine(BasePipeline):
case SearchStrategy.NORMAL:
return await self._normal_read(query)
case SearchStrategy.QUICK:
return await self._quick_read()
return await self._quick_read(query, limit)
case _:
raise RuntimeError("Unsupported search strategy")
@@ -22,5 +23,9 @@ class ReadPipeLine(BasePipeline):
async def _normal_read(self, query):
pass
async def _quick_read(self):
pass
async def _quick_read(self, query, limit):
search_service = Neo4jSearchService(
self.ctx,
self.get_embedding_client(self.db, self.ctx.memory_config.embedding_model_id)
)
return await search_service.search(query, limit)

View File

@@ -0,0 +1,178 @@
import asyncio
import logging
import math
import time
from pydantic import BaseModel, Field
from app.core.memory.enums import Neo4jNodeType
from app.core.memory.llm_tools import OpenAIEmbedderClient
from app.core.memory.memory_service import MemoryContext
from app.core.memory.models.service_models import Memory
from app.core.memory.read_services.result_builder import data_builder_factory
from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
logger = logging.getLogger(__name__)
class MemorySearchResult(BaseModel):
memories: dict[str, list[dict]] = Field(default_factory=dict)
content: str = Field(default="")
count: int = Field(default=0)
class Neo4jSearchService:
DEFAULT_ALPHA = 0.6
DEFAULT_FULLTEXT_SCORE_THRESHOLD = 1
DEFAULT_COSINE_SCORE_THRESHOLD = 0.5
DEFAULT_CONTENT_SCORE_THRESHOLD = 0.5
def __init__(
self,
ctx: MemoryContext,
embedder: OpenAIEmbedderClient,
includes: list[Neo4jNodeType] | None = None,
alpha: float = DEFAULT_ALPHA,
fulltext_score_threshold: float = DEFAULT_FULLTEXT_SCORE_THRESHOLD,
cosine_score_threshold: float = DEFAULT_COSINE_SCORE_THRESHOLD,
content_score_threshold: float = DEFAULT_CONTENT_SCORE_THRESHOLD
):
self.ctx = ctx
self.alpha = alpha
self.fulltext_score_threshold = fulltext_score_threshold
self.cosine_score_threshold = cosine_score_threshold
self.content_score_threshold = content_score_threshold
self.embedder: OpenAIEmbedderClient = embedder
self.connector: Neo4jConnector | None = None
self.includes = includes
if includes is None:
self.includes = [
Neo4jNodeType.STATEMENT,
Neo4jNodeType.CHUNK,
Neo4jNodeType.EXTRACTEDENTITY,
Neo4jNodeType.MEMORYSUMMARY,
Neo4jNodeType.PERCEPTUAL,
Neo4jNodeType.COMMUNITY
]
async def _keyword_search(
self,
query: str,
limit: int
):
return await search_graph(
connector=self.connector,
query=query,
end_user_id=self.ctx.end_user_id,
limit=limit,
include=self.includes
)
async def _embedding_search(self, query, limit):
return await search_graph_by_embedding(
connector=self.connector,
embedder_client=self.embedder,
query_text=query,
end_user_id=self.ctx.end_user_id,
limit=limit,
include=self.includes
)
def _rerank(
self,
keyword_results: list[dict],
embedding_results: list[dict],
limit: int,
) -> list[dict]:
keyword_results = self._normalize_kw_scores(keyword_results)
embedding_results = embedding_results
kw_norm_map = {}
for item in keyword_results:
item_id = item["id"]
kw_norm_map[item_id] = float(item.get("normalized_kw_score", 0))
emb_norm_map = {}
for item in embedding_results:
item_id = item["id"]
emb_norm_map[item_id] = float(item.get("score", 0))
combined = {}
for item in keyword_results:
item_id = item["id"]
combined[item_id] = item.copy()
combined[item_id]["kw_score"] = kw_norm_map.get(item_id, 0)
combined[item_id]["embedding_score"] = emb_norm_map.get(item_id, 0)
for item in embedding_results:
item_id = item["id"]
if item_id in combined:
combined[item_id]["embedding_score"] = emb_norm_map.get(item_id, 0)
else:
combined[item_id] = item.copy()
combined[item_id]["kw_score"] = kw_norm_map.get(item_id, 0)
combined[item_id]["embedding_score"] = emb_norm_map.get(item_id, 0)
for item in combined.values():
item_id = item["id"]
kw = float(combined[item_id].get("kw_score", 0) or 0)
emb = float(combined[item_id].get("embedding_score", 0) or 0)
base = self.alpha * emb + (1 - self.alpha) * kw
combined[item_id]["content_score"] = base + min(1 - base, 0.1 * kw * emb)
results = sorted(combined.values(), key=lambda x: x["content_score"], reverse=True)
# results = [res for res in results if res["content_score"] > self.content_score_threshold]
results = results[:limit]
logger.info(
f"[MemorySearch] rerank: merged={len(combined)}, after_threshold={len(results)} "
f"(alpha={self.alpha})"
)
return results
def _normalize_kw_scores(self, items: list[dict]) -> list[dict]:
if not items:
return items
scores = [float(it.get("score", 0) or 0) for it in items]
for it, s in zip(items, scores):
it[f"normalized_kw_score"] = 1 / (1 + math.exp(-(s - self.fulltext_score_threshold) / 2))
return items
async def search(
self,
query: str,
limit: int = 10,
) -> list[Memory]:
async with Neo4jConnector() as connector:
self.connector = connector
kw_task = self._keyword_search(query, limit)
emb_task = self._embedding_search(query, limit)
kw_results, emb_results = await asyncio.gather(kw_task, emb_task, return_exceptions=True)
if isinstance(kw_results, Exception):
logger.warning(f"[MemorySearch] keyword search error: {kw_results}")
kw_results = {}
if isinstance(emb_results, Exception):
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, []),
limit
)
for record in reranked:
memory = data_builder_factory(node_type, record)
memories.append(Memory(
score=memory.score,
content=memory.content,
data=memory.data,
source=node_type,
query=query
))
memories.sort(key=lambda x: x.score, reverse=True)
return memories[:limit]

View File

@@ -0,0 +1,150 @@
from abc import ABC, abstractmethod
from typing import TypeVar
from app.core.memory.enums import Neo4jNodeType
class BaseBuilder(ABC):
def __init__(self, records: dict):
self.record = records
@property
@abstractmethod
def data(self) -> dict:
pass
@property
@abstractmethod
def content(self) -> str:
pass
@property
def score(self) -> float:
return self.record.get("content_score", 0.0) or 0.0
T = TypeVar("T", bound=BaseBuilder)
class ChunkBuilder(BaseBuilder):
@property
def data(self) -> dict:
return {
"id": self.record.get("id"),
"content": self.record.get("content"),
"kw_score": self.record.get("kw_score", 0.0),
"emb_score": self.record.get("embedding_score", 0.0)
}
@property
def content(self) -> str:
return self.record.get("content")
class StatementBuiler(BaseBuilder):
@property
def data(self) -> dict:
return {
"id": self.record.get("id"),
"content": self.record.get("statement"),
"kw_score": self.record.get("kw_score", 0.0),
"emb_score": self.record.get("embedding_score", 0.0)
}
@property
def content(self) -> str:
return self.record.get("statement")
class EntityBuilder(BaseBuilder):
@property
def data(self) -> dict:
return {
"id": self.record.get("id"),
"content": self.record.get("name"),
"kw_score": self.record.get("kw_score", 0.0),
"emb_score": self.record.get("embedding_score", 0.0)
}
@property
def content(self) -> str:
return self.record.get("name")
class SummaryBuilder(BaseBuilder):
@property
def data(self) -> dict:
return {
"id": self.record.get("id"),
"content": self.record.get("content"),
"kw_score": self.record.get("kw_score", 0.0),
"emb_score": self.record.get("embedding_score", 0.0)
}
@property
def content(self) -> str:
return self.record.get("content")
class PerceptualBuilder(BaseBuilder):
@property
def data(self) -> dict:
return {
"id": self.record.get("id", ""),
"perceptual_type": self.record.get("perceptual_type", ""),
"file_name": self.record.get("file_name", ""),
"file_path": self.record.get("file_path", ""),
"summary": self.record.get("summary", ""),
"topic": self.record.get("topic", ""),
"domain": self.record.get("domain", ""),
"keywords": self.record.get("keywords", []),
"created_at": str(self.record.get("created_at", "")),
"file_type": self.record.get("file_type", ""),
"kw_score": self.record.get("kw_score", 0.0),
"emb_score": self.record.get("embedding_score", 0.0)
}
@property
def content(self) -> str:
return ("<history-file-info>"
f"<file-name>{self.record.get('file_name')}</file-name>"
f"<file-path>{self.record.get('file_path')}</file-path>"
f"<summary>{self.record.get('summary')}</summary>"
f"<topic>{self.record.get('topic')}</topic>"
f"<domain>{self.record.get('domain')}</domain>"
f"<keywords>{self.record.get('keywords')}</keywords>"
f"<file-type>{self.record.get('file_type')}</file-type>"
"</<history-file-info>")
class CommunityBuilder(BaseBuilder):
@property
def data(self) -> dict:
return {
"id": self.record.get("id"),
"content": self.record.get("content"),
"kw_score": self.record.get("kw_score", 0.0),
"emb_score": self.record.get("embedding_score", 0.0)
}
@property
def content(self) -> str:
return self.record.get("content")
def data_builder_factory(node_type, data: dict) -> T:
match node_type:
case Neo4jNodeType.STATEMENT:
return StatementBuiler(data)
case Neo4jNodeType.CHUNK:
return ChunkBuilder(data)
case Neo4jNodeType.EXTRACTEDENTITY:
return EntityBuilder(data)
case Neo4jNodeType.MEMORYSUMMARY:
return SummaryBuilder(data)
case Neo4jNodeType.PERCEPTUAL:
return PerceptualBuilder(data)
case Neo4jNodeType.COMMUNITY:
return CommunityBuilder(data)
case _:
raise KeyError(f"Unknown node_type: {node_type}")

View File

@@ -6,6 +6,8 @@ import time
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from app.core.memory.enums import Neo4jNodeType
if TYPE_CHECKING:
from app.schemas.memory_config_schema import MemoryConfig
@@ -131,7 +133,7 @@ def normalize_scores(results: List[Dict[str, Any]], score_field: str = "score")
return results
def _deduplicate_results(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def deduplicate_results(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Remove duplicate items from search results based on content.
@@ -194,7 +196,7 @@ def rerank_with_activation(
forgetting_config: ForgettingEngineConfig | None = None,
activation_boost_factor: float = 0.8,
now: datetime | None = None,
content_score_threshold: float = 0.5,
content_score_threshold: float = 0.1,
) -> Dict[str, List[Dict[str, Any]]]:
"""
两阶段排序:先按内容相关性筛选,再按激活值排序。
@@ -239,7 +241,7 @@ def rerank_with_activation(
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, [])
@@ -405,7 +407,7 @@ def rerank_with_activation(
f"items below content_score_threshold={content_score_threshold}"
)
sorted_items = _deduplicate_results(sorted_items)
sorted_items = deduplicate_results(sorted_items)
reranked[category] = sorted_items
@@ -691,7 +693,7 @@ async def run_hybrid_search(
search_type: str,
end_user_id: str | None,
limit: int,
include: List[str],
include: List[Neo4jNodeType],
output_path: str | None,
memory_config: "MemoryConfig",
rerank_alpha: float = 0.6,