# [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 分发)→ Embedding(10+ Provider)→ Hybrid 检索(BM25 + 向量)→ GraphRAG(light/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` 通过 `retriever`(Dealer)+ `kg_retriever`(KGSearch)两个全局单例并行存在,应用层(`workflow/nodes/knowledge/node.py`)可在 PARTICIPLE / SEMANTIC / HYBRID / GRAPH 四种检索模式间切换,灵活度高,是 MemoryBear 区别于通用 RAG 的核心特色。 ### 0.2 五个痛点(基于 S1-T3 Gap 报告 + 源码核验) 1. **抽象层不统一,存在双轨甚至三轨实现**: - **Embedding 双轨**:`rag/models/embedding.py RedBearEmbeddings`(LangChain,新,被 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-643`,token+vector+rank_feature 加权)。三套互不知晓彼此存在。 - **VDB 抽象有名无实**:`vector_base.py:9 BaseVector` 仅定义了 9 个抽象方法,但唯一实现为 `ElasticSearchVector`,且 `node.py:14`、`tasks.py` 直接 import 具体类 `ElasticSearchVectorFactory` 绕过基类,抽象层失效。 2. **配置散落,无中心化治理**:`os.environ.get` / `os.getenv` 在 `rag/` 目录下出现 **58 次**,分布在 48 个文件。例如 `LLM_TIMEOUT_SECONDS`/`LLM_MAX_RETRIES`(`chat_model.py:54-58`)、`MAX_CONCURRENT_CHATS`(`graphrag/utils.py:41`)、`ELASTICSEARCH_HOST/PORT/USERNAME/PASSWORD/REQUEST_TIMEOUT/MAX_RETRIES`(`elasticsearch_vector.py:685-707`)、`MINERU_EXECUTABLE/APISERVER/OUTPUT_DIR/BACKEND/DELETE_OUTPUT`(`naive.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` import(`workflow/nodes/knowledge/node.py:12`)。 - `chat_model.py` 中 `ChatBase` 子类硬编码各 provider 的 base_url 与认证 header(如 `chat_model.py:41-44 OpenAIEmbed.__init__` 直接拼 base_url),切换路径不优雅。 --- ## 1. 架构改造建议清单(共 11 条) 每条建议结构:**问题 → 方案 → 收益 → 成本/风险 → 优先级**。 ### 【建议 1 · 模块化】拆掉双轨 Embedding,统一到单一 Embedder 协议 `[P0]` - **问题陈述**:`RedBearEmbeddings`(LangChain)与 `OpenAIEmbed/QWenEmbed/...`(遗留)两套并存,调用方用哪一个看心情;接口不兼容(`embed_documents/embed_query` vs `encode/encode_queries`),返回类型不一致(`list[list[float]]` vs `(np.ndarray, total_tokens)`)。 - 源码:`rag/models/embedding.py:9-78`、`rag/llm/embedding_model.py:14-65`、`rag/graphrag/utils.py:301-327`(GraphRAG 调用 `embd_mdl.encode([ent_name])`)、`rag/nlp/search.py:365-373`(Dealer 调用 `emb_mdl.encode_queries(txt)`)。 - **改造方案**: - 定义 `app/core/rag/protocols/embedder.py` 中的 `Embedder` Protocol:`embed_documents(texts) -> EmbedResult` 与 `embed_query(text) -> EmbedResult`,`EmbedResult` 是 `dataclass(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 章节): ```python 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`,而是注入一个 `Retriever`(`HybridRetriever` / `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_id`,TTL=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 查询 5–20ms,去掉后**热路径单次省 5-20ms × QPS**);rerank 逻辑只需在一处 review 与单测。 - **成本与风险**:约 **3 人日**。风险低(接口对外不变)。 - **优先级**:**P0**(含调试残留的 hot fix 应优先合并)。 ### 【建议 4 · 性能优化】Embedder 与 Reranker 加缓存层 `[P0]` - **问题陈述**: - GraphRAG 用 Redis 缓存 Embedding(`graphrag/utils.py:115-134`,TTL=24h,key=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` 每次都打外部 API(DashScope/Jina),200+ 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=1h(rerank 结果对 query 变体很敏感,不要 TTL 太长)。 - 从环境变量读 `REDIS_*` 配置,cache 失败时优雅降级为 no-op(不要 break 主链路)。 - **收益**:Query embedding 命中场景 **减少 60-90% 外部 API 调用**(基于业内同类系统 query 重复率统计)。Rerank 命中场景再减少 30-50%。**单 query 端到端 P95 下降 100-300ms**(Rerank 是当前最慢的同步阻塞步骤之一)。 - **成本与风险**:约 **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` Protocol:`def 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)`。 - 第三方 parser(MinerU、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-sdk`、`opentelemetry-instrumentation-fastapi`、`opentelemetry-instrumentation-elasticsearch`、`opentelemetry-instrumentation-redis`、`opentelemetry-instrumentation-celery`、`opentelemetry-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 连接。 - 后果:单元测试无法 stub(import 时已触发副作用);多进程/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 injection(`fastapi.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` 是 4,`HuggingFaceEmbed` 是无(全量发送)。`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_text` 用 `asyncio.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-63` 的 `add_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) ```python # 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]: ... ``` ```python # 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) ``` ```python # 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-2:Embedder + Redis 缓存装饰器(建议 1 + 4) ```python # 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()}" ``` 使用方式: ```python 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 0–1,1-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 2–4,1-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 5–8,3-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 个月兼容期,旧接口保留并打 `DeprecationWarning`;CI 加契约测试 | | **回归风险(中)** | `KnowledgeRetrievalNode` 接口改造,影响 workflow 已部署应用 | 引入 `node_v2.py`,灰度切换;保留 `node.py` 至少一个 release | | **依赖风险** | OpenTelemetry 接入需 collector / Tempo / Loki 等基础设施 | 短期可先只导出到 stdout exporter,基础设施分阶段建设 | | **协作依赖** | 与 [@Python 开发工程师](mention://agent/f4d1c89f-0c71-4af3-bf72-d34f7ed115cf) 一起验证 PoC 与迁移可行性 | Sprint 0 启动前先 1 次架构对齐会 | | **运营依赖** | 配置治理(建议 #7)落地后,运维需更新部署脚本与文档 | 切换前 2 周通知;提供变量映射表(旧 → 新) | --- ## 5. 验收 Checklist 自检 - [x] 至少 8 条建议(实际 11 条) - [x] 覆盖 5 个方向:模块化拆分(#1, #3, #5, #8)/ 接口抽象(#1, #2, #11)/ 性能优化(#4, #5, #9)/ 可观测性(#6, #10)/ 配置与依赖治理(#7) - [x] 每条建议均有源码引用(文件:行号 + 关键摘录) - [x] PoC 代码草案:**2 套**(统一 Retriever 协议 + Embedder 缓存装饰器,均在 10–50 行) - [x] 现状评估:3 优点 + 5 痛点 - [x] 改造路线图:短期 / 中期 / 长期 三阶段,每阶段附交付物清单 - [x] 与 [S2-T7] Sprint-2 文档兼容:引用 [S2-T2 Embedding](mention://issue/7a8cd047-f339-427e-bd60-999c62caea22) 双轨问题、[S2-T5 LLM/Reranking](mention://issue/eef8ed99-c13e-43ba-a2b3-2c9e59b74301) 三处 rerank 实现,与 [S1-T3 Gap 报告](mention://issue/264529aa-1856-4505-8e26-6125df061c18) 中识别的"`rag_utils` vs `rag/utils` 命名冲突"等差异交叉印证 - [x] 提交至 [S3-T3] 终审 --- *文档基于 MemoryBear `agent/ai/f8de881a` 分支(基于 commit `feae2f2e`)逐文件核验。所有源码引用可在 ±3 行内复现。*