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>
53 KiB
[S2-T3] 向量数据库选型、索引与检索策略实现详解
范围:
api/app/core/rag/vdb/elasticsearch/、api/app/core/rag/utils/es_conn.py、api/app/core/rag/utils/doc_store_conn.py、api/app/core/rag/nlp/{search.py, query.py}、api/app/core/rag/res/mapping.json以及调用方api/app/core/workflow/nodes/knowledge/node.py、api/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)。结合源码可以推出三条关键决策依据:
- 关键词侧需要 Lucene 生态 — 既要中文分词(IK
ik_max_word),又要 BM25 / 布尔过滤 / 高亮 / 同义词扩展 / 短语匹配 / 字段权重等成熟能力,Milvus / Qdrant / Pinecone 在这一侧几乎都需要外接 ES/OS。api/app/core/rag/nlp/query.py:14-22的query_fields = ["title_tks^10", "important_kwd^30", "content_ltks^2", ...]就是典型 Lucene field-boost 写法,离开 ES 改造代价很高。 - 一份索引同时承担多种载荷 — 一个 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。 - 运维与生态成本 — 团队仅运行 PostgreSQL / Neo4j / Redis / ES(README "Prerequisites"),引入第二套向量服务会显著抬高运维曲线。
@singleton的ESConnection(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}")
why:ES 8.0 才正式提供
dense_vectorHNSW 索引、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 路径 A:ESConnection(DSL 抽象层,主要服务于 GraphRAG 与高级检索)
- 抽象基类:
api/app/core/rag/utils/doc_store_conn.py:128-256定义DocStoreConnection接口(dbType / createIdx / search / insert / update / delete / sql 等)。 - 表达式族:同文件 43-126 行定义
MatchTextExpr、MatchDenseExpr、MatchSparseExpr、MatchTensorExpr、FusionExpr、OrderByExpr—— 这是上层与底层解耦的"查询 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: Dealer(350-907 行),由kg_retriever、retriever全局共用。
3.2 路径 B:ElasticSearchVector(应用层 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.py:page_content/metadata/vector/metadata.doc_id等。 - ES 实现:
class ElasticSearchVector(BaseVector)+class ElasticSearchVectorFactory(api/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_retrieval(36-147 行)API/服务层入口。api/app/services/memory_konwledges_server.py、api/app/controllers/{chunk,document,knowledge}_controller.py等。
3.3 两条路径的边界
| 维度 | 路径 A(ESConnection / Dealer) | 路径 B(ElasticSearchVector) |
|---|---|---|
| 索引名 | 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) |
固定 vector(dim 取首批 embeddings 长度) |
| 关键词检索 | Lucene query_string(field-boost、同义词、短语) |
match + analyzer=ik_max_word(BM25) |
| 向量检索 | s.knn(...)(HNSW,ES 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_feas、pagerank_fea、question_tks等),是面向"知识图谱 + 复杂 RAG"的;路径 B 简单直接,是工作流/服务层的"够用就好"封装。代码上是渐进演化中的双轨,但目前两条路径都在生产使用。
四、索引设计
4.1 全局 mapping(路径 A,api/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 mapping(KB 索引)
# 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 一个 index,KB 多了 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;这里不传_id,ES 自动生成。 - 唯一性:路径 B 把 chunk 唯一标识放在
metadata.doc_id(vector_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-174)。add_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"字典,新值无法覆盖旧 key(341-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_ids 再 add_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-298 与 api/app/core/rag/nlp/search.py:220-281 两处可以看到完全一致的三分支 + 默认走 hybrid 的派发逻辑。
6.2 关键词检索(路径 B:BM25 + 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.2、top_k=1024。
6.3 关键词检索(路径 A:query_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-boost:
important_kwd^30表示标签字段权重远高于正文,符合"重要标签命中即高排名"的直觉。 - 同义词加权 0.2:同义词召回但低权重,避免"同义词稀释"主体相关性。
- minimum_should_match:默认 0.3 / 0.6,控制 BM25 召回的"严苛度"。当 hybrid 总命中为 0 时会 fallback 到 0.1 重试(详见 6.7)。
type="best_fields":多字段场景取每字段最高分作为最终分,符合"标题命中比正文命中更重要"的语义。
6.4 向量检索(路径 B:script_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 向量检索(路径 A:knn + 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 原生
knnquery,底层 HNSW 索引,毫秒级,但有近似召回率损失。 - k vs num_candidates:
topn * 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 路径 A:FusionExpr("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.05;knn 的分数没有显式 boost,相当于权重 1.0,但语义上由调用方约定 0.95(即代码层面是"BM25 直接乘 0.05,knn 不缩放",并未严格归一化到等比例——这是一个已知近似,见 6.7 fallback)。 - 排序逻辑:ES 8 的 hybrid 行为是"bool query 命中集 ∪ knn top-k 候选集",并集后用各自分数相加(未命中那侧分数为 0)。
elasticsearch-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_model 与 Dealer.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.7(vector_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.3,fallback 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"——默认值是 boolTrue,但与字符串"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 不超过 32GB(zero-based compressed oops);
- 留 50% RAM 给 OS file cache(lucene 依赖 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 一个 index,KB 数 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)
排查路径:
- 打开 debug 日志:
logger=rag.es_conn调到 DEBUG,可以看到完整 DSL。 - 关闭
track_total_hits=True:超过 10000 hits 时它会真正扫表,对大 KB 是常见慢点;如果不需要精确总数,改为track_total_hits=10000。 - 打开
dfs_query_then_fetch:在多 shard 时让 IDF 全局计算,对相关性更准;代价是一次 RTT。 - 限制
num_candidates:HNSW 阶段候选数大幅影响延迟;已是topn * 2,进一步压缩到topn可观察延迟下降。 - 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_nodes、active_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 默认未生效 |
网络抖动直接抛错 | bug:bool 与 "true" 字符串比较;需显式 =true |
script_score 暴力扫描 |
大 KB 延迟高 | 路径 B 升级到 knn query(ES 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 * topn 或 topn * 4 |
| 维度不匹配报错 | 换 embedding 模型未 reindex | 按 §8 维度限制处理;或在路径 B 删 KB 重建 |
| cluster state 过大 | KB 索引数过多 | §10 改造为共享索引 + kb_id routing |
| 中文检索召回差 | 写入 analyzer 与查询 analyzer 不一致 | 路径 B 必须保持 ik_max_word(写入与查询) |
十、优化建议与未来扩展点
10.1 架构改造(短期,1-2 个迭代)
- 统一双路径:保留路径 A 抽象 (
DocStoreConnection+Dealer),把路径 B 的ElasticSearchVector重构为DocStoreConnection的薄封装,删除重复的连接管理 (ElasticSearchVectorFactory),全局只用@singleton ESConnection。 - 修复默认配置:
mapping.jsonnumber_of_replicas: 0 → 1;- 修正
ELASTICSEARCH_RETRY_ON_TIMEOUTbool/str 比较; - 路径 B 的
script_score切换为knnquery; - 路径 B mapping 加上
kb_idkeyword 字段,为后续合并索引铺路。
- 共享索引 + 路由:把
Vector_index_{kb_id}_Node改为kb_chunks_{workspace_id}共享索引,kb_id字段做 routing key,索引数从 N(KB) 降到 N(workspace)。
10.2 检索增强(中期)
- 真正的 RRF(reciprocal rank fusion):当前
weighted_sum对分数尺度敏感,引入rank_fusion(ES 8.8+) 或在应用层实现rrf_score(d) = Σ 1/(k + rank_i(d)),对尺度不敏感。 - 稀疏向量(ELSER / SPLADE):路径 A 已在
MatchSparseExpr接口预留位置,但 ES 实现未启用rank_features稀疏检索,引入后可在中文长尾查询上显著提升召回。 - 多模态检索:路径 B 已感知
is_multimodal_embedding(elasticsearch_vector.py:41),但只针对火山引擎;引入跨模态 BGE-M3 类模型后,可在同一 dense_vector 字段上做"图文混排"。 - HNSW 参数显式化:
mapping.json没有指定index_options(m / ef_construction)。在构建大索引时显式m=16, ef_construction=200可显著提升召回率。
10.3 工程鲁棒性(中期)
- 写入幂等保护:路径 B
add_texts不传_id,依赖metadata.doc_id后查;改为直接用doc_id作为_id,写入即可幂等,省去后查。 - 变更检测 reindex:当 mapping 变化时,加一个
migration_version字段触发 alias-swap reindex(old_index → new_index),避免线上停机重建。 - 批量限流:
helpers.bulk默认无背压,引入chunk_size=500, max_chunk_bytes=10MB显式限制,避免大 chunk 撑爆 ES heap。 - 路径 A 的
ATTEMPT_TIME=2太少:网络抖动 2 次重试后丢错,建议升到 3-5 次,配合指数退避。
10.4 长期扩展点
- 冷热分离:超过半年/低访问的 chunk 迁到冷节点(warm tier)+ rollover index,配合"记忆遗忘引擎" (Memory Forgetting Engine, README §4) 协同。
- 跨集群联邦:当多 workspace 数据量过大,引入 cross-cluster search(CCS)按 workspace 切集群。
- GraphRAG 与 VDB 联合检索:当前
kg_retriever.retrieval在路径 B 是后置 insert(node.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-194 与 api/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.1,similarity 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,多字段动态模板,配 Dealer 做 weighted_sum=0.05,0.95 的应用层加权 + ES 原生 hybrid 与 rank_features,主要给 GraphRAG/复杂 RAG 用);路径 B ElasticSearchVector(BaseVector 简化封装,script_score+cosine 与 match+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 做联合检索。