Files
MemoryBear/docs/rag/pipeline/03-vdb-and-retrieval.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

53 KiB
Raw Blame History

[S2-T3] 向量数据库选型、索引与检索策略实现详解

范围:api/app/core/rag/vdb/elasticsearch/api/app/core/rag/utils/es_conn.pyapi/app/core/rag/utils/doc_store_conn.pyapi/app/core/rag/nlp/{search.py, query.py}api/app/core/rag/res/mapping.json 以及调用方 api/app/core/workflow/nodes/knowledge/node.pyapi/app/services/memory_konwledges_server.py 等。

提示MemoryBear 当前版本中存在两套并行的 ES 实现路径,本文会逐一拆开说明,并给出二者的边界与实际调用方。


一、一句话定位

MemoryBear 使用 Elasticsearch 8.x 作为向量 + 全文一体化的检索引擎,通过 dense_vector (HNSW) 实现语义检索、Lucene + IK 分词器实现关键词检索,并在应用层与 ES DSL 层各自实现一套"混合搜索"策略(应用层为"双路 + 去重 + 可选 Rerank"DSL 层为"weighted_sum 加权融合")。

二、设计目标与选型说明

2.1 选型动机(为什么是 Elasticsearch 而非 Milvus / Qdrant / Pinecone

README 中明确把 "Hybrid Search: Keyword + Semantic Vector" 列为产品级核心能力之一README.md:62-66。结合源码可以推出三条关键决策依据

  1. 关键词侧需要 Lucene 生态 — 既要中文分词IK ik_max_word),又要 BM25 / 布尔过滤 / 高亮 / 同义词扩展 / 短语匹配 / 字段权重等成熟能力Milvus / Qdrant / Pinecone 在这一侧几乎都需要外接 ES/OS。api/app/core/rag/nlp/query.py:14-22query_fields = ["title_tks^10", "important_kwd^30", "content_ltks^2", ...] 就是典型 Lucene field-boost 写法,离开 ES 改造代价很高。
  2. 一份索引同时承担多种载荷 — 一个 ES 索引同时存储 chunk 文本 (page_content)、向量 (*_vec)、稀疏 tokens (*_tks / *_ltks)、标签 rank_features (tag_feas)、PageRank-like 分数 (pagerank_fea)、地理 (lat_lon)、嵌套结构 (*_nst) 等异构字段(见 api/app/core/rag/res/mapping.json:25-209)。专用向量库无法承载这种混合 schema。
  3. 运维与生态成本 — 团队仅运行 PostgreSQL / Neo4j / Redis / ESREADME "Prerequisites"),引入第二套向量服务会显著抬高运维曲线。@singletonESConnection (api/app/core/rag/utils/es_conn.py:26-56) 与 ElasticSearchVectorFactory._client (api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:666-732) 共享连接,工程上已经按"单实例多用途"在使用 ES。

代价ES 的 ANN 在百万-千万 chunk 时延迟会明显高于 Milvus/Qdrant当未来 chunk 量级或 QPS 显著增长时,本架构需要拆出独立向量服务(详见 §6 优化建议)。

2.2 ES 版本约束

启动期硬性校验 ES 必须 ≥ 8.0

# api/app/core/rag/utils/es_conn.py:44-49
v = self.info.get("version", {"number": "8.0.0"})
v = v["number"].split(".")[0]
if int(v) < 8:
    msg = f"Elasticsearch version must be greater than or equal to 8, current version: {v}"
    logger.error(msg)
    raise Exception(msg)
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:716-722
if not cls._version_checked:
    info = client.info()
    version = info["version"]["number"]
    if parse_version(version) < parse_version("8.0.0"):
        raise ValueError(f"Elasticsearch version must be >= 8.0.0, got {version}")

whyES 8.0 才正式提供 dense_vector HNSW 索引、knn 顶层查询、以及 query_string + knn 的混合检索,本系统的 s.knn(...)type=dense_vector index=true similarity=cosine 都依赖该版本。


三、模块结构与两条实现路径

api/app/core/rag/
├── res/mapping.json            ← graphrag_{workspace_id} 索引使用的全局 mapping
├── utils/
│   ├── doc_store_conn.py       ← 抽象接口 DocStoreConnection + MatchExpr / FusionExpr
│   └── es_conn.py              ← @singleton 实现 DocStoreConnection路径 A
├── vdb/
│   ├── vector_base.py          ← 抽象接口 BaseVector
│   ├── field.py                ← page_content / metadata / vector 等字段名常量
│   └── elasticsearch/
│       └── elasticsearch_vector.py  ← BaseVector 的 ES 实现(路径 B
├── nlp/
│   ├── search.py               ← 同时承载两条路径knowledge_retrieval路径 B+ Dealer路径 A
│   └── query.py                ← FulltextQueryer构造 Lucene query_string
└── common/
    ├── settings.py             ← 全局初始化 docStoreConn / retriever / kg_retriever
    └── constants.py            ← PAGERANK_FLD / TAG_FLD 等常量

3.1 路径 AESConnectionDSL 抽象层,主要服务于 GraphRAG 与高级检索)

  • 抽象基类:api/app/core/rag/utils/doc_store_conn.py:128-256 定义 DocStoreConnection 接口dbType / createIdx / search / insert / update / delete / sql 等)。
  • 表达式族:同文件 43-126 行定义 MatchTextExprMatchDenseExprMatchSparseExprMatchTensorExprFusionExprOrderByExpr —— 这是上层与底层解耦的"查询 IR"。
  • ES 实现:@singleton class ESConnection(DocStoreConnection)api/app/core/rag/utils/es_conn.py:26-634)。
  • 全局入口:api/app/core/rag/common/settings.py:13-24 在模块导入时即 init_settings(),把 ESConnection() 装进 docStoreConn,并注入 Dealer / KGSearch
  • 对应的检索门面:api/app/core/rag/nlp/search.py: Dealer350-907 行),由 kg_retrieverretriever 全局共用。

3.2 路径 BElasticSearchVector(应用层 BaseVector主要服务于 KB 节点 / 工作流)

  • 抽象基类:api/app/core/rag/vdb/vector_base.py:9-67 定义 BaseVector 接口create / add_texts / search_by_vector / search_by_full_text / delete 等)。
  • 字段命名:api/app/core/rag/vdb/field.pypage_content / metadata / vector / metadata.doc_id 等。
  • ES 实现:class ElasticSearchVector(BaseVector) + class ElasticSearchVectorFactoryapi/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:29-732)。
  • 关键调用方:
    • api/app/core/workflow/nodes/knowledge/node.py:195-298 工作流知识节点,按 RetrieveType 分支调用 search_by_vector / search_by_full_text
    • api/app/core/rag/nlp/search.py: knowledge_retrieval36-147 行API/服务层入口。
    • api/app/services/memory_konwledges_server.pyapi/app/controllers/{chunk,document,knowledge}_controller.py 等。

3.3 两条路径的边界

