Elasticsearch做RAG向量库实践

Elasticsearch做RAG向量库:别再被专用向量数据库忽悠了

你们团队已经在用ES了。半夜报警、扩容、监控、Kibana看板,啥都有。现在要做RAG对话系统,老板问「要不要上个Pinecone/Weaviate/Qdrant?」

------答案很明确:不用

Elasticsearch 8.x的dense_vector + kNN能力已经足够硬核。本文直接把关键代码和坑给你趟了。


一、为什么是ES

核心理由就四个:

  • 混合检索原生支持。 BM25关键词 + kNN语义向量 + RRF融合,一行代码不做外部编排,单次查询搞定。
  • 运维体系现成的。 不用再给新组件搭监控、配权限、搞扩容。
  • 元数据过滤不是补丁。 在HNSW图遍历阶段就把filter套上了,不是先搜再筛。
  • HNSW算法。 近似近邻搜索的事实标准,上百ms内搞定大规模检索。

一句话总结:ES不是「也能做向量检索」,它是唯一一个把生产级混合RAG的门槛拉到这么低的搜索引擎。


二、建索引------一切的起点

索引mapping搞错,后面都得删了重来。一把梭对:

json 复制代码
PUT /rag_docs
{
  "mappings": {
    "properties": {
      "doc_id":    { "type": "keyword" },
      "title":     { "type": "text", "analyzer": "english" },
      "content":   { "type": "text", "analyzer": "english" },
      "category":  { "type": "keyword" },
      "created_at": { "type": "date" },
      "embedding": {
        "type": "dense_vector",
        "dims": 1536,
        "index": true,
        "similarity": "cosine",
        "index_options": {
          "type": "int8_hnsw",
          "m": 16,
          "ef_construction": 200
        }
      }
    }
  }
}

关键决策说明:

  • similarity: cosine------OpenAI的embedding已经归一化,cosine和dot_product排名一样。选cosine更稳:哪天换模型或上未归一化向量,不用重建索引。
  • int8_hnsw------量化索引,内存省掉约75%,召回损失不到2%。数据量大就是白捡的性能。
  • ef_construction: 200------默认100,拉到200索引慢一点,但召回率有肉眼可见的提升。索引是一锤子买卖,值得。

三、分块策略------比你想象的更重要

太多人花一周时间换embedding模型调参,结果发现分块边界才是祸根。句子被拦腰截断的chunk,语义直接碎一地。

python 复制代码
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", ". ", " "]
)
chunks = splitter.split_text(document_text)

经验公式:512 token一块 + 50 token重叠,句边界分割。chunk太大检索噪音多,太小丢上下文。512是甜点区。


四、向量嵌入和批量入库

用OpenAI的text-embedding-3-small,1536维,性价比最高。批量入库的关键是关掉refresh_interval,否则每条doc都触发一次段刷新,慢到怀疑人生。

python 复制代码
import openai
from elasticsearch import Elasticsearch, helpers

es = Elasticsearch("https://localhost:9200", api_key="YOUR_API_KEY")
client = openai.OpenAI(api_key="OPENAI_KEY")

def embed_batch(texts: list[str]) -> list[list[float]]:
    resp = client.embeddings.create(model="text-embedding-3-small", input=texts)
    return [d.embedding for d in resp.data]

def bulk_index(chunks: list[dict]):
    # 入库前关refresh
    es.indices.put_settings(index="rag_docs", body={"refresh_interval": "-1"})

    actions = []
    for i in range(0, len(chunks), 100):
        batch = chunks[i:i+100]
        texts = [c["content"] for c in batch]
        embs = embed_batch(texts)
        for chunk, emb in zip(batch, embs):
            actions.append({
                "_index": "rag_docs",
                "_source": {
                    "doc_id": chunk["doc_id"],
                    "title": chunk["title"],
                    "content": chunk["content"],
                    "category": chunk["category"],
                    "embedding": emb
                }
            })
        helpers.bulk(es, actions)
        actions.clear()

    # 入库完恢复并强制刷新
    es.indices.put_settings(index="rag_docs", body={"refresh_interval": "1s"})
    es.indices.refresh(index="rag_docs")

实测:50万条chunk,开refresh要47分钟,关了只要9分钟。


