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

435 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# [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` 通过 `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 查询 520ms去掉后**热路径单次省 5-20ms × QPS**rerank 逻辑只需在一处 review 与单测。
- **成本与风险**:约 **3 人日**。风险低(接口对外不变)。
- **优先级****P0**(含调试残留的 hot fix 应优先合并)。
### 【建议 4 · 性能优化】Embedder 与 Reranker 加缓存层 `[P0]`
- **问题陈述**
- GraphRAG 用 Redis 缓存 Embedding`graphrag/utils.py:115-134`TTL=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-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)`。
- 第三方 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-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 连接。
- 后果:单元测试无法 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 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-2Embedder + 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 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 个月兼容期,旧接口保留并打 `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 缓存装饰器,均在 1050 行)
- [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 行内复现。*