千万文档规模的查询时态RAG:过滤器、路由器、智能体与评估机制
拥有千万份文档的RAG系统:通过SQL过滤器、混合搜索与重排序技术,结合查询路由、评估功能以及Qdrant脚本,实现低于200毫秒的检索速度。
来源: Scaling RAG to 10 Million Documents Part 2: Optimizing retrieval and generation
将一千万份文档导入向量数据库仅是完成工作的一半。本主题的前半部分讨论了数据导入过程:诸如Docling、Unstructured和Apache Tika之类的文档路由工具,基于布局的分块处理,使用MinHash实现的增量索引,以及一种无需破坏数据或增加成本即可对实际文件进行索引的双存储设计。
后半部分则涉及查询速度。即便有数百万个嵌入的分块,如果检索需要8秒钟,或者因特定错误代码而卡住,又或者泄露了敏感的薪资数据,或是被某些旧文件中隐藏的注入指令影响,那这些分块也毫无用处。接下来的内容将介绍一种能在200毫秒内给出响应的检索流程、智能查询分类机制、代理协调功能、持续评估方法以及完整的脚本。
1. 多阶段检索流程
典型的教程建议将用户查询嵌入其中,然后对整个数据库进行top_k=5的相似度搜索。在千万级文档量的情况下,这种方法会引发两个重大问题。
- 密集向量能很好地捕捉抽象含义,但对于精确的关键词、字母数字形式的零件编号以及错误代码则效果不佳。搜索
Error 504 Gateway Timeout时,可能返回的是关于超时的通用内容,而非具体的故障排除页面。 - 使用复杂的相关性模型为每个候选项打分,其速度实在过于缓慢,无法满足交互式使用的需求。
因此,生产系统将检索过程视为一个逐步筛选候选项的漏斗:
[ 10,000,000 Total Document Chunks ]
│
▼
[ Step 1: SQL Pre-Filter ] ────────► Filter by Tenant / Dept / Role / Region
│
▼
[ ~50,000 Candidates ]
│
▼
[ Step 2: Hybrid Search ] ─────────► Dense Vectors (Qdrant) + Sparse BM25
│
▼
[ Top 100 Candidates ]
│
▼
[ Step 3: Cross-Encoder ] ───────► Cohere Rerank / BGE-Reranker
│
▼
[ Final Top 5 Chunks ] ──────────► Passed to LLM Context Window
第一阶段:关联预过滤
在进行任何高维数学运算之前,基于用户角色、部门或文档状态的元数据过滤器会先缩小搜索范围。这样就能将数据量从一千万个单元减少到大约5万个,从而在降低延迟的同时实现访问控制:
from qdrant_client.models import Filter, FieldCondition, MatchValue
# Restrict search space by user session permissions before distance scoring
user_access_filter = Filter(
must=[
FieldCondition(key="department", match=MatchValue(value="Engineering")),
FieldCondition(key="is_active", match=MatchValue(value=True))
]
)
第二阶段:混合搜索
实际的搜索方式有两种互补的形式,在研究融合算法之前了解这两种方式很有帮助。
密集型或语义搜索会将文本转换为向量数据库中的嵌入向量,使含义相似的词语彼此靠近。它能够很好地处理同义表达、近义词以及口语化表达,但可能会忽略精确的关键词,而返回仅具有相似概念的文档。
稀疏型或关键词搜索则使用BM25或较旧的TF-IDF等算法,根据某个词在文档中出现的频率以及在整个语料库中的稀有程度来为其评分。它能可靠地捕捉到精确的词语,但无法理解其含义。
一个诱人的做法是同时运行这两种排序方法并汇总它们的得分,而这正是互反排名融合技术的用武之地。RRF能够将多个排序列表合并为一个统一的列表,且无需对原始得分进行标准化处理。在两种排序中都处于靠前位置的文档,会在合并后的列表中位居首位。
第三阶段:交叉编码器重新排序
混合搜索很快就能生成大约一百个候选结果,但仅依据向量距离对它们进行排序仍可能产生无关结果。有两种编码方式可以解决这个问题。
双编码器是普通向量搜索的运作方式。在数据导入时,文档会被转换为向量,而在查询时用户的提示语也会被转换为另一个向量,随后通过余弦相似度或点积等简单的几何运算来比较它们。这种方式速度快,但准确度仅处于中等水平。
交叉编码器会同时读取查询内容和文档,然后输出该文档与查询内容的关联度得分。由于需要为一次搜索对百万份文档进行评分,这一过程十分耗时。解决方法是将两者结合使用:首先由双向编码器筛选出前100个候选文档,再由交叉编码器从中选出最佳的10个,整个过程仅需不到100毫秒。
2. 条件查询路由器
大多数聊天界面都是纯文本输入框,用户可以输入任何内容。像“总结我们目前的对话”或“将结果翻译成德语”这样的消息根本不需要查询文档,将其通过完整流程处理只会浪费时间和金钱。
条件路由器作为前置的快速分类器,负责决定每条消息的去向:
User Query
│
▼
[ Intent Classifier / Router ]
│
├── Simple Math / Logic ──────────► Direct Calculator / Python REPL
├── Conversational / Follow-up ───► Direct LLM Memory Context
└── Domain Knowledge Request ─────► Full Hybrid RAG Pipeline
从概念上讲,简短的提示会让模型将查询分类到几种处理路径中的一种,比如从内部文档中检索信息或直接给出答案:
# Conceptual Router Pattern
def route_query(user_query: str) -> str:
prompt = f"""Classify the user query into one of these routes:
- RETRIEVE: Needs internal company documentation/database lookup.
- COMPUTE: Pure math, calculation, or logic.
- DIRECT: Conversational, greetings, or basic language rewrites.
Query: {user_query}
Classification:"""
# Run a fast, lightweight classifier (or small local SLM)
decision = fast_classifier(prompt).strip()
return decision
3. 超出单次查询:协调与反馈
许多实际问题需要多个推理步骤。以比较2025年亚太地区的销售数据与2024年的合规准则并标记差异为例,该任务可拆分为三个子任务:查找并提取2025年亚太地区的销售数据、获取2024年的合规准则,以及找出两者之间的冲突。
单次查询系统无法处理此类问题,因为它试图在一次提示中回答所有问题。代理式RAG通过LangGraph或CrewAI等协调框架来解决这一问题,这些框架将任务分解为相互协作的专项任务。规划器可以创建多个代理,每个代理都使用相同的检索流程来收集属于自己的相关证据:
[ Orchestrator / Planner ]
│
┌─────────────────┴─────────────────┐
▼ ▼
[ Researcher Agent ] [ Compliance Agent ]
• Retrieves 2025 Sales Data • Retrieves 2024 Regulations
• Extracts regional tables • Parses policy constraints
│ │
└─────────────────┬─────────────────┘
▼
[ Synthesis Agent ]
• Reconciles numbers
• Validates output consistency
│
Confidence Score Check
│ │
[ Low Confidence ] [ High Confidence ]
│ │
▼ ▼
Loop back & refine Final Guardrail Validation
自我修正
当检索结果返回相似度较低的片段,或合成智能体发现矛盾时,它应当作出反应。它应拒绝这些质量较低的文本,通过展开缩写或尝试同义词来重新编写查询,然后再次向检索引擎发起查询,而非给出编造的通用答案。智能体负责协调与决策,但若没有可靠的RAG流程作为支撑,它们就无从下手。
4. 持续评估
生产系统需要借助Ragas、TruLens或DeepEval等自动化框架进行持续评分。这些框架会从多个维度进行评估:
┌──► Faithfulness (Is the answer grounded in the retrieved chunks?)
RAG Evaluation ─┼──► Answer Relevance (Did it actually answer the user's prompt?)
├──► Context Recall (Did retrieval find all necessary reference chunks?)
└──► System Latency & Token Cost (Is it cost-effective at scale?)
除了运营指标之外,还有若干服务水平指标用于衡量检索效果:
- “准确性”指的是答案中由检索到的上下文支持的占比,可用于检测幻觉问题。
- “上下文精确度”是指在提示词中所有被检索到的片段中,实际相关的占比。
- “上下文召回率”则用于判断问题所需的每一个真实事实是否都被检索到。
- “延迟时间”包括端到端延迟以及各个阶段的延迟,涉及路由器、过滤器、混合搜索、重排序器和生成环节。
每个指标都应设定目标基准值,以便检查质量和性能是否符合要求。
5. 完整的检索与生成脚本
下面的 Python 脚本负责执行部分。它会对元数据进行预过滤,从 Qdrant 中获取向量,整合上下文信息,然后调用本地的 LM Studio 服务器进行推理:
from openai import OpenAI
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
# 1. Initialize Clients
# Pointing to local LM Studio running on port 8080
ai_client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="lm-studio")
qdrant_client = QdrantClient(url="http://localhost:6333")
COLLECTION_NAME = "enterprise_knowledge_base"
EMBEDDING_MODEL = "nomic-ai/nomic-embed-text-v1.5"
def retrieve_and_generate(user_query: str, user_department: str) -> str:
print(f"\n🔍 Processing query: \"{user_query}\" for department: [{user_department}]")
# 2. Vectorize the User Query
query_resp = ai_client.embeddings.create(
input=[user_query],
model=EMBEDDING_MODEL
)
query_vector = query_resp.data[0].embedding
# 3. Stage 1 & 2: SQL Pre-Filter + Vector Search
# Filter by user department and active document status
access_filter = Filter(
must=[
FieldCondition(key="department", match=MatchValue(value=user_department))
]
)
search_results = qdrant_client.search(
collection_name=COLLECTION_NAME,
query_vector=query_vector,
query_filter=access_filter,
limit=3
)
if not search_results:
return "No relevant or authorized documents found."
# 4. Context Assembly with Breadcrumbs
context_blocks = []
for hit in search_results:
breadcrumb = hit.payload.get("breadcrumb", "General")
text = hit.payload.get("text", "")
context_blocks.append(f"[{breadcrumb}]\n{text}")
full_context = "\n\n---\n\n".join(context_blocks)
# 5. Generation via Local LLM
system_prompt = (
"You are an enterprise technical assistant. "
"Answer the user query strictly using the provided context. "
"If the context does not contain the answer, explicitly state that you do not know.\n\n"
f"Context:\n{full_context}"
)
completion = ai_client.chat.completions.create(
model="local-model",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query}
],
temperature=0.1
)
return completion.choices[0].message.content
# Example Execution
if __name__ == "__main__":
response = retrieve_and_generate(
user_query="How do I enable TLS 1.3 in config.yaml?",
user_department="Engineering"
)
print("\n🤖 Final Answer:\n", response)
生产架构的形态
从原型阶段发展到能够处理数百万份企业文档的系统,意味着需要将RAG视为一个完整的工程学科来处理,而非仅仅是一次简单的库调用。以下四个习惯可概括这一要点:
- 规范数据导入。结构优于巧思,因此应使用专用解析器,保留文件结构信息,并通过差分哈希避免对未更改的文件重新建立索引。
- 尽早过滤。安全范围与关联元数据应在任何复杂的向量计算之前,通过SQL步骤进行处理。
- 结合多种搜索策略。将密集向量与BM25关键词匹配以及交叉编码器重排器相结合使用。
- 严守边界。根据条件路由以避免不必要的查询,设置输入与输出约束,并在高风险操作中让人工介入。
相关阅读
- 为1亿份文档设计RAG:分片、混合搜索与成本控制 —— 当RAG系统的数据量增长到数十亿个片段时会发生哪些变化:异步导入、路由、分片、混合检索、重排序、授权、缓存以及成本管控。
- 如何排查RAG系统返回无结果的问题 —— 针对RAG系统返回无有用片段的逐步排查清单:数据导入、得分阈值、过滤器、查询重写、混合搜索、重排序、备用方案以及性能监控。