维度 路径 AESConnection / Dealer 路径 BElasticSearchVector
索引名 graphrag_{workspace_id} Vector_index_{kb_id}_Node(小写)
索引粒度 一个 workspace 一个 ES index多 KB 用 kb_id 字段过滤 一个 KB 一个 ES index
Mapping res/mapping.json 全局 dynamic_templates 代码里 inline 的 index_mapping (elasticsearch_vector.py:616-661)
文本字段 content_ltks / content_sm_ltks / title_tks / important_kwd / *_tks page_content (text + ik_max_word)
向量字段 动态 q_{dim}_vec (*_512_vec / *_768_vec / *_1024_vec / *_1536_vec) 固定 vectordim 取首批 embeddings 长度)
关键词检索 Lucene query_stringfield-boost、同义词、短语 match + analyzer=ik_max_wordBM25
向量检索 s.knn(...)HNSWES 8 原生 ANN script_score + cosineSimilarity(暴力,但精度高)
混合融合 FusionExpr("weighted_sum", weights="0.05,0.95") 应用层加权 + ES 内部混合 双路并发查 → metadata.doc_id 去重 → 可选 reranker
主要使用者 GraphRAG、Dealer.retrieval()、tag/citation 等高级能力 工作流知识节点、KB CRUD、召回测试

why 不合并:路径 A 携带丰富 IR同义词扩展、tag_feaspagerank_feaquestion_tks 等),是面向"知识图谱 + 复杂 RAG"的;路径 B 简单直接,是工作流/服务层的"够用就好"封装。代码上是渐进演化中的双轨,但目前两条路径都在生产使用


四、索引设计

4.1 全局 mapping路径 Aapi/app/core/rag/res/mapping.json

4.1.1 settings

// api/app/core/rag/res/mapping.json:2-15
"settings": {
  "index": {
    "number_of_shards": 2,
    "number_of_replicas": 0,
    "refresh_interval": "1000ms"
  },
  "similarity": {
    "scripted_sim": {
      "type": "scripted",
      "script": {
        "source": "double idf = Math.log(1+(field.docCount-term.docFreq+0.5)/(term.docFreq + 0.5))/Math.log(1+((field.docCount-0.5)/1.5)); return query.boost * idf * Math.min(doc.freq, 1);"
      }
    }
  }
}
说明
number_of_shards 2 适合中小型部署;超过 50GB / 单 shard 时需重新规划
number_of_replicas 0 生产风险点:单副本意味着任一分片丢失即数据丢失,建议生产环境改为 ≥1
refresh_interval 1000ms 默认 1s 即可见,写入吞吐场景可调高至 30s 或写入期 -1
scripted_sim 自定义 BM25 变体 Math.min(doc.freq, 1) 把词频压成 0/1等价于 binary BM25——抑制高 TF 的关键字"灌水",对 token 字段更鲁棒

4.1.2 dynamic_templates按字段名后缀决定字段类型

// api/app/core/rag/res/mapping.json:25-209节选
{ "int":           { "match": "*_int",   "mapping": { "type": "integer", "store": "true" }}},
{ "ulong":         { "match": "*_ulong", "mapping": { "type": "unsigned_long" }}},
{ "long":          { "match": "*_long",  "mapping": { "type": "long" }}},
{ "numeric":       { "match": "*_flt",   "mapping": { "type": "float" }}},

{ "tks":  { "match": "*_tks",  "mapping": { "type": "text", "similarity": "scripted_sim", "analyzer": "whitespace" }}},
{ "ltks": { "match": "*_ltks", "mapping": { "type": "text", "analyzer": "whitespace" }}},