五、混合检索------这才是ES的真杀招

纯向量搜语义牛但丢关键词,纯BM25匹配关键词但不懂意思。两者融合,RRF做排序融合,不需要操心两种分数的量纲对齐。

python 复制代码
def hybrid_search(query: str, category: str = None, top_k: int = 5):
    query_vec = embed_batch([query])[0]

    filter_clause = []
    if category:
        filter_clause = [{"term": {"category": category}}]

    body = {
        "sub_searches": [
            {
                "query": {
                    "knn": {
                        "field": "embedding",
                        "query_vector": query_vec,
                        "num_candidates": 100,
                        "filter": filter_clause
                    }
                }
            },
            {
                "query": {
                    "bool": {
                        "must": [{"match": {"content": query}}],
                        "filter": filter_clause
                    }
                }
            }
        ],
        "rank": {
            "rrf": {"window_size": 50, "rank_constant": 20}
        },
        "size": top_k,
        "_source": ["doc_id", "title", "content", "category"]
    }

    resp = es.search(index="rag_docs", body=body)
    return [hit["_source"] for hit in resp["hits"]["hits"]]

RRF怎么算的: 每个文档在每个检索分支拿到的排名 r,贡献 1/(r + rank_constant)。两路分数求和,简单暴力但有效。

关键认知: num_candidatestop_k。它是HNSW图里搜索的候选数,ratio 10:1 到 20:1 是甜点区(k=5,candidates=100)。


六、生成回答

检索出top-k段落,塞进LLM的prompt:

python 复制代码
def generate_answer(query: str, contexts: list[dict]) -> str:
    ctx = "\n\n".join([f"[来源: {c['title']}]\n{c['content']}" for c in contexts])

    system = "你是一个问答助手。仅根据提供的上下文回答。如果上下文没有答案,就说'信息不足'。必须标注来源。"
    user = f"上下文:\n{ctx}\n\n问题: {query}\n\n答案(带引用):"

    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
        temperature=0.1,
        max_tokens=800
    )
    return resp.choices[0].message.content

Temperature设0.1,不要0。 Temperature=0模型输出太生硬机械,0.1给一点自然的措辞弹性。


七、完整对话流程

一把梭,两行就完事:

python 复制代码
def rag_chat(query: str) -> str:
    contexts = hybrid_search(query, top_k=5)
    answer = generate_answer(query, contexts)
    return answer

不依赖任何RAG框架。就是ES Python Client + OpenAI SDK。依赖越少,线上出问题能排查的地方就越少。


八、性能基准

3节点ES集群(8 vCPU / 32GB 内存),5万chunk法律文档语料:

检索模式 P95延迟 Precision@5 Recall@10
纯BM25 <10ms 72% 68%
纯kNN向量 45-80ms 81% 78%
混合RRF 50-90ms 89% 87%
混合+Reranker 120-160ms 93% 91%

混合检索比纯向量Precision@5高了8个百分点,比纯BM25高了17个百分点。上cross-encoder reranker直接飙到93%。


九、踩过的坑

1. 分块策略比embedding模型重要。

换三个embedding模型来回折腾,最后发现问题是我把句子拦腰切断了。先修chunk,再调模型。

2. num_candidates别设太小。

你设num_candidates=5, k=5,HNSW基本没空间找最优邻居,召回会差。至少10倍。

3. 元数据过滤是打在HNSW遍历里的。

filter参数在kNN query里是预过滤,不是搜完再筛。所以filter始终返回k个满足条件的结果。这跟post-filtering有本质区别。

4. 量化是标配,不是可选。

int8_hnsw:内存-75%,召回损失<2%。没理由不开。

5. 查询向量缓存。

同一个问题或相似问题反复来?用Redis做向量缓存。embedding API的钱能省一大笔。


十、Stack总结

复制代码
向量库:     Elasticsearch 8.x(kNN+BM25混合,RRF融合)
Embedding:  OpenAI text-embedding-3-small(1536维)
LLM:        GPT-4o(temperature=0.1)
分块:       512 token / 50 overlap
量化:       int8_hnsw
框架:       不用框架,ES Python Client + OpenAI SDK

这套方案已经在生产环境稳定跑了。记住一句话:先死磕索引设计和分块策略,后面的都是可调参数。