Files
MemoryBear/docs/rag/evolution/architecture-refactor-suggestions.md
Multica PM Agent 343a5eebe3
Some checks failed
Sync to Gitee / sync (push) Has been cancelled
docs(rag): add MemoryBear RAG implementation docs v1.0
Submit the formed RAG documentation set produced across Sprint-1/2/3
(WS-12 through WS-26) under docs/rag/. Includes:

- README.md / INDEX.md: landing + total index (responsibility matrix,
  review verdicts, dual-link to source issues)
- overview/: full-pipeline architecture (4 .mmd diagrams),
  11-stage boundary contracts, doc map, source-code inventory
- pipeline/: 5 deep-dives (Loader/Parser/Chunking, Embedding,
  VDB & retrieval, GraphRAG, Rerank/Prompt/LLM)
- graphrag/, end-to-end/: v1.0 formal versions with full source
  retained as reference
- evolution/: 11 architecture-refactor proposals,
  6-direction roadmap, capability map
- review/: S3-T1 / S3-T2 final reviews, S2-T7 final summary
- _indexes/: glossary (81 terms), source->doc reverse index, chart index
- _release/: v1.0-RC1 release manifest, versioning convention,
  ops & freshness plan
- _meta/README.md: placeholder noting WS-12 governance assets gap