{ "kwd":  { "match_pattern": "regex",
            "match": "^(.*_(kwd|id|ids|uid|uids)|uid)$",
            "mapping": { "type": "keyword", "similarity": "boolean" }}},
{ "dt":   { "match_pattern": "regex",
            "match": "^.*(_dt|_time|_at)$",
            "mapping": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||yyyy-MM-dd_HH:mm:ss" }}},

{ "rank_feature":  { "match": "*_fea",  "mapping": { "type": "rank_feature" }}},
{ "rank_features": { "match": "*_feas", "mapping": { "type": "rank_features" }}},

{ "dense_vector": { "match": "*_512_vec",  "mapping": { "type": "dense_vector", "index": true, "similarity": "cosine", "dims": 512 }}},
{ "dense_vector": { "match": "*_768_vec",  "mapping": { "type": "dense_vector", "index": true, "similarity": "cosine", "dims": 768 }}},
{ "dense_vector": { "match": "*_1024_vec", "mapping": { "type": "dense_vector", "index": true, "similarity": "cosine", "dims": 1024 }}},
{ "dense_vector": { "match": "*_1536_vec", "mapping": { "type": "dense_vector", "index": true, "similarity": "cosine", "dims": 1536 }}},

{ "nested": { "match": "*_nst", "mapping": { "type": "nested" }}},
{ "binary": { "match": "*_bin", "mapping": { "type": "binary" }}}

why dynamic 而不是 strict mapping

  • 不同 embedding 模型维度不同512/768/1024/1536通过字段名后缀让"模型即维度",在 nlp/search.py:372 看到查询侧动态拼名 f"q_{len(embedding_data)}_vec",写入侧也是同样命名,零配置切换 embedding。
  • token 字段分 *_tks*_ltks:前者使用 scripted_sim(去 TF用于 important_kwd 这类"命中即可"字段;后者 BM25 默认,用于正文型 content_ltks
  • *_fea (rank_feature) 与 *_feas (rank_features) 用于 PageRank 与 tag 加权,详见检索章节的 _rank_feature_scores

why analyzer 是 whitespace 而不是 IK:路径 A 在写入前先用 rag_tokenizer 在应用层做完中文分词,写入 ES 时已经是空格分隔的 tokens。这样"分词逻辑"留在应用层,便于热更新词典与同义词,不用 reindex。

4.2 路径 B 的 inline mappingKB 索引)

# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:609-663
def create_collection(self, embeddings, metadatas=None, index_params=None):
    if not self._client.indices.exists(index=self._collection_name):
        index_mapping = {
            "mappings": {
                "properties": {
                    Field.CONTENT_KEY.value: {           # "page_content"
                        "type": "text",
                        "analyzer": "ik_max_word"
                    },
                    Field.METADATA_KEY.value: {           # "metadata"
                        "type": "object",
                        "properties": {
                            "doc_id":          {"type": "keyword"},
                            "file_id":         {"type": "keyword"},
                            "file_name":       {"type": "keyword"},
                            "file_created_at": {"type": "date", "format": "epoch_millis"},
                            "document_id":     {"type": "keyword"},
                            "knowledge_id":    {"type": "keyword"},
                            "sort_id":         {"type": "long"},
                            "status":          {"type": "integer"}
                        }
                    },
                    Field.VECTOR.value: {                 # "vector"
                        "type": "dense_vector",
                        "dims": len(embeddings[0]),
                        "index": True,
                        "similarity": "cosine"
                    }
                }
            }
        }
        self._client.indices.create(index=self._collection_name, body=index_mapping)

要点:

  • 索引按 KB 隔离:collection_name = f"Vector_index_{knowledge.id}_Node"(同文件 738 行ES 端要求小写,所以 super().__init__(index_name.lower())32 行)。
  • dims = len(embeddings[0]) —— 维度由"第一批数据"决定,一旦确定不可改。换 embedding 模型必须重建索引(详见 §6 风险点)。
  • similarity = "cosine" —— 写入向量不要求归一化,由 ES 内部计算余弦相似度。
  • 没有显式 number_of_shards/replicas 设置,走 ES 集群默认8.x 默认 1 shard 1 replica可用性比路径 A 反而更好;但碎片化风险也更高(每个 KB 一个 indexKB 多了 cluster state 会膨胀)。

4.3 索引命名与隔离

路径 索引模板 来源
A graphrag_{workspace_id} nlp/search.py:346 def index_name(uid): return f"graphrag_{uid}"
B Vector_index_{kb_id}_Node(小写) elasticsearch_vector.py:738

路径 A 在删除知识库时故意不删 ES 索引,而是仅删 kb_id 维度的文档:

# api/app/core/rag/utils/es_conn.py:115-124
def deleteIdx(self, indexName: str, knowledgebaseId: str):
    if len(knowledgebaseId) > 0:
        # The index need to be alive after any kb deletion since all kb under this workspace are in one index.
        return
    try:
        self.es.indices.delete(index=indexName, allow_no_indices=True)
    except NotFoundError:
        pass

why:一个 workspace 多 KB 共享同一个 index单 KB 删除不能动 index只能在 delete() 通过 condition["kb_id"]=knowledgebaseId 走 delete-by-query同文件 424-471


五、写入链路

5.1 路径 B高层封装KB / 工作流场景)

# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:55-87
def add_chunks(self, chunks: list[DocumentChunk], **kwargs):
    texts = [chunk.page_content for chunk in chunks]
    if self.is_multimodal_embedding:
        embeddings = self.embeddings.embed_batch(texts)
    else:
        embeddings = self.embeddings.embed_documents(list(texts))
    self.create(chunks, embeddings, **kwargs)

def create(self, chunks, embeddings, **kwargs):
    metadatas = [chunk.metadata or {} for chunk in chunks]
    if not self._client.indices.exists(index=self._collection_name):
        self.create_collection(embeddings, metadatas)   # 懒建索引
    self.add_texts(chunks, embeddings, **kwargs)

def add_texts(self, chunks, embeddings, **kwargs):
    uuids = self._get_uuids(chunks)
    actions = []
    for i, chunk in enumerate(chunks):
        actions.append({
            "_index": self._collection_name,
            "_source": {
                Field.CONTENT_KEY.value: chunk.page_content,
                Field.METADATA_KEY.value: chunk.metadata or {},
                Field.VECTOR.value: embeddings[i] or None,
            }
        })
    result = helpers.bulk(self._client, actions)
    return uuids

特性:

  • 懒建索引:第一次写入时根据 len(embeddings[0]) 建 mapping。
  • 批量写elasticsearch.helpers.bulk 默认 chunk_size=500、max_chunk_bytes=100MB这里不传 _idES 自动生成。
  • 唯一性:路径 B 把 chunk 唯一标识放在 metadata.doc_idvector_base.py:62-63 _get_uuids),更新/删除走"先 search by metadata.doc_id 拿真正 _id 再 bulk delete"两步走(elasticsearch_vector.py:148-174)。
  • 失败处理helpers.bulk 默认抛 BulkIndexError,调用方在 delete_by_ids / delete_by_metadata_field 中分桶捕获 404 与其它错误(同文件 137-147、164-174add_texts 没有捕获——一旦底层网络失败会向上抛,调用方需要保证幂等性或重试。

5.2 路径 A抽象层批量写

# api/app/core/rag/utils/es_conn.py:294-330
def insert(self, documents, indexName, knowledgebaseId=None) -> list[str]:
    operations = []
    for d in documents:
        assert "_id" not in d
        assert "id" in d
        d_copy = copy.deepcopy(d)
        d_copy["kb_id"] = knowledgebaseId
        meta_id = d_copy.pop("id", "")
        operations.append({"index": {"_index": indexName, "_id": meta_id}})
        operations.append(d_copy)

    res = []
    for _ in range(ATTEMPT_TIME):       # 默认 2 次
        try:
            r = self.es.bulk(index=indexName, operations=operations,
                             refresh=False, timeout="60s")
            if re.search(r"False", str(r["errors"]), re.IGNORECASE):
                return res
            for item in r["items"]:
                for action in ["create", "delete", "index", "update"]:
                    if action in item and "error" in item[action]:
                        res.append(str(item[action]["_id"]) + ":" + str(item[action]["error"]))
            return res
        except ConnectionTimeout:
            time.sleep(3); self._connect(); continue
        except Exception as e:
            res.append(str(e)); break
    return res

要点:

  • 显式 _id = id:调用方自己保证 chunk_id 唯一(典型实现:uuid4() 或基于 doc_id+chunk_idx 的稳定 hash重复写入即"覆盖式更新",天然支持幂等重试。
  • 强制注入 kb_id:所有 chunk 都打上 kb_id 标签,作为多租户隔离与 delete-by-query 的依据。
  • refresh=False:写入不等可见,吞吐优先;查询侧通过 1s 默认 refresh 间隔获得近实时性。
  • 显式 timeout="60s" + ATTEMPT_TIME=2 重连 —— 网络抖动会自动重试一次。
  • 失败回滚? 只返回失败列表,不做事务回滚。这是 ES 的典型用法bulk 是 best-effort调用方需要根据返回值决定是否补偿如 chunker 重新生成失败 chunk

5.3 增量更新(路径 A

# api/app/core/rag/utils/es_conn.py:332-422
def update(self, condition, newValue, indexName, knowledgebaseId) -> bool:
    # 单文档 update
    if "id" in condition and isinstance(condition["id"], str):
        chunkId = condition["id"]
        # 删除字段(带 _feas 后缀的 rank_features 必须先 remove 再 set否则旧 token 残留)
        for k in doc.keys():
            if k.split("_")[-1] == "feas":
                self.es.update(index=indexName, id=chunkId, script=f"ctx._source.remove(\"{k}\");")
        self.es.update(index=indexName, id=chunkId, doc=doc)
        return True

    # 批量 update_by_query构造 painless 脚本
    bqry = Q("bool")
    # ... 把 condition 转成 filter
    scripts = []; params = {}
    for k, v in newValue.items():
        if k == "remove":     # remove 单个 list 元素
            scripts.append(f"int i=ctx._source.{kk}.indexOf(params.p_{kk});ctx._source.{kk}.remove(i);")
        elif k == "add":      # 向 list 追加
            scripts.append(f"ctx._source.{kk}.add(params.pp_{kk});")
        elif isinstance(v, str):
            v = re.sub(r"(['\n\r]|\\.)", " ", v)         # 防止脚本注入
            scripts.append(f"ctx._source.{k}=params.pp_{k};")
        ...
    ubq = UpdateByQuery(index=indexName).using(self.es).query(bqry)\
                                          .script(source="".join(scripts), params=params)\
                                          .params(refresh=True, slices=5, conflicts="proceed")
    ubq.execute()

亮点:

  • slices=5 —— 并行 update-by-query写吞吐放大 5 倍。
  • conflicts="proceed" —— 遇到版本冲突跳过而不中止任务;适合"标签批量更新"这种最终一致场景。
  • rank_features 必须先 remove:因为 *_feas 是"key→score"字典,新值无法覆盖旧 key341-346 行的 patch
  • input sanitation:对 string 值做 re.sub(r"(['\n\r]|\\.)", " ", v) 防止 painless 脚本注入。

5.4 路径 B 的 update_by_query

# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:299-342
def update_by_segment(self, chunk: DocumentChunk, **kwargs) -> str:
    if self.is_multimodal_embedding:
        chunk.vector = self.embeddings.embed_text(chunk.page_content)
    else:
        chunk.vector = self.embeddings.embed_query(chunk.page_content)
    body = {
        "script": {
            "source": """
                ctx._source.page_content = params.new_content;
                ctx._source.vector = params.new_vector;
            """,
            "params": {"new_content": chunk.page_content, "new_vector": chunk.vector}
        },
        "query": {"term": {Field.DOC_ID.value: chunk.metadata["doc_id"]}}
    }
    return self._client.update_by_query(index=indices, body=body)['updated']

注意:metadata.doc_id(关键字段) 一查多匹配 → 全部刷新内容与向量。这是路径 B 的"chunk 更新"语义,没有版本控制,并发更新会以最后写入为准;需要严格控制时应在调用方加锁或退化为先 delete_by_idsadd_chunks


六、检索链路

6.1 三种检索类型(应用层枚举)

# api/app/schemas/chunk_schema.py:8-13
class RetrieveType(StrEnum):
    PARTICIPLE = "participle"   # 关键词 / 分词检索BM25
    SEMANTIC   = "semantic"     # 语义 / 向量检索cosine
    HYBRID     = "hybrid"       # 混合检索:双路 + 去重 (+ rerank)
    Graph      = "graph"        # 在 hybrid 之上叠加 GraphRAG 检索

api/app/core/workflow/nodes/knowledge/node.py:213-298api/app/core/rag/nlp/search.py:220-281 两处可以看到完全一致的三分支 + 默认走 hybrid 的派发逻辑。

6.2 关键词检索(路径 BBM25 + IK

# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:468-558节选
def search_by_full_text(self, query: str, **kwargs) -> list[DocumentChunk]:
    top_k           = kwargs.get("top_k", 1024)
    score_threshold = float(kwargs.get("score_threshold") or 0.2)
    file_names_filter = kwargs.get("file_names_filter")

    query_str = {
        "bool": {
            "must": {
                "match": {
                    Field.CONTENT_KEY.value: {
                        "query": query,
                        "analyzer": "ik_max_word"        # 与建索引时一致
                    }
                }
            },
            "filter": {"term": {"metadata.status": 1}}   # 只看启用状态
        }
    }
    # 可选叠加 file_name 多选过滤
    if file_names_filter:
        query_str["bool"]["filter"] = [
            {"term":  {"metadata.status": 1}},
            {"terms": {"metadata.file_name": file_names_filter}}
        ]

    result = self._client.search(index=indices, from_=0, size=top_k, query=query_str)
    max_score = result["hits"]["max_score"] or 1.0
    docs_and_scores = []
    for res in result["hits"]["hits"]:
        normalized_score = res["_score"] / max_score      # 归一化到 [0,1]
        ...
    return [doc for doc, score in docs_and_scores if score > score_threshold]

要点:

  • BM25 默认相似度,ik_max_word 中文分词;写入与查询使用同一 analyzer避免分词错位。
  • score 归一化BM25 score 是开放区间,除以 max_score 缩放到 [0,1],便于与 score_threshold 比较,也便于和向量分数同尺度对齐。
  • 默认 score_threshold=0.2top_k=1024

6.3 关键词检索(路径 Aquery_string + 同义词扩展)

# api/app/core/rag/nlp/query.py:69-201节选
class FulltextQueryer:
    query_fields = [
        "title_tks^10", "title_sm_tks^5",
        "important_kwd^30", "important_tks^20",
        "question_tks^20",
        "content_ltks^2", "content_sm_ltks",
    ]

    def question(self, txt, tbl="qa", min_match: float = 0.6):
        txt = self.add_space_between_eng_zh(txt)        # 中英分词预处理
        txt = self.rmWWW(txt)                            # 去问句词(怎么/吗/啥/what/how/...
        ...
        # 中文分支term_weight 权重 + synonym 同义词扩展
        for tt in self.tw.split(txt)[:256]:
            twts = self.tw.weights([tt])
            syns = self.syn.lookup(tt)
            tk_syns = [f"({tk} OR (%s)^0.2)" % " ".join(tk_syns), ...]   # 同义词权重 0.2
            tms.append((tk, w))
        query = " OR ".join([f"({t})" for t in qs if t])
        return MatchTextExpr(self.query_fields, query, 100,
                             {"minimum_should_match": min_match}), keywords
# api/app/core/rag/utils/es_conn.py:196-217
for m in matchExprs:
    if isinstance(m, MatchTextExpr):
        minimum_should_match = m.extra_options.get("minimum_should_match", 0.0)
        if isinstance(minimum_should_match, float):
            minimum_should_match = str(int(minimum_should_match * 100)) + "%"
        bqry.must.append(Q("query_string", fields=m.fields,
                           type="best_fields", query=m.matching_text,
                           minimum_should_match=minimum_should_match,
                           boost=1))
        bqry.boost = 1.0 - vector_similarity_weight

亮点:

  • 多字段 field-boostimportant_kwd^30 表示标签字段权重远高于正文,符合"重要标签命中即高排名"的直觉。
  • 同义词加权 0.2:同义词召回但低权重,避免"同义词稀释"主体相关性。
  • minimum_should_match:默认 0.3 / 0.6,控制 BM25 召回的"严苛度"。当 hybrid 总命中为 0 时会 fallback 到 0.1 重试(详见 6.7)。
  • type="best_fields":多字段场景取每字段最高分作为最终分,符合"标题命中比正文命中更重要"的语义。

6.4 向量检索(路径 Bscript_score + cosine

# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:374-466节选
def search_by_vector(self, query: str, **kwargs) -> list[DocumentChunk]:
    if self.is_multimodal_embedding:
        query_vector = self.embeddings.embed_text(query)
    else:
        query_vector = self.embeddings.embed_query(query)
    top_k           = kwargs.get("top_k", 1024)
    score_threshold = float(kwargs.get("score_threshold") or 0.3)
    file_names_filter = kwargs.get("file_names_filter")

    query_str = {
        "bool": {
            "must": {
                "script_score": {
                    "query": {"match_all": {}},
                    "script": {
                        # cosineSimilarity 范围 [-1,1]+1 后落到 [0,2]
                        "source": f"cosineSimilarity(params.query_vector, '{Field.VECTOR.value}') + 1.0",
                        "params": {"query_vector": query_vector}
                    }
                }
            },
            "filter": {"term": {"metadata.status": 1}}
        }
    }

    result = self._client.search(index=indices, from_=0, size=top_k, query=query_str)
    docs_and_scores = []
    for res in result["hits"]["hits"]:
        score = res["_score"] / 2          # [0,2] -> [0,1]
        docs_and_scores.append((..., score))
    return [doc for doc, score in docs_and_scores if score > score_threshold]

特性与权衡:

  • script_score 是暴力扫描:会对 match_all 命中的所有文档(叠加 status=1 filter 后)逐一算 cosine复杂度 O(N·dim)。优点是结果精确、无 ANN 召回率损失;缺点是延迟随 KB chunk 数线性增长,不适合 chunk 量级大的 KB。
  • score 归一化(cos+1)/2 ∈ [0,1],与 BM25 归一化值同尺度。
  • 过滤集成metadata.status=1 在 filter 上,先过滤再算分;file_names_filter 同理。

6.5 向量检索(路径 Aknn + filter

# api/app/core/rag/utils/es_conn.py:206-217
elif isinstance(m, MatchDenseExpr):
    similarity = m.extra_options.get("similarity", 0.0)
    s = s.knn(
        m.vector_column_name,
        m.topn,
        m.topn * 2,                  # num_candidates = 2 * k控制召回率
        query_vector=list(m.embedding_data),
        filter=bqry.to_dict(),       # 与 BM25 同一份 bool filter
        # similarity=similarity      # 已注释:未启用阈值剪枝
    )
# api/app/core/rag/nlp/search.py:365-373
def get_vector(self, txt, emb_mdl, topk=10, similarity=0.1):
    qv, _ = emb_mdl.encode_queries(txt)
    embedding_data = [get_float(v) for v in qv]
    vector_column_name = f"q_{len(embedding_data)}_vec"   # 动态选维度
    return MatchDenseExpr(vector_column_name, embedding_data,
                          'float', 'cosine', topk, {"similarity": similarity})

要点:

  • HNSW ANN:路径 A 用的是 ES 8 原生 knn query底层 HNSW 索引,毫秒级,但有近似召回率损失。
  • k vs num_candidatestopn * 2 即 ANN 阶段先取 2k 候选再精排到 k是召回率与延迟的折中。生产建议至少 4 * topn,更高召回。
  • filter 共享filter=bqry.to_dict()——把 BM25 那份 bool filter 同时挂在 knn 上,确保过滤条件在 ANN 内部应用pre-filter这点对多租户 kb_id 隔离尤为关键,否则 ANN 先取 top-k 再过滤,可能完全不返回该 KB 的文档。
  • similarity 阈值已注释:当前不启用 ES 内置阈值剪枝;需要按相似度阈值过滤的话,由应用层 (Dealer.retrieval) 在 rerank 阶段做。

6.6 混合搜索 —— 这是本节最关键的"融合公式"

6.6.1 路径 AFusionExpr("weighted_sum") + ES 内部混合(核心融合点

# api/app/core/rag/nlp/search.py:435-445
matchDense = self.get_vector(qst, emb_mdl, topk, req.get("similarity", 0.1))
q_vec = matchDense.embedding_data
src.append(f"q_{len(q_vec)}_vec")

fusionExpr = FusionExpr("weighted_sum", topk, {"weights": "0.05,0.95"})
matchExprs = [matchText, matchDense, fusionExpr]

res = self.dataStore.search(src, highlightFields, filters, matchExprs, orderBy,
                            offset, limit, idx_names, kb_ids, rank_feature=rank_feature)
# api/app/core/rag/utils/es_conn.py:186-218
s = Search()
vector_similarity_weight = 0.5
for m in matchExprs:
    if isinstance(m, FusionExpr) and m.method == "weighted_sum" and "weights" in m.fusion_params:
        # 必须按 [text, dense, fusion] 顺序传入
        assert len(matchExprs) == 3 and isinstance(matchExprs[0], MatchTextExpr) \
            and isinstance(matchExprs[1], MatchDenseExpr) and isinstance(matchExprs[2], FusionExpr)
        weights = m.fusion_params["weights"]
        vector_similarity_weight = get_float(weights.split(",")[1])    # "0.05,0.95" -> 0.95

for m in matchExprs:
    if isinstance(m, MatchTextExpr):
        ...
        bqry.must.append(Q("query_string", ..., boost=1))
        bqry.boost = 1.0 - vector_similarity_weight                    # text 整体 boost = 0.05
    elif isinstance(m, MatchDenseExpr):
        s = s.knn(m.vector_column_name, m.topn, m.topn * 2,
                  query_vector=list(m.embedding_data), filter=bqry.to_dict())
if bqry:
    s = s.query(bqry)

融合公式(这是 [S2-T7] 评审要求"必须明确"的部分):

final_score(doc) = (1 - w_vec) * BM25_query_string_score(doc)
                 +     w_vec   * knn_cosine_score(doc)
                 + Σ rank_feature_score(doc)            ← PageRank + tag 加权(可选)

其中:

  • w_vec = 0.95(来自 FusionExpr"weights": "0.05,0.95" 第二个权重)。
  • BM25 整体 bqry.boost = 0.05,即 query_string 的 BM25 分数被乘 0.05knn 的分数没有显式 boost相当于权重 1.0,但语义上由调用方约定 0.95即代码层面是"BM25 直接乘 0.05knn 不缩放",并未严格归一化到等比例——这是一个已知近似,见 6.7 fallback
  • 排序逻辑ES 8 的 hybrid 行为是"bool query 命中集 knn top-k 候选集",并集后用各自分数相加(未命中那侧分数为 0elasticsearch-dsl Search.query(...).knn(...) 组合自动启用此模式。
  • rank_feature 通过 bqry.should.append(Q("rank_feature", field=fld, linear={}, boost=sc))es_conn.py:219-223加性方式融入最终分。

这种"应用层约定 + ES 端 boost 缩放"的混合不是教科书式的归一化加权但工程上简单BM25 与 cosine 在统计上不同尺度0.05/0.95 的极端偏向语义是为了"以语义检索为主、关键词作为补强"。

6.6.2 路径 B双路 + 去重 + 可选 Rerank

# api/app/core/workflow/nodes/knowledge/node.py:236-271
case retrieve_type if retrieve_type in (RetrieveType.HYBRID, RetrieveType.Graph):
    rs1_task = asyncio.to_thread(vector_service.search_by_vector, **{
        "query": query, "top_k": kb_config.top_k,
        "indices": indices, "score_threshold": kb_config.vector_similarity_weight
    })
    rs2_task = asyncio.to_thread(vector_service.search_by_full_text, **{
        "query": query, "top_k": kb_config.top_k,
        "indices": indices, "score_threshold": kb_config.similarity_threshold
    })
    rs1, rs2 = await asyncio.gather(rs1_task, rs2_task)        # 双路并发

    unique_rs = self._deduplicate_docs(rs1, rs2)               # 按 doc_id 去重
    if not unique_rs: return []
    if self.typed_config.reranker_id:
        rs.extend(await asyncio.to_thread(
            self.rerank, **{"query": query, "docs": unique_rs, "top_k": kb_config.top_k}))
    else:
        rs.extend(sorted(unique_rs,
                         key=lambda d: d.metadata.get("score", 0),
                         reverse=True)[:kb_config.top_k])
# api/app/core/rag/nlp/search.py:236-261同等逻辑的同步版
case _:
    rs1 = vector_service.search_by_vector(...)
    rs2 = vector_service.search_by_full_text(...)
    seen_ids = set(); unique_rs = []
    for doc in rs1 + rs2:
        if doc.metadata["doc_id"] not in seen_ids:
            seen_ids.add(doc.metadata["doc_id"])
            unique_rs.append(doc)
    rs = unique_rs
    if unique_rs:
        rs = vector_service.rerank(query=..., docs=unique_rs, top_k=...)

融合公式(路径 B

candidates = vector_topk(q, w_v)  bm25_topk(q, w_t)         # 双路并发召回
deduped    = unique_by(metadata.doc_id, candidates)           # 后到的丢弃
if reranker:
    final = reranker(query, deduped)[:top_k]                  # 跨编码器重排
else:
    final = sort_by_score_desc(deduped)[:top_k]               # 各自归一化分数直接比

why 不在路径 B 做加权融合:路径 B 双路分数已分别归一化到 [0,1],但"BM25 归一化分"与"cosine 归一化分"之间不可比(一个是相对最大分,一个是绝对几何相似度)。直接把它们排序虽然不严谨,但通常依赖下游的 cross-encoder reranker 做最终排序,因此前置阶段以"召回多样性"为优先vector 主召回 + BM25 补关键词),不再做权重融合。

6.7 兜底:低召回 fallback

# api/app/core/rag/nlp/search.py:447-459
# If result is empty, try again with lower min_match
if total == 0:
    if filters.get("document_id"):
        # 限定文档场景下,直接退化为"无关键词"召回
        res = self.dataStore.search(src, [], filters, [], orderBy, offset, limit, idx_names, kb_ids)
        total = self.dataStore.getTotal(res)
    else:
        matchText, _ = self.qryr.question(qst, min_match=0.1)    # 0.3 -> 0.1
        matchDense.extra_options["similarity"] = 0.17            # 0.1 -> 0.17(提高语义阈值)
        res = self.dataStore.search(src, highlightFields, filters,
                                    [matchText, matchDense, fusionExpr],
                                    orderBy, offset, limit, idx_names, kb_ids,
                                    rank_feature=rank_feature)

设计意图第一轮严格匹配min_match=0.3)保证精度;命中为 0 时放宽 BM25 但提高向量阈值,等价于"换主导侧",避免空结果。

6.8 Rerank模型重排 + 应用层混合相似度

Dealer.rerank_by_modelDealer.rerank 是两套 reranker

# api/app/core/rag/nlp/search.py:606-666
def rerank(self, sres, query, tkweight=0.3, vtweight=0.7, ...):
    sim, tksim, vtsim = self.qryr.hybrid_similarity(
        sres.query_vector, ins_embd, keywords, ins_tw, tkweight, vtweight)
    return sim + rank_fea, tksim, vtsim

def rerank_by_model(self, rerank_mdl, sres, query, tkweight=0.3, vtweight=0.7, ...):
    tksim = self.qryr.token_similarity(keywords, ins_tw)
    vtsim, _ = rerank_mdl.similarity(query, [...])
    return tkweight * (np.array(tksim) + rank_fea) + vtweight * vtsim, tksim, vtsim
# api/app/core/rag/nlp/query.py:203-211
def hybrid_similarity(self, avec, bvecs, atks, btkss, tkweight=0.3, vtweight=0.7):
    sims = CosineSimilarity([avec], bvecs)
    tksim = self.token_similarity(atks, btkss)
    if np.sum(sims[0]) == 0:
        return np.array(tksim), tksim, sims[0]
    return np.array(sims[0]) * vtweight + np.array(tksim) * tkweight, tksim, sims[0]

应用层重排公式:

final_score = vtweight * cosine(q_vec, c_vec) + tkweight * token_sim(q, c) + rank_feature_score
            ≈ 0.7      * vector_sim          + 0.3       * keyword_sim    + (PageRank + tag)

注意 Dealer.retrieval()674-768 行)调用时传入的是 1 - vector_similarity_weight, vector_similarity_weight,所以这两个权重由调用方(用户配置)决定,默认 0.3 / 0.7vector_similarity_weight=0.3 见 678 行)。

6.9 top_k / 召回率 / 延迟权衡

阶段 默认值 含义 调参建议
top_k (KB 节点) 工作流配置 单 KB 单路召回数 hybrid 模式建议 ≥ 50语义高质 KB 可 30
topn / topk (Dealer) 1024 (ann fallback)10 (默认) knn 阶段 k 与下游 RERANK_LIMIT 联动
num_candidates topn * 2 (es_conn.py:213) HNSW 候选数,影响召回率 高召回场景改为 4 * topn
RERANK_LIMIT ceil(64/page_size)*page_size (search.py:683) rerank 输入数 与显示页大小绑定,避免 rerank 过多
score_threshold (BM25) 0.2 归一化后阈值 关键词强场景可调到 0.3
score_threshold (vector) 0.3 (cos+1)/2 后阈值 严苛去噪可到 0.5
min_match 0.3fallback 0.1 BM25 词命中比 短查询调高,长查询调低
request_timeout 30s ES 客户端超时 高并发下 60s
search timeout "600s" (es_conn.py:257) ES 服务端超时 超长 KB 才放宽

七、配置项与运维要点

7.1 环境变量(连接 + 客户端调优)

# api/app/core/rag/utils/es_conn.py:60-80
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:685-710
ELASTICSEARCH_HOST                  # 默认 127.0.0.1,可填 http://es-1 / https://es-1
ELASTICSEARCH_PORT                  # 默认 9200
ELASTICSEARCH_USERNAME              # 默认 elastic
ELASTICSEARCH_PASSWORD              # 默认 elastic
ELASTICSEARCH_REQUEST_TIMEOUT       # 默认 30 (秒)
ELASTICSEARCH_RETRY_ON_TIMEOUT      # 默认 True (es_conn 中是字符串比较,注意 bug 见下)
ELASTICSEARCH_MAX_RETRIES           # 默认 3
ELASTICSEARCH_VERIFY_CERTS          # 默认 false
ELASTICSEARCH_CA_CERTS              # 自签证书路径
ELASTICSEARCH_CONNECTIONS_PER_NODE  # 路径 B 独有,默认 10

小坑es_conn.py:72 写的是 os.getenv("ELASTICSEARCH_RETRY_ON_TIMEOUT", True) == "true"——默认值是 bool True,但与字符串 "true" 比较恒为 False。所以默认情况下其实没开启 retry_on_timeout,需要显式设置 ELASTICSEARCH_RETRY_ON_TIMEOUT=true(小写)才生效。

7.2 ES 集群规模建议

mapping.json 默认 2 shards、0 replicas不可直接用于生产。建议:

数据量 节点数 shards replicas heap 备注
< 100w chunks 1-3 2 1 8GB 默认配置 + 1 副本
100w-1000w 3-5 4-8 1 16GB 增加 shard 减少单 shard 体积
> 1000w 5+ 8-16 1-2 31GB(不超过 32) shard 大小控制在 30-50GB

核心准则

  • 单 shard 不超过 50GB
  • replicas ≥ 1至少容忍 1 节点宕机;
  • JVM heap 不超过 32GBzero-based compressed oops
  • 留 50% RAM 给 OS file cachelucene 依赖 mmap

7.3 索引膨胀治理

观察点:

  • 路径 A 的 graphrag_{workspace_id} 索引:随 workspace chunk 数增长,number_of_shards=2 容易超过 50GB/shard。需要按"workspace 容量分层",对热门/大 workspace 单独 reindex 到更多 shards。
  • 路径 B 的 Vector_index_{kb_id}_Node 索引:每 KB 一个 indexKB 数 1000+ 时 cluster state 显著膨胀,可能拖慢所有索引创建/查询。建议引入"KB 共享索引 + kb_id 路由"模式(详见 §8 优化建议)。
# api/app/core/rag/utils/es_conn.py:587-633
def get_cluster_stats(self):
    """
    暴露 store_size / docs / nodes_version / jvm_heap_used 等用于 dashboard
    """
    raw_stats = self.es.cluster.stats()
    return {...}

建议:在调度器里定时拉取 get_cluster_stats(),把 store_size / docs / heap_used_percent 接入告警。

7.4 慢查询排查

# api/app/core/rag/utils/es_conn.py:250-263
logger.debug(f"ESConnection.search {str(indexNames)} query: " + json.dumps(q))
res = self.es.search(index=indexNames, body=q, timeout="600s",
                     # search_type="dfs_query_then_fetch",
                     track_total_hits=True, _source=True)

排查路径:

  1. 打开 debug 日志logger=rag.es_conn 调到 DEBUG可以看到完整 DSL。
  2. 关闭 track_total_hits=True:超过 10000 hits 时它会真正扫表,对大 KB 是常见慢点;如果不需要精确总数,改为 track_total_hits=10000
  3. 打开 dfs_query_then_fetch:在多 shard 时让 IDF 全局计算,对相关性更准;代价是一次 RTT。
  4. 限制 num_candidatesHNSW 阶段候选数大幅影响延迟;已是 topn * 2,进一步压缩到 topn 可观察延迟下降。
  5. slow log:在 ES 集群层面打开 index.search.slowlog.threshold.query.warn: 1s,定位单查询慢点。

7.5 健康监控接口

# api/app/core/rag/utils/es_conn.py:95-98
def health(self) -> dict:
    health_dict = dict(self.es.cluster.health())
    health_dict["type"] = "elasticsearch"
    return health_dict
# api/app/core/rag/utils/doc_store_conn.py:140-145
@abstractmethod
def health(self) -> dict:
    """Return the health status of the database."""

接入业务监控的最简方法:起一个轻量 endpoint 调用 docStoreConn.health(),把 status (green/yellow/red)number_of_nodesactive_shards_percent_as_number 上报。


八、边界条件与已知限制

限制 影响 解决方向
路径 B dims = len(embeddings[0]) 锁定维度 换 embedding 模型必须重建索引 按维度后缀命名向量字段(参考路径 A 的 q_{dim}_vec
路径 A 默认 0 副本 节点宕机即数据丢失 修改 res/mapping.json number_of_replicas: 1
ELASTICSEARCH_RETRY_ON_TIMEOUT 默认未生效 网络抖动直接抛错 bugbool 与 "true" 字符串比较;需显式 =true
script_score 暴力扫描 大 KB 延迟高 路径 B 升级到 knn queryES 8 原生)
路径 B inline mapping 不带 metadata.kb_id 多 KB 共享索引时无法过滤 与路径 A 对齐,引入 kb_id keyword
update_by_segment 无并发控制 并发更新最后写入胜出 delete_by_ids + add_chunks 或显式版本号
add_texts 不捕获 BulkIndexError 局部失败整批失败 增加 try/except + 失败重投队列
一个 workspace 多 KB 共享路径 A 索引 单 KB 删除走 delete-by-query不立即释放磁盘 定期 _forcemerge?only_expunge_deletes=true
路径 B 每 KB 一索引 大量 KB 时 cluster state 膨胀 改为共享索引 + kb_id routing
track_total_hits=True 大库 search 全表扫描慢 默认改为 10000按需取 max

九、监控指标与排错指引

9.1 关键指标

指标 来源 告警阈值(参考)
ES cluster status health() red 立即告警
active_shards_percent_as_number health() < 100% 持续 5min 告警
jvm_heap_used_percent get_cluster_stats() > 75% 警告,> 85% 紧急
os_mem_used_percent get_cluster_stats() > 90% 警告
写入失败比例 ESConnection.insert 返回的 res 列表长度 / 总 chunk 数 > 1% 告警
单次 search P95 延迟 调用方时序日志 hybrid > 1s 告警
track_total_hits 命中超过 10k 比例 search.py 总数 频繁触发即扩 shard

9.2 典型故障与处理

现象 可能原因 处置
写入超时 bulk 太大 / refresh 阻塞 减小 batch≤ 1000/ 写入窗口 refresh_interval=30s
检索召回为 0 min_match 过严 / kb_id 过滤不一致 看 search.py:447 fallback 是否触发;核对 kb_id
HNSW 召回率低 num_candidates 过小 增大到 4 * topntopn * 4
维度不匹配报错 换 embedding 模型未 reindex 按 §8 维度限制处理;或在路径 B 删 KB 重建
cluster state 过大 KB 索引数过多 §10 改造为共享索引 + kb_id routing
中文检索召回差 写入 analyzer 与查询 analyzer 不一致 路径 B 必须保持 ik_max_word(写入与查询)

十、优化建议与未来扩展点

10.1 架构改造短期1-2 个迭代)

  1. 统一双路径:保留路径 A 抽象 (DocStoreConnection + Dealer),把路径 B 的 ElasticSearchVector 重构为 DocStoreConnection 的薄封装,删除重复的连接管理 (ElasticSearchVectorFactory),全局只用 @singleton ESConnection
  2. 修复默认配置
    • mapping.json number_of_replicas: 0 → 1
    • 修正 ELASTICSEARCH_RETRY_ON_TIMEOUT bool/str 比较;
    • 路径 B 的 script_score 切换为 knn query
    • 路径 B mapping 加上 kb_id keyword 字段,为后续合并索引铺路。
  3. 共享索引 + 路由:把 Vector_index_{kb_id}_Node 改为 kb_chunks_{workspace_id} 共享索引,kb_id 字段做 routing key索引数从 N(KB) 降到 N(workspace)。

10.2 检索增强(中期)

  1. 真正的 RRFreciprocal rank fusion当前 weighted_sum 对分数尺度敏感,引入 rank_fusion (ES 8.8+) 或在应用层实现 rrf_score(d) = Σ 1/(k + rank_i(d)),对尺度不敏感。
  2. 稀疏向量ELSER / SPLADE:路径 A 已在 MatchSparseExpr 接口预留位置,但 ES 实现未启用 rank_features 稀疏检索,引入后可在中文长尾查询上显著提升召回。
  3. 多模态检索:路径 B 已感知 is_multimodal_embeddingelasticsearch_vector.py:41),但只针对火山引擎;引入跨模态 BGE-M3 类模型后,可在同一 dense_vector 字段上做"图文混排"。
  4. HNSW 参数显式化mapping.json 没有指定 index_optionsm / ef_construction。在构建大索引时显式 m=16, ef_construction=200 可显著提升召回率。

10.3 工程鲁棒性(中期)

  1. 写入幂等保护:路径 B add_texts 不传 _id,依赖 metadata.doc_id 后查;改为直接用 doc_id 作为 _id,写入即可幂等,省去后查。
  2. 变更检测 reindex:当 mapping 变化时,加一个 migration_version 字段触发 alias-swap reindexold_index → new_index),避免线上停机重建。
  3. 批量限流helpers.bulk 默认无背压,引入 chunk_size=500, max_chunk_bytes=10MB 显式限制,避免大 chunk 撑爆 ES heap。
  4. 路径 A 的 ATTEMPT_TIME=2 太少:网络抖动 2 次重试后丢错,建议升到 3-5 次,配合指数退避。

10.4 长期扩展点

  1. 冷热分离:超过半年/低访问的 chunk 迁到冷节点warm tier+ rollover index配合"记忆遗忘引擎" (Memory Forgetting Engine, README §4) 协同。
  2. 跨集群联邦:当多 workspace 数据量过大,引入 cross-cluster searchCCS按 workspace 切集群。
  3. GraphRAG 与 VDB 联合检索:当前 kg_retriever.retrieval 在路径 B 是后置 insertnode.py:286-298可改为"先图谱召回相关实体 → 把实体名作为 important_kwd^30 注入 BM25"实现一次 ES 调用同时享受图谱与向量。

十一、关键源码片段索引(评审检查点)

主题 文件:行号 一句话说明
抽象接口 api/app/core/rag/utils/doc_store_conn.py:128-256 DocStoreConnection 14 个抽象方法
MatchExpr 族 api/app/core/rag/utils/doc_store_conn.py:43-114 文本/稠密/稀疏/张量/融合表达式
ES 连接管理 api/app/core/rag/utils/es_conn.py:26-86 @singleton + 8.x 版本校验
全局 mapping api/app/core/rag/res/mapping.json:1-211 dynamic_templates + 自定义 BM25
ES 8 hybrid 核心 api/app/core/rag/utils/es_conn.py:186-218 query_string + s.knn(...) 共享 filter
加权融合 api/app/core/rag/utils/es_conn.py:188-194api/app/core/rag/nlp/search.py:439 FusionExpr("weighted_sum", weights="0.05,0.95")
应用层 hybrid_similarity api/app/core/rag/nlp/query.py:203-211 0.7*cos + 0.3*token_sim
双路 + 去重 + rerank api/app/core/workflow/nodes/knowledge/node.py:236-271 工作流默认混合策略
BaseVector 抽象 api/app/core/rag/vdb/vector_base.py:9-67 路径 B 的接口骨架
KB 索引 mapping api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:609-663 inline 创建 + dims 锁定
关键词检索BM25+IK api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:468-558 match + ik_max_word + 归一化
向量检索cosine 暴力) api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:374-466 script_score + filter
关键词构造(多字段 + 同义词) api/app/core/rag/nlp/query.py:14-22, 69-201 query_fields field-boost + synonym
Dealer.retrieval (主入口) api/app/core/rag/nlp/search.py:674-768 检索 + rerank + 分页
低召回 fallback api/app/core/rag/nlp/search.py:447-459 min_match 0.3→0.1similarity 0.1→0.17
update_by_query api/app/core/rag/utils/es_conn.py:332-422 painless + slices=5 + conflicts=proceed
bulk 写 + 错误处理 api/app/core/rag/utils/es_conn.py:294-330 refresh=False + 两次重试 + 错误聚合
工厂单例 (路径 B) api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:666-732 双重检查锁 + 版本校验一次性
全局初始化 api/app/core/rag/common/settings.py:13-24 docStoreConn / retriever / kg_retriever
检索类型枚举 api/app/schemas/chunk_schema.py:8-13 participle / semantic / hybrid / graph

十二、TL;DR一段话总结

MemoryBear 用 Elasticsearch 8 同时承担全文Lucene + IK + 自定义 BM25和向量dense_vector + HNSW双引擎,所以选 ES 而不是专用向量库。代码里有两套并行路径:路径 A ESConnection(单例 DocStoreConnection,多字段动态模板,配 Dealerweighted_sum=0.05,0.95 的应用层加权 + ES 原生 hybrid 与 rank_features主要给 GraphRAG/复杂 RAG 用);路径 B ElasticSearchVectorBaseVector 简化封装,script_score+cosinematch+ik_max_word,主要给工作流知识节点和 KB 服务用hybrid 走"双路并发 → metadata.doc_id 去重 → 可选 reranker")。索引按 workspace 或按 KB 隔离,mapping.json 默认 2 shards / 0 副本 / 1s refresh向量字段按维度后缀512/768/1024/1536动态创建文本字段以 _tks/_ltks/_kwd 后缀套用 dynamic_templates。生产化的主要风险点路径 B 锁死 dims、默认 0 副本、ELASTICSEARCH_RETRY_ON_TIMEOUT 比较 bug、script_score 暴力扫描、KB 索引数膨胀;优化方向是合并双路径、改用 knn + RRF、共享索引 + kb_id routing、配合 GraphRAG 做联合检索。