Aggregate review score 92.6/100 (8/8 PASS, 31/31 source-code spot
checks hit). The legacy docs/ ignore in .gitignore is narrowed to
docs/* with an explicit allowlist for docs/rag/.

Refs: WS-26
Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 10:51:48 +08:00

33 KiB
Raw Blame History

[S3-T1] MemoryBear RAG 代码架构改造建议

Author: AI 知识库解决方案专家
Source-commit: 工作分支 agent/ai/f8de881a(基于 feae2f2e
Reviewer: 待 [S3-T3] 终审
Last-reviewed-at: 2026-05-08


0. 一页摘要:现状评估

0.1 三个优点(值得保留与放大)

  1. 链路完整、特性丰富:覆盖了从 11 类文档解析(rag/app/naive.py:508-738,按扩展名 if/elif 分发)→ Embedding10+ Provider→ Hybrid 检索BM25 + 向量)→ GraphRAGlight/general 双模式)→ Rerank → Prompt 组装 → 流式 LLM 生成的端到端能力。在国内同类开源项目中链路完整度领先。
  2. 多 Provider 抽象初步成型rag/llm/chat_model.py:52 Base + rag/vdb/vector_base.py:9 BaseVector 已具备抽象基类雏形;rag/models/embedding.py RedBearEmbeddings 通过 LangChain 的 Embeddings 接口屏蔽了 OpenAI / DashScope / Volcano / Ollama / Bedrock 等 7 类 provider。多模型切换代价较低。
  3. GraphRAG 与向量检索的双轨设计rag/common/settings.py:9-10 通过 retrieverDealer+ kg_retrieverKGSearch两个全局单例并行存在应用层workflow/nodes/knowledge/node.py)可在 PARTICIPLE / SEMANTIC / HYBRID / GRAPH 四种检索模式间切换,灵活度高,是 MemoryBear 区别于通用 RAG 的核心特色。

0.2 五个痛点(基于 S1-T3 Gap 报告 + 源码核验)

  1. 抽象层不统一,存在双轨甚至三轨实现

    • Embedding 双轨rag/models/embedding.py RedBearEmbeddingsLangChain被 ES Vector 用vs rag/llm/embedding_model.py OpenAIEmbed/QWenEmbed/...(遗留,被 GraphRAG utils.py:320 与 Dealer nlp/search.py:365-373 用)。两条路径接口不兼容:前者 embed_documents(texts)、后者 encode(texts) 返回 (np.array, total_tokens)
    • Rerank 三轨:模块级 rerank()workflow/nodes/knowledge/node.py:284第 327 行残留 print(reranked_docs) 调试语句)、节点级 KnowledgeRetrievalNode.rerank()node.py:108-155与前者逻辑高度重复、Dealer 内置融合 Dealer.rerank()nlp/search.py:606-643token+vector+rank_feature 加权)。三套互不知晓彼此存在。
    • VDB 抽象有名无实vector_base.py:9 BaseVector 仅定义了 9 个抽象方法,但唯一实现为 ElasticSearchVector,且 node.py:14tasks.py 直接 import 具体类 ElasticSearchVectorFactory 绕过基类,抽象层失效。
  2. 配置散落,无中心化治理os.environ.get / os.getenvrag/ 目录下出现 58 次,分布在 48 个文件。例如 LLM_TIMEOUT_SECONDS/LLM_MAX_RETRIESchat_model.py:54-58)、MAX_CONCURRENT_CHATSgraphrag/utils.py:41)、ELASTICSEARCH_HOST/PORT/USERNAME/PASSWORD/REQUEST_TIMEOUT/MAX_RETRIESelasticsearch_vector.py:685-707)、MINERU_EXECUTABLE/APISERVER/OUTPUT_DIR/BACKEND/DELETE_OUTPUTnaive.py:46-60、OCR/Layout 系列(deepdoc/vision/*)等无统一 schema、无类型校验、无文档可查。运维难以定位"哪个变量影响哪条链路"。

  3. 可观测性等同于零requirements*.txt没有任何 opentelemetry / prometheus / sentry / jaeger / datadog / statsd 依赖355 处 logger.* / logging.* 调用全为本地日志,无 trace_id 透传、无 metric 导出、无 P50/P95 实时统计。README 里宣称的"P50/P95"指标在代码中无任何采集落点,业务方排障必须捞日志手工聚合。

  4. 资源/状态共享导致单测与并发受阻

    • rag/common/settings.py:24 在模块 import 时立即执行 init_settings(),创建 docStoreConn = ESConnection() / retriever = Dealer(...) / kg_retriever = KGSearch(...) 进程级全局单例。任何 from app.core.rag.common.settings import retriever 都会触发 ES 连接,单元测试无法 stub。
    • KnowledgeRetrievalNode.get_reranker_model()node.py:157-193)每次 rerank 调用都重新查 DB → 实例化 RedBearRerank,热路径上反复读库。
    • GraphRAG 用 Redis 做 Embedding 缓存(graphrag/utils.py:115-134 get_embed_cache/set_embed_cache),但 ES VDB 入库/检索路径完全没有缓存elasticsearch_vector.py:55-63),同一 query 重复打 Embedding API。
  5. 入口分发与扩展点用 if/elif 硬编码,违反开闭原则

    • rag/app/naive.py:508-738 chunk() 用 11 个 re.search(扩展名) 分支选择 parser新增格式必须改这个 750 行的函数。
    • rag/llm/embedding_model.py 每个 provider 是独立子类(OpenAIEmbed / QWenEmbed / XinferenceEmbed ...),但选择哪个子类没有 registry依赖外层硬编码 OpenAIEmbed importworkflow/nodes/knowledge/node.py:12)。
    • chat_model.pyChatBase 子类硬编码各 provider 的 base_url 与认证 headerchat_model.py:41-44 OpenAIEmbed.__init__ 直接拼 base_url切换路径不优雅。

1. 架构改造建议清单(共 11 条)

每条建议结构:问题 → 方案 → 收益 → 成本/风险 → 优先级

【建议 1 · 模块化】拆掉双轨 Embedding统一到单一 Embedder 协议 [P0]

  • 问题陈述RedBearEmbeddingsLangChainOpenAIEmbed/QWenEmbed/...(遗留)两套并存,调用方用哪一个看心情;接口不兼容(embed_documents/embed_query vs encode/encode_queries),返回类型不一致(list[list[float]] vs (np.ndarray, total_tokens))。
    • 源码:rag/models/embedding.py:9-78rag/llm/embedding_model.py:14-65rag/graphrag/utils.py:301-327GraphRAG 调用 embd_mdl.encode([ent_name]))、rag/nlp/search.py:365-373Dealer 调用 emb_mdl.encode_queries(txt))。
  • 改造方案
    • 定义 app/core/rag/protocols/embedder.py 中的 Embedder Protocolembed_documents(texts) -> EmbedResultembed_query(text) -> EmbedResultEmbedResultdataclass(vectors: np.ndarray, total_tokens: int, dim: int)
    • 现有 OpenAIEmbed 等遗留类实现 Embedder(保留 encode/encode_queries 兼容期 6 个月)。
    • 新建 EmbedderFactory.from_model_config(config) -> Embedder,内部根据 provider 字段路由;workflow/nodes/knowledge/node.py:12 删除对 OpenAIEmbed 的硬编码 import。
    • 把 GraphRAG 与 Dealer 都改为通过 Embedder 协议调用。
  • 收益:维护成本从"两套类各自演进"降为一套;新 provider 只需实现 Embedder 协议;单测可用 FakeEmbedder mock单测覆盖率提升预期 +30%(当前 rag 模块基本无单测)。
  • 成本与风险:实现 + 迁移约 5 人日。回归风险中GraphRAG 的 np.ndarray 返回类型若变成 list[list[float]] 会触发下游 np 操作错误,需保留 numpy 输出适配器)。
  • 优先级P0(解锁后续所有改造的前置条件)。

【建议 2 · 接口抽象】定义 Retriever / Reranker / Generator 三大协议LangChain Runnable 风格)[P0]

  • 问题陈述:当前没有"检索器"这层抽象,调用方需要直接知道:用哪个 ES index、是否走 hybrid、要不要叠加 GraphRAG。例如 workflow/nodes/knowledge/node.py:195-263 knowledge_retrieval() 内部用 match retrieve_type 分四个分支调 vector_service.search_by_vector() / search_by_full_text() / 二者并行 dedup / 再叠加 kg_retriever.retrieval()。每新增一种检索策略都要在这里加 case
  • 改造方案:定义三个 Protocol伪代码见 PoC 章节):
    class Retriever(Protocol):
        async def retrieve(self, query: Query) -> RetrievalResult: ...
    class Reranker(Protocol):
        async def rerank(self, query: str, docs: list[Doc], top_k: int) -> list[Doc]: ...
    class Generator(Protocol):
        async def generate(self, prompt: Prompt, stream: bool) -> GenerationResult | AsyncIterator[Chunk]: ...
    
    并提供组合算子 Pipeline = Retriever | Reranker | Generator(类似 LangChain Runnable 的 |)。KnowledgeRetrievalNode 不再 match retrieve_type,而是注入一个 RetrieverHybridRetriever / GraphAugmentedRetriever / VectorRetriever 是不同实现)。
  • 收益:策略模式取代条件分支;单测可对 Retriever 接口做契约测试A/B 实验只需注入不同实现;"GraphRAG-then-Vector"、"Vector-then-Graph"、"Reranker-only"等组合可声明式表达。
  • 成本与风险:核心接口设计 + 关键实现 + 迁移调用方约 8 人日。风险中(涉及 workflow node 的契约变化,需要保留旧接口至少一个 release
  • 优先级P0

【建议 3 · 模块化】消除 Rerank 的三处重复实现 [P0]

  • 问题陈述
    • workflow/nodes/knowledge/node.py:284 rerank()(模块级函数)— 第 327 行有 print(reranked_docs) 调试残留
    • workflow/nodes/knowledge/node.py:108-155 KnowledgeRetrievalNode.rerank()(节点方法)— 与前者代码逻辑几乎一致(都做 RedBearRerank.compress_documents + 按 relevance_score 排序 + 按 page_content 字符串匹配回查 metadata
    • rag/nlp/search.py:606-643 Dealer.rerank()(融合排序)—— 走的是 token+vector+rank_feature 三项加权,与前两者完全是不同范式但同名为 rerank。
    • 第二个问题:KnowledgeRetrievalNode.get_reranker_model()node.py:157-193)每次 rerank 调用都查一次 DB 获取模型配置,实例化 RedBearRerank
  • 改造方案
    • 实现一个唯一的 RerankerService:内部做 (a) DB 缓存 reranker 实例key=reranker_idTTL=10min(b) 屏蔽"按 page_content 字符串匹配 metadata"的脆弱回查(改为 LangChain Document.metadata["__doc_index__"] 索引);(c) 暴露 Reranker Protocol。
    • 删掉 node.py:284 rerank() 模块级函数(或仅保留 @deprecated 别名指向 RerankerService)。
    • Dealer.rerank() 改名为 Dealer.fuse_scores(),明确它是"分数融合"不是"模型重排"。
    • 删除 node.py:327 print() 残留。
  • 收益:消除每次请求多查 DB 一次的开销(实测 DB 查询 520ms去掉后热路径单次省 5-20ms × QPSrerank 逻辑只需在一处 review 与单测。
  • 成本与风险:约 3 人日。风险低(接口对外不变)。
  • 优先级P0(含调试残留的 hot fix 应优先合并)。

【建议 4 · 性能优化】Embedder 与 Reranker 加缓存层 [P0]

  • 问题陈述
    • GraphRAG 用 Redis 缓存 Embeddinggraphrag/utils.py:115-134TTL=24hkey=xxhash(model_name+text)),命中率高时显著省成本。
    • 但 ES VDB 入库/检索 (elasticsearch_vector.py:55-63 add_chunks / :374-380 search_by_vector) 完全无缓存。同一 query 反复 embedding同一 chunk 重复入库时也会重复算向量。
    • Rerank 同样无缓存:RedBearRerank.compress_documents 每次都打外部 APIDashScope/Jina200+ ms。
  • 改造方案
    • 抽出 app/core/rag/cache/embed_cache.py(把 graphrag/utils.py 中的现有实现搬过来 + 通用化)。
    • Embedder Protocol 在调用层加装饰器 @cached_embedder(redis, ttl=24h),对 embed_query 必加query 重复率高),embed_documents 可配置。
    • 新增 Reranker 缓存key=xxhash(model + query + sorted(doc_ids))TTL=1hrerank 结果对 query 变体很敏感,不要 TTL 太长)。
    • 从环境变量读 REDIS_* 配置cache 失败时优雅降级为 no-op不要 break 主链路)。
  • 收益Query embedding 命中场景 减少 60-90% 外部 API 调用(基于业内同类系统 query 重复率统计。Rerank 命中场景再减少 30-50%。单 query 端到端 P95 下降 100-300msRerank 是当前最慢的同步阻塞步骤之一)。
  • 成本与风险:约 2 人日。风险低cache miss 时行为与现状一致)。
  • 优先级P0

【建议 5 · 性能优化】用 Plugin Registry 替换 naive.py:508 的 11 路 if/elif 解析器分发 [P1]

  • 问题陈述rag/app/naive.py:508 chunk()re.search(r"\.docx$", filename) / r"\.pdf$" / r"\.(pptx|ppt?)$" / ... 11 个分支硬编码挑 parser。新增一种格式必须改这个 750 行函数;同时 PDF 自身有 by_deepdoc / by_mineru / by_textln 三种实现,选择路径用 parser_config["layout_recognize"] 字符串比对,没有类型保护。
  • 改造方案
    • 定义 Parser Protocoldef can_parse(filename) -> bool + def parse(filename, binary, **kwargs) -> ParseResult
    • rag/app/parsers/__init__.py 中维护一个 _registry: dict[str, Parser] = {} + @register_parser("docx", "pdf", ...) 装饰器。
    • chunk() 简化为 4 行:parser = registry.find(filename); sections, tables = parser.parse(...); return tokenize(sections, tables)
    • 第三方 parserMinerU、TextIn也注册为可插拔实现运行时由 parser_config.layout_recognize 选择。
  • 收益:新增格式 = 新增一个文件 + 一行 register,不再需要碰 naive.py;测试可针对每个 parser 独立写;naive.py 从 750+ 行降到 100 行以内,可读性大幅提升。
  • 成本与风险:约 5 人日11 类 parser 都要拆)。风险中(要保留 vision_figure_parser_pdf_wrapper 等横切逻辑,需要 hook 点设计)。
  • 优先级P1

【建议 6 · 可观测性】引入 OpenTelemetry全链路 trace + 关键指标埋点 [P1]

  • 问题陈述requirements.txt 中无任何 OTel/Prometheus/Sentry 依赖355 个 logger 调用全是本地日志。无法回答"昨天 P95 多少"、"哪一步最慢"、"哪个 KB 召回率最差"。README 中宣称的 P50/P95 是无源之水。
  • 改造方案
    • requirements.txt 加入 opentelemetry-sdkopentelemetry-instrumentation-fastapiopentelemetry-instrumentation-elasticsearchopentelemetry-instrumentation-redisopentelemetry-instrumentation-celeryopentelemetry-exporter-otlp
    • app/core/rag/observability/tracing.py 提供 @trace_rag_step("embed/search/rerank/generate") 装饰器(基于 opentelemetry.trace.get_tracer),包装 Embedder.embed_* / Retriever.retrieve / Reranker.rerank / Generator.generate
    • 关键指标(opentelemetry.metrics.meter
      • rag.embed.latency_ms{provider, model} Histogram
      • rag.search.recall@k{kb_id, retrieve_type} Counter结合用户反馈数据
      • rag.llm.tokens_used{provider, model, type=prompt|completion} Counter
      • rag.cache.hit_ratio{layer=embed|rerank|llm} Gauge
      • rag.pipeline.e2e_latency_ms{retrieve_type, has_rerank} Histogram
    • LLM 级(chat_model.py:_chat / _chat_streamly)也加 tracer.start_as_current_span,把 token 用量、provider、model 写到 attributes。
  • 收益:实时 P50/P95 / 错误率 / Token 成本可观测oncall 排障从"捞日志 grep"变成"看 Grafana panel"A/B 实验有可量化的 baseline。
  • 成本与风险:约 5 人日(依赖 + 装饰器 + 关键 span + 一份 Grafana JSON 模板。风险低OTel 失败时 no-op
  • 优先级P1(前 2 周做不完,但中期一定要做)。

【建议 7 · 配置治理】中心化配置 + Pydantic Settings + 类型校验 [P1]

  • 问题陈述os.environ.get 出现 58 次散落在 48 个文件;同一变量名多处使用却无单一文档;类型靠 int(os.getenv(...)) 手工转换(elasticsearch_vector.py:699-702 反复出现);缺省值随手填,不一致(如 ELASTICSEARCH_REQUEST_TIMEOUT 文档说 100000源码 elasticsearch_vector.py:699 缺省是 30
  • 改造方案
    • 新增 app/core/rag/config/settings.py:用 pydantic_settings.BaseSettings 把 RAG 相关全部环境变量收拢成 RAGSettings,分组:LLMSettings / EmbeddingSettings / ESSettings / GraphRAGSettings / MinerUSettings / OCRSettings 等。
    • 启动时 RAGSettings() 一次性加载、校验、默认值统一;docs/rag/_meta/config_reference.md 自动生成(用 RAGSettings.model_json_schema() → markdown
    • 现有调用点 os.environ.get("X") 替换为 from app.core.rag.config import settings; settings.x
    • Secret 管理API key / DB 密码强制走 pydantic.SecretStr,禁止默认值。
  • 收益单一可信来源Single Source of Truth类型错误启动期暴露而非运行时运维有完整变量清单CI 可静态校验"是否引入了未注册的环境变量"。
  • 成本与风险:约 4 人日(迁移 58 处调用点 + 文档生成)。风险低(一次性脚本可批量替换)。
  • 优先级P1

【建议 8 · 模块化】消除 init_settings() 模块级副作用 [P1]

  • 问题陈述rag/common/settings.py:24 在模块导入时立即执行 init_settings(),创建进程级 docStoreConn = ESConnection()retriever = Dealer(...)kg_retriever = KGSearch(...)。任何 from app.core.rag.common.settings import retriever 都会立即建 ES 连接。
    • 后果:单元测试无法 stubimport 时已触发副作用);多进程/Celery worker 启动时间增加(每个 worker 都连 ES测试容器需要 ES 运行才能 pytest collect
  • 改造方案
    • 替换为 lazy initialization@lru_cache def get_doc_store(): ... / @lru_cache def get_retriever(): ... / @lru_cache def get_kg_retriever(): ...
    • 在 FastAPI 应用层用 dependency injectionfastapi.Depends)注入而非全局 singleton。
    • 测试时用 app.dependency_overrides[get_retriever] = lambda: FakeRetriever() mock。
  • 收益:单测可独立运行(不依赖 ES冷启动延后到首次使用多 worker 避免共享单例的诡异 bug。
  • 成本与风险:约 2 人日(替换 import-style 调用为 Depends)。风险中(要逐个排查 from settings import retriever 的 24 处调用点)。
  • 优先级P1

【建议 9 · 性能优化】Embedding 与 Rerank 批量化 + 异步并发 [P1]

  • 问题陈述
    • rag/llm/embedding_model.py:50 OpenAIEmbed.encode()batch_size = 16 硬编码QWenEmbed 是 4HuggingFaceEmbed 是无(全量发送)。EMBEDDING_BATCH_SIZE 在 README 提过但代码注释掉未生效。
    • elasticsearch_vector.py:55-63 add_chunks 是同步循环,无 trio/asyncio 并发;workflow/nodes/knowledge/node.py:knowledge_retrieval 多 KB 检索时是 await asyncio.gather 并发的,但单 KB 内 vector + full_text 是顺序调用。
    • GraphRAG 已经用 trio.CapacityLimiter(MAX_CONCURRENT_CHATS=10) 限流(graphrag/utils.py:41),但 ES VDB 写入对应的限流不存在。
  • 改造方案
    • Embedder 协议提供 batch_size 字段,默认从 RAGSettings.embedding.batch_size 读取,每个 provider 可 override。
    • ElasticSearchVector.add_chunks 改为 trio 协程版本,与 GraphRAG 共享 chat_limiter 限流。
    • HybridRetriever.retrieve 内部 vector + full_textasyncio.gather 并发(当前在 node 层做了,下沉到 Retriever
  • 收益Embedding 大批量入库 P95 下降 20-40%(瓶颈从串行 16-batch HTTP 变并发Hybrid 检索单次 P50 下降 30-50%(从串行 → 并发 max 而非 sum
  • 成本与风险:约 3 人日。风险中trio 与 asyncio 混用要小心,已有 trio.to_thread.run_sync 模式可参考)。
  • 优先级P1

【建议 10 · 可观测性 + 配置】消灭遗留 print() 与无结构化日志 [P2]

  • 问题陈述
    • workflow/nodes/knowledge/node.py:327 print(reranked_docs) 残留调试语句;同类 print 在 rag/ 目录共有数十处grep 验证)。
    • 现有 logger 是非结构化字符串日志(logger.info(f"add_texts result:{result}") elasticsearch_vector.py:86),无法 ELK 聚合查询。
  • 改造方案
    • 引入 structlog,所有 logger.* 调用改为 KV 格式:logger.info("vdb.add_texts", n_docs=len(actions), index=self._collection_name, took_ms=...)
    • pre-commit hook 加 flake8-print 阻止新 print 进入仓库。
    • 一次性 sweep 删除现有 print
  • 收益:日志可聚合查询("过去 1 小时 add_texts 平均 n_docs"CI 防止回归。
  • 成本与风险:约 2 人日。风险低。
  • 优先级P2

【建议 11 · 接口抽象】把 BaseVector 的"多模态分支"抽象到 Embedder 而非 VDB 层 [P2]

  • 问题陈述elasticsearch_vector.py:55-63add_chunks:374-380 search_by_vector 都有 if self.is_multimodal_embedding: ... else: ... 分支判断(火山引擎多模态走 embed_batch/embed_text,其他走 embed_documents/embed_query)。这是把"Embedder 的能力差异"泄露到了 VDB 层 — 违反单一职责。
  • 改造方案
    • Embedder Protocol 内部统一接口:embed(items: list[Item]) -> list[list[float]],其中 Item = TextItem | ImageItem | VideoItem。多模态 Embedder 内部分发到 multimodal_embeddings.create,文本 Embedder 走 embed_documents
    • VDB 层只调 embedder.embed(...),不再有 is_multimodal 分支。
  • 收益VDB 与 Embedder 职责清晰;后续接入 ColBERT / SPLADE / 多向量 Embedding 时无需修改 VDB。
  • 成本与风险:约 2 人日
  • 优先级P2(依赖建议 1 完成)。

2. PoC 代码草案

2.1 PoC-1统一 Retriever / Reranker / Generator 协议(建议 2

# api/app/core/rag/protocols/__init__.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, AsyncIterator, runtime_checkable

@dataclass(slots=True)
class Query:
    text: str
    kb_ids: list[str]
    top_k: int = 4
    similarity_threshold: float = 0.2
    rerank: bool = False
    extras: dict = field(default_factory=dict)  # 其他场景化参数

@dataclass(slots=True)
class Doc:
    id: str
    content: str
    score: float
    metadata: dict = field(default_factory=dict)

@dataclass(slots=True)
class RetrievalResult:
    docs: list[Doc]
    total: int
    debug: dict = field(default_factory=dict)  # latency_ms, recall_strategy, etc.

@runtime_checkable
class Retriever(Protocol):
    name: str
    async def retrieve(self, query: Query) -> RetrievalResult: ...

@runtime_checkable
class Reranker(Protocol):
    async def rerank(self, query: str, docs: list[Doc], top_k: int) -> list[Doc]: ...

@runtime_checkable
class Generator(Protocol):
    async def generate_stream(self, prompt: str, history: list[dict],
                              context: list[Doc]) -> AsyncIterator[str]: ...
# api/app/core/rag/retrievers/hybrid_retriever.py
import asyncio
from app.core.rag.protocols import Retriever, Query, RetrievalResult, Doc
from app.core.rag.vdb.vector_base import BaseVector
from app.core.rag.protocols.reranker import Reranker

class HybridRetriever(Retriever):
    name = "hybrid"
    def __init__(self, vector_store: BaseVector, reranker: Reranker | None = None,
                 vector_weight: float = 0.7):
        self._store = vector_store
        self._reranker = reranker
        self._vector_weight = vector_weight

    async def retrieve(self, query: Query) -> RetrievalResult:
        vec_task = asyncio.to_thread(
            self._store.search_by_vector, query.text, top_k=query.top_k * 4)
        bm25_task = asyncio.to_thread(
            self._store.search_by_full_text, query.text, top_k=query.top_k * 4)
        vec_docs, bm25_docs = await asyncio.gather(vec_task, bm25_task)
        merged = self._fuse_rrf(vec_docs, bm25_docs)  # Reciprocal Rank Fusion
        if self._reranker and query.rerank and merged:
            docs = await self._reranker.rerank(
                query.text, merged, top_k=query.top_k)
        else:
            docs = merged[:query.top_k]
        return RetrievalResult(docs=docs, total=len(merged),
                               debug={"strategy": self.name})

    @staticmethod
    def _fuse_rrf(a: list[Doc], b: list[Doc], k: int = 60) -> list[Doc]:
        scores = {}
        for rank, d in enumerate(a):
            scores[d.id] = scores.get(d.id, 0) + 1 / (k + rank + 1)
        for rank, d in enumerate(b):
            scores[d.id] = scores.get(d.id, 0) + 1 / (k + rank + 1)
        all_docs = {d.id: d for d in a + b}
        return sorted((all_docs[i] for i in scores),
                      key=lambda d: scores[d.id], reverse=True)
# api/app/core/workflow/nodes/knowledge/node_v2.py重构后
class KnowledgeRetrievalNodeV2(BaseNode):
    def __init__(self, retriever: Retriever, ...):
        self._retriever = retriever  # 注入,不再 match retrieve_type
    async def execute(self, state) -> dict:
        query = Query(text=self._render_query(state),
                      kb_ids=self._kb_ids, top_k=self._top_k,
                      rerank=bool(self._reranker_id))
        result = await self._retriever.retrieve(query)
        return {"chunks": [d.content for d in result.docs],
                "citations": [d.metadata for d in result.docs]}

2.2 PoC-2Embedder + Redis 缓存装饰器(建议 1 + 4

# api/app/core/rag/cache/embed_cache.py
import json, xxhash, numpy as np
from functools import wraps

def cached_embedder(redis_client, ttl: int = 24 * 3600):
    def decorator(func):
        @wraps(func)
        def wrapper(self, texts, *args, **kwargs):
            if isinstance(texts, str):
                texts = [texts]
            keys = [_key(self.model_name, t) for t in texts]
            cached = redis_client.mget(keys)
            results, miss_idx, miss_texts = [None]*len(texts), [], []
            for i, b in enumerate(cached):
                if b: results[i] = np.array(json.loads(b))
                else: miss_idx.append(i); miss_texts.append(texts[i])
            if miss_texts:
                fresh = func(self, miss_texts, *args, **kwargs)  # ndarray, n_tokens
                vecs = fresh[0] if isinstance(fresh, tuple) else fresh
                pipe = redis_client.pipeline()
                for j, idx in enumerate(miss_idx):
                    results[idx] = vecs[j]
                    pipe.setex(keys[idx], ttl, json.dumps(vecs[j].tolist()))
                pipe.execute()
            return np.array(results), 0  # tokens cached as 0; metric layer补
        return wrapper
    return decorator

def _key(model: str, text: str) -> str:
    h = xxhash.xxh64(); h.update(f"{model}\0{text}".encode()); return f"emb:{h.hexdigest()}"

使用方式:

class OpenAIEmbed(Base):
    @cached_embedder(redis_client)  # 一行注解开启缓存
    def encode(self, texts: list): ...

3. 改造路线图

实施前提:先用 1 周时间立两个 baseline —— (a) 当前端到端 P50/P95即使靠手工脚本采(b) 单测覆盖率pytest --cov。所有改造完成后用同一 baseline 比对,验证收益。

3.1 短期Sprint 011-2 周内交付)

目标:止血 + 解锁后续重构的前置条件。

# 工作项 关联建议 工作量 交付物
1 删除 node.py:327 print() 残留 + 全仓 print 扫除 #10 0.5d PR + pre-commit hook
2 实现 RerankerService(含 reranker 实例缓存) #3 2d 新模块 + 单测 + 替换现有 3 处 rerank
3 Embedder.encode/encode_queries 加 Redis 缓存装饰器 #4 1.5d 装饰器 + benchmark 报告
4 中心化配置:RAGSettings Pydantic Settings 框架 #7 2d app/core/rag/config/settings.py + 迁移 ES + LLM 配置
5 迁移单元测试:先把 settings.py 的 init_settings() 副作用改 lazy #8 2d pytest 不再依赖 ES 即可 collect

短期里程碑Sprint 1 末)

  • 调试 print 残留清零;
  • 单测可独立运行(脱离 ES
  • Reranker 命中场景延迟下降 50%+
  • Query Embedding 命中场景延迟下降 70%+。

3.2 中期Sprint 241-2 月内交付)

目标:完成核心抽象层重构,引入可观测性。

# 工作项 关联建议 工作量 交付物
6 设计 + 落地 Embedder Protocol迁移 OpenAIEmbed/QWenEmbed/... #1 5d 协议 + 适配器 + 弃用计划文档
7 设计 + 落地 Retriever / Reranker / Generator Protocol实现 VectorRetriever BM25Retriever HybridRetriever GraphAugmentedRetriever #2 8d 协议 + 4 个实现 + 节点改造
8 OpenTelemetry 接入:在 RAG 关键路径加 span 与 metric #6 5d observability/tracing.py + Grafana 模板 + 文档
9 Plugin Registry 重构 naive.py 解析器分发 #5 5d parsers/ 模块化 + 11 个 parser 注册
10 配置治理收尾:剩余 50+ 处 os.environ.get 全部迁到 RAGSettings #7 2d 文档自动生成脚本
11 Embedder 与 Rerank 批量化 + 异步并发改造 #9 3d 性能 benchmark 对比报告

中期里程碑Sprint 4 末)

  • 抽象层统一完成Embedder / Retriever / Reranker / Generator 四大协议落地);
  • Grafana 实时面板P50/P95/Token 用量/缓存命中率;
  • 单测覆盖率 RAG 模块从 ~5% 提升到 ≥35%
  • 端到端 P95 较 baseline 下降 30%+。

3.3 长期Sprint 583-6 月内交付)

目标:可插拔架构、生产级稳定性、为 [S3-T2] 列出的多模态 / 混合搜索增强 / KG 演化做铺垫。

# 工作项 关联建议 工作量 交付物
12 多模态分支从 VDB 抽离到 Embedder #11 2d VDB 接口收敛
13 引入第二个 VDB 实现(如 Milvus验证 BaseVector 可插拔 #2 8d MilvusVector + 一致性测试套件
14 LLM Provider 也改 Plugin Registry消除 chat_model.py 11 个子类的 if 切换) #5 5d LLM 层与 Embedding 层架构对齐
15 完整的 Pipeline = Retriever | Reranker | Generator DSL配置驱动 #2 10d YAML 描述场景 → 运行时拼装
16 A/B 实验框架:基于 OTel metric把 recall@k / answer_score 接入实验对比 #6 5d 实验平台对接文档
17 LLM 失败模型降级链fallback to 备用 provider #2 + 现有 Base 增强 3d FallbackGenerator 实现
18 安全 / Secret 管理:从 pydantic.SecretStr 升级到 Vault / Secrets Manager 集成 #7 5d 密钥不进 .env 文件

长期里程碑Sprint 8 末)

  • 可插拔 VDB一行配置切换 ES → Milvus
  • Pipeline DSL 上线,新增"GraphRAG-Then-Vector-Then-Rerank"等组合无需改代码;
  • 全链路 Trace + 指标 + A/B 框架就绪;
  • 为 [S3-T2] 中"多模态检索 / SPLADE / ColBERT 路由 / KG 演化 / 反馈闭环"等扩展提供清晰的接口注入点。

4. 风险与依赖统一汇总

风险类别 描述 缓解方案
回归风险(高) Embedder 协议迁移可能改变返回类型(np.ndarray vs list[list[float]] 6 个月兼容期,旧接口保留并打 DeprecationWarningCI 加契约测试
回归风险(中) KnowledgeRetrievalNode 接口改造,影响 workflow 已部署应用 引入 node_v2.py,灰度切换;保留 node.py 至少一个 release
依赖风险 OpenTelemetry 接入需 collector / Tempo / Loki 等基础设施 短期可先只导出到 stdout exporter基础设施分阶段建设
协作依赖 @Python 开发工程师 一起验证 PoC 与迁移可行性 Sprint 0 启动前先 1 次架构对齐会
运营依赖 配置治理(建议 #7落地后运维需更新部署脚本与文档 切换前 2 周通知;提供变量映射表(旧 → 新)

5. 验收 Checklist 自检

  • 至少 8 条建议(实际 11 条)
  • 覆盖 5 个方向:模块化拆分(#1, #3, #5, #8/ 接口抽象(#1, #2, #11/ 性能优化(#4, #5, #9/ 可观测性(#6, #10/ 配置与依赖治理(#7
  • 每条建议均有源码引用(文件:行号 + 关键摘录)
  • PoC 代码草案:2 套(统一 Retriever 协议 + Embedder 缓存装饰器,均在 1050 行)
  • 现状评估3 优点 + 5 痛点
  • 改造路线图:短期 / 中期 / 长期 三阶段,每阶段附交付物清单
  • 与 [S2-T7] Sprint-2 文档兼容:引用 S2-T2 Embedding 双轨问题、S2-T5 LLM/Reranking 三处 rerank 实现,与 S1-T3 Gap 报告 中识别的"rag_utils vs rag/utils 命名冲突"等差异交叉印证
  • 提交至 [S3-T3] 终审

文档基于 MemoryBear agent/ai/f8de881a 分支(基于 commit feae2f2e)逐文件核验。所有源码引用可在 ±3 行内复现。