AI应用开发五:RAG高级技术与调优

RAG 真正难的地方,往往不是"把文档塞进向量库"这一步,而是系统上线以后怎么持续变准、变稳、变可维护。

一个最朴素的 RAG 流程可以拆成三段:

text 复制代码
Indexing    -> 怎么把知识更好地存起来
Retrieval   -> 怎么从大量知识里找到少量有用内容
Generation  -> 怎么结合用户问题和检索结果生成答案

这三段看起来简单,但只要进入真实业务,就会遇到一堆工程问题:用户问法和知识切片对不上、对话里产生的新知识没有沉淀、知识库里有过期和冲突内容、版本更新后不知道效果有没有变差、召回结果太多太杂、单纯向量检索漏掉关键词、长文档上下文塞不进去、宏观问题又很难靠几个 chunk 回答。

所以 RAG 调优不能只盯着单一参数,例如 TopK、chunk size 或相似度阈值。更合理的看法是把 RAG 看成一条完整链路:

知识库处理 -> 高效检索/召回 -> Rerank 精排 -> GraphRAG 关系增强与全局理解 -> Agentic RAG 动态决策。

其中,知识库处理解决"知识本身是否可靠、可维护";高效检索解决"能不能找得到";Rerank 解决"找出来后能不能排得准";GraphRAG 解决"跨文档、跨实体、跨关系的全局理解";Agentic RAG 解决"检索流程是否可以根据问题动态规划"。

下面按这条链路,把 RAG 高级技术和调优方法整理一遍。

一、整体框架:先建立 RAG 调优的全局认识

1. RAG 调优先看全局

RAG 调优可以分成四组能力。

第一组是知识库处理。它解决的是"知识本身靠不靠谱、能不能持续维护"的问题,包括问题生成、对话沉淀、知识健康度检查、版本管理等。

第二组是高效检索与精排。它解决的是"相关内容能不能被找出来、找出来以后能不能排得准"的问题,包括 MultiQuery、多路召回、BM25 + Vector 混合检索,以及 Rerank 精排。

第三组是GraphRAG。它解决的是"跨文档、跨实体、跨关系的全局理解"问题。相比普通 RAG 主要依赖文本切片和相似度检索,GraphRAG 会进一步抽取实体、关系和主题结构,更适合大量文档中的宏观总结、关系分析和复杂推理。

第四组是Agentic RAG。它解决的是"检索流程是否固定"的问题。Native RAG 通常是固定流程,例如用户提问后直接检索、拼接上下文、生成回答;Agentic RAG 则让模型动态判断是否需要检索、调用哪个检索工具、是否进行问题改写、多跳查询或多轮检索。简单说,Native RAG 是流程驱动,Agentic RAG 是模型决策驱动。

这不是互相替代,而是 RAG 系统逐步增强的一条工程路线: 知识库处理 → 高效召回 → Rerank 精排 → GraphRAG 关系理解 → Agentic RAG 动态决策

RAG 不是一个单点工具,而是一套知识工程系统。

二、知识库建设与治理:让知识更容易被检索、更可靠、更可维护

1. 为什么要给知识切片生成问题

普通 RAG 通常直接把原始 chunk 做 embedding。问题是,用户的提问方式未必和原文长得像。

例如原始知识是:

text 复制代码
创极速光轮位于明日世界,是上海迪士尼乐园中速度感较强的项目之一。

用户可能会问:

text 复制代码
如果我想体验最刺激的过山车,应该去哪个区域?

这句话里没有"创极速光轮",也没有"明日世界",单靠原文相似度可能召回不稳。

一个实用做法是:为每个知识切片提前生成若干个"这个 chunk 能回答的问题"。入库时不只存原文特征,也存这些问题的特征。用户提问时,query 更容易和"生成的问题"匹配上。

整体逻辑是:

text 复制代码
原始 chunk
-> LLM 生成 5 到 8 个可能问题
-> 原文建索引
-> 问题也建索引
-> 用户 query 同时检索原文和问题
-> 命中问题后,回到原始 chunk

注意最后一步:返回给大模型的通常还是原文 chunk,不是生成的问题。生成的问题更像"检索入口",作用是帮系统更容易找到原始知识。

生成基础问题

可以先做一个基础版本,为每个 chunk 生成 5 个问题。

python 复制代码
def generate_questions_for_chunk(knowledge_chunk: str, num_questions: int = 5):
    instruction = """
你是一个专业的问答系统专家。给定一段知识内容,请判断它能够回答哪些问题。
问题需要满足:
1. 使用不同问法,例如直接问、间接问、对比问、条件问。
2. 避免重复和相似问题。
3. 不要超出知识内容本身。
4. 返回 JSON 格式。
"""

    prompt = f"""
### 指令 ###
{instruction}

### 知识内容 ###
{knowledge_chunk}

### 生成问题数量 ###
{num_questions}

### 返回格式 ###
{{
  "questions": [
    {{
      "question": "问题内容",
      "question_type": "直接问/间接问/对比问/条件问",
      "difficulty": "简单/中等/困难"
    }}
  ]
}}
"""

    response = get_completion(prompt)
    response = preprocess_json_response(response)
    return json.loads(response)["questions"]

比如关于上海迪士尼的 chunk,可以生成这些问题:

text 复制代码
1. 上海迪士尼乐园是什么时候开园的?
2. 中国大陆第一座迪士尼乐园在哪里?
3. 上海迪士尼和其他迪士尼相比有什么特殊意义?
4. 如果想游览全部主题园区,需要了解哪些区域?
5. 上海迪士尼乐园占地多少公顷?

生成更多样化的问题

如果想让检索覆盖更多问法,可以把问题类型扩展到假设问、推理问、规划问等,并要求模型返回答案、角度、是否可回答。

python 复制代码
def generate_diverse_questions(knowledge_chunk: str, num_questions: int = 8):
    prompt = f"""
你是一个专业的问答系统专家。请为给定知识内容生成高度多样化的问题。

要求:
1. 问题类型多样:直接问、间接问、对比问、条件问、假设问、推理问。
2. 表达方式多样:不要只替换几个词,要换不同句式。
3. 难度层次多样:简单、中等、困难都要有。
4. 角度多样:时间、地点、用途、规划、限制、意义都可以覆盖。
5. 不能编造知识内容之外的信息。

### 知识内容 ###
{knowledge_chunk}

### 生成数量 ###
{num_questions}

### 返回 JSON ###
{{
  "questions": [
    {{
      "question": "问题内容",
      "question_type": "问题类型",
      "difficulty": "难度等级",
      "perspective": "提问角度",
      "is_answerable": true,
      "answer": "基于该知识的回答"
    }}
  ]
}}
"""

    response = get_completion(prompt)
    return json.loads(preprocess_json_response(response))["questions"]

这个方法本质上就是 Doc2Query。它不是为了让模型"凭空知道更多",而是给原有知识增加更多可匹配的入口。

原文检索和问题检索的效果对比

材料里的示例用 BM25 做了对比:原文检索准确率是 66.7%,问题检索准确率是 100%。尤其是"如果我想体验最刺激的过山车,应该去哪个区域?"这种口语化问题,问题索引明显更容易命中。

这里的关键不是具体数字,而是思路:

text 复制代码
原文 chunk 适合保真
生成问题适合召回
命中问题以后再回到原文

2. 对话知识沉淀:把线上问答变成知识库

产品上线以后,每天会产生大量用户对话。里面有很多信息不应该只停留在聊天记录里,比如:

  • 用户反复问到的新问题。
  • 客服临时补充的规则。
  • 某个流程的真实操作步骤。
  • 高频误解和注意事项。
  • 某些知识库没有覆盖到的内容。

对话知识沉淀的目标是:从对话里提取稳定、有复用价值的知识,再合并、过滤、入库。

从单次对话中提取结构化知识

可以让 LLM 把一段对话整理成结构化 JSON。

python 复制代码
def extract_knowledge_from_conversation(conversation: str):
    prompt = f"""
你是一个专业的知识提取专家。请从给定对话中提取有价值的知识点。

可提取的知识类型包括:
1. 事实性信息:地点、时间、价格、规则等。
2. 用户需求和偏好。
3. 常见问题和解答。
4. 操作流程和步骤。
5. 注意事项和提醒。

### 对话内容 ###
{conversation}

### 返回 JSON ###
{{
  "extracted_knowledge": [
    {{
      "knowledge_type": "事实/需求/问题/流程/注意",
      "content": "知识内容",
      "confidence": 0.0,
      "source": "用户/AI/对话",
      "keywords": ["关键词1", "关键词2"],
      "category": "分类"
    }}
  ],
  "conversation_summary": "对话摘要",
  "user_intent": "用户意图"
}}
"""

    response = get_completion(prompt)
    return json.loads(preprocess_json_response(response))

例如用户询问上海迪士尼门票、预订和交通方式,系统可以提取出:

text 复制代码
事实:成人票价格、儿童票价格、免票规则
流程:从浦东机场坐地铁或打车到迪士尼
注意:周末和节假日建议提前预订
摘要:用户在规划迪士尼出行
意图:了解票价、购票建议和交通方式

过滤临时信息

不是所有对话内容都适合入库。需求和问题往往是临时的、个性化的,不一定要作为知识沉淀。

python 复制代码
filtered_knowledge = [
    item for item in knowledge_list
    if item.get("knowledge_type") not in ["需求", "问题"]
]

这一步很重要。知识库应该沉淀"稳定知识",而不是把每一个用户的临时想法都塞进去。

合并相似知识

不同对话里可能反复出现同类信息,比如门票、交通、停车、携带食物。可以先按知识类型分组,再用 LLM 合并相似内容。

python 复制代码
def merge_similar_knowledge(filtered_knowledge):
    knowledge_by_type = {}

    for item in filtered_knowledge:
        knowledge_type = item.get("knowledge_type", "其他")
        knowledge_by_type.setdefault(knowledge_type, []).append(item)

    merged_knowledge = []

    for knowledge_type, group in knowledge_by_type.items():
        if len(group) == 1:
            merged_knowledge.append(group[0])
        else:
            merged = merge_knowledge_with_llm(group, knowledge_type)
            merged_knowledge.append(merged)

    return merged_knowledge

合并提示词可以这样写:

text 复制代码
你是一个专业的知识整理专家。请将以下同类型知识点进行智能合并,生成一个更完整、准确的知识点。

合并要求:
1. 保留所有重要信息,避免信息丢失。
2. 消除重复内容,整合相似表述。
3. 提高内容的准确性和完整性。
4. 保持逻辑清晰,结构合理。
5. 合并后的置信度取所有知识点中的最高值。

合并后的结果可以带上 frequency 字段。某个知识点出现次数越高,越说明它可能是高频问题,应该优先维护。

3. 知识库健康度检查:完整性、时效性、一致性

RAG 系统上线后,知识库不是越大越好。更重要的是健康。

健康度可以从三方面看:

text 复制代码
完整性:用户常问的问题,知识库有没有覆盖?
时效性:价格、活动、政策、版本是不是过期?
一致性:不同 chunk 之间有没有互相冲突?

检查缺失知识

完整性检查通常需要一组测试查询。LLM 读取测试查询和知识库内容后,判断哪些问题缺少知识支撑。

python 复制代码
def check_missing_knowledge(knowledge_text: str, queries_text: str):
    prompt = f"""
你是一个知识库完整性检查专家。请分析给定测试查询和知识库内容,判断知识库中是否缺少相关知识。

检查标准:
1. 查询是否能在知识库中找到相关答案。
2. 知识是否完整、准确。
3. 是否覆盖用户的主要需求。
4. 是否存在知识空白。

### 知识库内容 ###
{knowledge_text}

### 测试查询 ###
{queries_text}

### 返回 JSON ###
{{
  "missing_knowledge": [
    {{
      "query": "测试查询",
      "missing_aspect": "缺少的知识方面",
      "importance": "高/中/低",
      "suggested_content": "建议补充内容",
      "category": "知识分类"
    }}
  ],
  "coverage_score": 0.0,
  "completeness_analysis": "完整性分析"
}}
"""

    return json.loads(preprocess_json_response(get_completion(prompt)))

检查过期知识

时效性检查要把当前时间传进去。价格、营业时间、活动、技术版本、政策规则都容易过期。

python 复制代码
from datetime import datetime


def check_outdated_knowledge(knowledge_text: str):
    current_time = datetime.now().strftime("%Y年%m月%d日")

    prompt = f"""
你是一个知识时效性检查专家。请分析给定知识内容,判断是否存在过期或需要更新的信息。

检查标准:
1. 时间相关信息是否过期。
2. 价格、费用、票价是否最新。
3. 政策、规定、规则是否更新。
4. 活动信息是否有效。
5. 联系方式是否准确。
6. 技术版本或标准是否过时。

### 知识库内容 ###
{knowledge_text}

### 当前时间 ###
{current_time}

### 返回 JSON ###
{{
  "outdated_knowledge": [
    {{
      "chunk_id": "知识切片ID",
      "content": "知识内容",
      "outdated_aspect": "过期方面",
      "severity": "高/中/低",
      "suggested_update": "建议更新内容",
      "last_verified": "最后验证时间"
    }}
  ],
  "freshness_score": 0.0,
  "update_recommendations": "更新建议"
}}
"""

    return json.loads(preprocess_json_response(get_completion(prompt)))

检查冲突知识

一致性检查更像"知识库体检"。同一主题不同说法、价格不一致、营业时间冲突、规则互相矛盾,都要被找出来。

python 复制代码
def check_conflicting_knowledge(knowledge_text: str):
    prompt = f"""
你是一个知识一致性检查专家。请分析给定知识库,找出可能存在冲突或矛盾的信息。

检查标准:
1. 同一主题的不同说法。
2. 价格信息差异。
3. 时间信息不一致。
4. 规则政策冲突。
5. 操作流程差异。
6. 联系方式差异。

### 知识库内容 ###
{knowledge_text}

### 返回 JSON ###
{{
  "conflicting_knowledge": [
    {{
      "conflict_type": "冲突类型",
      "chunk_ids": ["相关切片ID"],
      "conflicting_content": ["冲突内容"],
      "severity": "高/中/低",
      "resolution_suggestion": "解决建议"
    }}
  ],
  "consistency_score": 0.0,
  "conflict_analysis": "冲突分析"
}}
"""

    return json.loads(preprocess_json_response(get_completion(prompt)))

材料里的健康度报告示例给出了整体评分、覆盖率、新鲜度、一致性和改进建议。

这里有个现实问题:LLM 能不能在大量 chunk 中全面找出冲突?

不能指望一次性把十万条 chunk 全塞进去检查。更实际的做法是先分类,再分组检查。例如:

text 复制代码
票价类知识 -> 单独检查价格冲突
活动规则 -> 单独检查时间和规则冲突
交通类知识 -> 单独检查路线和耗时冲突
权限制度 -> 单独检查适用范围和版本冲突

也就是先用 metadata 或目录结构缩小范围,再让 LLM 做局部健康检查。

4. 知识库版本管理与性能比较

RAG 的知识库会持续更新。只要持续更新,就需要版本管理。

版本管理至少要解决几个问题:

  • 当前知识库是哪一版?
  • 新版和旧版差了哪些 chunk?
  • 新版检索效果是否更好?
  • 新版有没有破坏旧问题的召回?
  • 上线前怎么验收?

创建版本

版本里可以记录名称、描述、chunk 数量、平均长度、分类分布和内容 hash。

python 复制代码
import hashlib
from datetime import datetime


def calculate_kb_hash(knowledge_base):
    raw = json.dumps(knowledge_base, ensure_ascii=False, sort_keys=True)
    return hashlib.md5(raw.encode("utf-8")).hexdigest()


def create_version(name: str, description: str, knowledge_base: list):
    lengths = [len(item["content"]) for item in knowledge_base]

    return {
        "version_name": name,
        "description": description,
        "created_at": datetime.now().isoformat(),
        "hash": calculate_kb_hash(knowledge_base),
        "chunk_count": len(knowledge_base),
        "avg_chunk_length": sum(lengths) / len(lengths) if lengths else 0,
        "knowledge_base": knowledge_base,
    }

版本差异检测

差异检测可以先不用 LLM,用集合运算就能识别新增、删除和修改。

python 复制代码
def detect_changes(kb1: list, kb2: list):
    kb1_dict = {chunk["id"]: chunk for chunk in kb1}
    kb2_dict = {chunk["id"]: chunk for chunk in kb2}

    added_ids = set(kb2_dict) - set(kb1_dict)
    removed_ids = set(kb1_dict) - set(kb2_dict)
    common_ids = set(kb1_dict) & set(kb2_dict)

    modified = []
    for chunk_id in common_ids:
        if kb1_dict[chunk_id]["content"] != kb2_dict[chunk_id]["content"]:
            modified.append({
                "id": chunk_id,
                "old_content": kb1_dict[chunk_id]["content"],
                "new_content": kb2_dict[chunk_id]["content"],
            })

    return {
        "added": [kb2_dict[i] for i in added_ids],
        "removed": [kb1_dict[i] for i in removed_ids],
        "modified": modified,
    }

检索性能评估

性能评估要准备固定测试集。每次版本更新后,用同一批 query 测:准确率、响应时间、通过率。

python 复制代码
def evaluate_version_performance(version, test_queries, retriever):
    total = len(test_queries)
    correct = 0
    response_times = []

    for query_info in test_queries:
        query = query_info["query"]
        expected_answer = query_info["expected_answer"]

        start = datetime.now()
        retrieved_chunks = retriever.retrieve(query, version_name=version["version_name"], k=3)
        end = datetime.now()

        response_times.append((end - start).total_seconds())

        if evaluate_retrieval_quality(expected_answer, retrieved_chunks):
            correct += 1

    return {
        "accuracy": correct / total if total else 0,
        "avg_response_time": sum(response_times) / len(response_times) if response_times else 0,
        "total_queries": total,
    }

材料里的示例中,v1.0 只有 3 个 chunk,v2.0 增加到 5 个 chunk,并补充了儿童票、交通、特色项目等信息。结果是准确率从 60% 提升到 100%,但响应时间略有增加。

这正是版本管理的价值:不要凭感觉说"新版更好",要用固定测试集量化对比。

三、召回、排序与上下文组织:先找全,再排准,最后用好

1. 高效召回:先把候选找全

召回阶段的目标不是一步到位选出最终答案,而是先把可能有用的候选找出来。

如果召回阶段漏掉了正确 chunk,后面的 LLM 再强也很难回答对。

材料里有一个 DeepSeek + FAISS 的本地知识库检索案例,问题是:

text 复制代码
客户经理被投诉了,投诉一次扣多少分?

系统从文档中找到了"有客户投诉的,每投诉一次扣 2 分"的规则,并基于检索结果回答。

用 LangChain + FAISS 时,基础代码大概是这样:

python 复制代码
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.llms import Tongyi

embeddings = DashScopeEmbeddings(
    model="text-embedding-v1",
    dashscope_api_key=DASHSCOPE_API_KEY,
)

knowledge_base = FAISS.from_texts(chunks, embeddings)

llm = Tongyi(
    model_name="deepseek-v3",
    dashscope_api_key=DASHSCOPE_API_KEY,
)

最简单的召回调参是增大 k

python 复制代码
docs = knowledge_base.similarity_search(query, k=10)

k 不是越大越好。候选多了,召回率可能变高,但噪声也会变多,后续生成成本也会上升。因此一般会配合 Rerank:粗召回多拿一点,精排后只保留少量高质量 chunk。

2. MultiQuery:把一个问题改写成多个问题

用户问题经常很短,表达也不稳定。MultiQuery 的思路是让 LLM 生成多个查询变体,从不同角度检索。

例如原问题:

text 复制代码
客户经理被投诉了,投诉一次扣多少分?

可以改写成:

text 复制代码
客户经理投诉扣分标准是什么?
银行客户经理被投诉一次会扣除多少绩效分?
金融机构客户经理投诉处罚机制是什么?

代码可以这样写:

python 复制代码
from typing import List


def generate_multi_queries(query: str, llm, num_queries: int = 3) -> List[str]:
    prompt = f"""
你是一个检索查询改写助手。请根据用户问题生成 {num_queries} 个不同但相关的查询。

要求:
1. 每个查询都表达同一个信息需求。
2. 尽量从不同角度改写。
3. 不要输出编号和解释。
4. 每行一个查询。

原始问题:{query}
"""

    response = llm.invoke(prompt)
    queries = [line.strip() for line in response.splitlines() if line.strip()]

    return [query] + queries[:num_queries]

然后对每个 query 分别检索,再去重:

python 复制代码
def multi_query_search(query: str, retriever, llm, k: int = 4):
    queries = generate_multi_queries(query, llm)
    all_docs = []

    for q in queries:
        docs = retriever.similarity_search(q, k=k)
        all_docs.extend(docs)

    return deduplicate_documents(all_docs)

MultiQuery 的价值是提高召回覆盖率,代价是会多次检索,也可能引入更多噪声。适合问题短、问法多、同义表达多的场景。

3. 混合检索:BM25 + Vector

向量检索擅长语义相似,但有时会漏掉精确关键词。BM25 擅长关键词匹配,但不懂同义词和语义。

所以实际项目里经常做混合检索。

BM25 可以理解为 TF-IDF 的改进版本。它会看:

text 复制代码
一个词在当前文档里出现得多不多
这个词在整个语料库里稀不稀有
当前文档长度是否影响分数

对于法规、制度、技术文档、专有名词、编号、字段名、接口名,BM25 往往非常有用。

混合检索的流程是:

text 复制代码
用户 query
-> BM25 检索得到关键词分数
-> 向量检索得到语义分数
-> 两种分数归一化到 [0, 1]
-> 按权重融合
-> 返回 Top-K

融合公式:

text 复制代码
Score = alpha * VectorScore + (1 - alpha) * BM25Score

alpha 控制偏向:

  • alpha = 0.0:纯 BM25,只看关键词。
  • alpha = 0.3:偏关键词,兼顾语义。
  • alpha = 0.5:平衡,常用默认值。
  • alpha = 0.7:偏语义,兼顾关键词。
  • alpha = 1.0:纯向量,只看语义。

一个混合检索实现

下面是简化版代码,便于理解逻辑。

python 复制代码
import jieba
import numpy as np
from rank_bm25 import BM25Okapi


class HybridRetriever:
    def __init__(self, chunks, vectorstore):
        self.chunks = chunks
        self.vectorstore = vectorstore
        self.tokenized_corpus = [self.tokenize(chunk.page_content) for chunk in chunks]
        self.bm25 = BM25Okapi(self.tokenized_corpus)

    def tokenize(self, text: str):
        return [word for word in jieba.lcut(text) if word.strip()]

    def bm25_search(self, query: str):
        tokenized_query = self.tokenize(query)
        scores = self.bm25.get_scores(tokenized_query)

        max_score = max(scores) if len(scores) and max(scores) > 0 else 1
        return [score / max_score for score in scores]

    def vector_search_scores(self, query: str):
        vector_results = self.vectorstore.similarity_search_with_score(
            query,
            k=len(self.chunks),
        )

        vector_scores = [0.0] * len(self.chunks)
        max_distance = max(distance for _, distance in vector_results) or 1

        for doc, distance in vector_results:
            idx = doc.metadata["chunk_index"]
            vector_scores[idx] = 1 - distance / max_distance

        return vector_scores

    def search(self, query: str, k: int = 5, alpha: float = 0.5):
        bm25_scores = self.bm25_search(query)
        vector_scores = self.vector_search_scores(query)

        combined = []
        for idx, chunk in enumerate(self.chunks):
            score = alpha * vector_scores[idx] + (1 - alpha) * bm25_scores[idx]
            combined.append((chunk, score))

        combined.sort(key=lambda item: item[1], reverse=True)
        return [doc for doc, _ in combined[:k]]

几个常见问题可以一起说明。

BM25 分数和向量分数不是同一个尺度。BM25 可能大于 1,cos 相似度通常在 [0, 1][-1, 1],L2 距离又是越小越相似。所以融合前要做归一化。

FAISS 本身不做 BM25。FAISS 管向量相似度,BM25 需要另外的工具库,比如 rank_bm25、Elasticsearch、OpenSearch 等。

BM25 很依赖分词。中文场景里分词质量会明显影响结果,尤其是专业词、产品名、机构名、字段名。

如果语义相似度很高但关键词一个都没命中,BM25 分可能是 0,但向量分仍然高。融合后不会直接变成 0,只是综合分会被拉低。这就像考试偏科:一科高、一科低,最终总分中等。

4. Rerank:粗召回之后再精排

Embedding 检索适合快速从大量文档里找候选,但它是"双塔"思路:query 和 doc 分别编码,再算相似度。速度快,但交互不够细。

Rerank 模型通常是 Cross-Encoder:把 (Query, Doc) 一起送进模型,让模型直接判断相关性。它更准,但更慢。

可以这样理解:

text 复制代码
Embedding:快速批量筛选候选
Rerank:对候选逐个打相关性分
LLM:基于最终上下文生成答案

材料里的排序关系可以记成:

text 复制代码
Embedding < Rerank < LLM

这里不是说能力绝对强弱,而是说计算成本和理解深度通常逐级提高。

常见 Rerank 有两类:

  • BGE-Rerank:开源,可本地部署,适合中文和数据敏感场景。
  • Cohere Rerank:商业 API,接入简单,多语言效果好。

BGE Rerank 基础用法

python 复制代码
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer


tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-base")
model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-base")
model.eval()

pairs = [
    ["what is panda?", "The giant panda is a bear species endemic to China."],
    ["what is panda?", "The Eiffel Tower is in Paris."],
]

inputs = tokenizer(
    pairs,
    padding=True,
    truncation=True,
    max_length=512,
    return_tensors="pt",
)

with torch.no_grad():
    scores = model(**inputs).logits.view(-1).float()

print(scores)

BGE 的输出是 logits,不是严格的 0 到 1 概率。分数可以为负,也可以大于 1。一般只需要关心同一批候选里的相对排序。

封装成 Reranker

python 复制代码
from typing import List

import torch
from modelscope import snapshot_download
from transformers import AutoModelForSequenceClassification, AutoTokenizer


class Reranker:
    def __init__(self, model_name="BAAI/bge-reranker-base", cache_dir="./models"):
        model_dir = snapshot_download(model_name, cache_dir=cache_dir)

        self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
        self.model = AutoModelForSequenceClassification.from_pretrained(model_dir)
        self.model.eval()

        self.device = "cuda" if torch.cuda.is_available() else "cpu"
        self.model.to(self.device)

    def rerank(self, query: str, documents: List, top_k: int = 4):
        pairs = [[query, doc.page_content] for doc in documents]

        inputs = self.tokenizer(
            pairs,
            padding=True,
            truncation=True,
            max_length=512,
            return_tensors="pt",
        ).to(self.device)

        with torch.no_grad():
            scores = self.model(**inputs).logits.squeeze(-1).cpu().tolist()

        scored_docs = sorted(
            zip(documents, scores),
            key=lambda item: item[1],
            reverse=True,
        )

        return [doc for doc, _ in scored_docs[:top_k]]

MultiQuery + Hybrid + Rerank

更完整的召回流程通常是两阶段:

text 复制代码
Stage 1:MultiQuery + Hybrid Search 粗召回 10 到 20 个候选
Stage 2:Rerank 精排,最终保留 3 到 5 个
python 复制代码
def hybrid_multi_query_search_with_rerank(
    query: str,
    hybrid_retriever,
    reranker,
    llm,
    initial_k: int = 10,
    final_k: int = 4,
):
    queries = generate_multi_queries(query, llm)

    candidate_docs = []
    for q in queries:
        docs = hybrid_retriever.search(q, k=initial_k, alpha=0.5)
        candidate_docs.extend(docs)

    candidate_docs = deduplicate_documents(candidate_docs)
    print(f"初步召回 {len(candidate_docs)} 个候选")

    reranked_docs = reranker.rerank(query, candidate_docs, top_k=final_k)
    print(f"Rerank 后保留 {len(reranked_docs)} 个")

    return reranked_docs

参数建议:

  • initial_k:10 到 20。太小可能漏召回,太大会拖慢 Rerank。
  • final_k:3 到 5。太多容易引入噪声。
  • max_length:512。长文档需要分段,否则会被截断。
  • alpha:先从 0.5 开始,再根据业务测试集调。

5. Query2Doc 和 Doc2Query

双向改写主要解决短文本向量化效果不稳定的问题。

Query2Doc

Query2Doc 是把用户的短 query 扩写成一段"可能的答案文档"。

比如用户问:

text 复制代码
如何提高深度学习模型的训练效率?

LLM 可以扩写成:

text 复制代码
提高深度学习模型训练效率可以从优化算法、混合精度训练、分布式训练、数据预处理、学习率调度等角度入手。

这样扩写后的内容更像文档,embedding 时语义特征更丰富。

python 复制代码
def query_to_doc(query: str, llm):
    prompt = f"""
请把下面这个检索问题扩写成一段可能出现在相关文档中的内容。
不要直接回答成最终答案,而是生成适合检索的背景描述。

用户问题:{query}
"""

    return llm.invoke(prompt)

Doc2Query

Doc2Query 是为每个文档 chunk 生成可能问题,然后把问题和原文一起用于索引。实际入库时可以这样组织:

text 复制代码
原始 chunk:500 token
生成问题:5 个,约 200 token
索引用文本:原始 chunk + 生成问题
向量:embedding(索引用文本)
返回给 LLM:原始 chunk

代码结构可以这样写:

python 复制代码
def build_doc2query_index_text(chunk: str, generated_questions: list[str]):
    questions_text = "\n".join(f"Q: {q}" for q in generated_questions)

    return f"""
### 原始知识 ###
{chunk}

### 这个知识可以回答的问题 ###
{questions_text}
""".strip()

如果用户 query 匹配到了生成问题 D,系统仍然通过 metadata 找回原始 chunk K,把 K 放进 prompt。不要只把问题 D 丢给大模型。

6. Small-to-Big:先用小内容定位,再取大上下文

Small-to-Big 是处理长文档时很常用的策略。

它的核心思想是:

text 复制代码
small:摘要、标题、关键句、小段落,用来检索
big:原文大段、完整章节、完整文档,用来回答

为什么要这样做?

小内容更适合做索引,因为它短、信息密度高、检索快。大内容更适合给 LLM,因为它上下文完整,不容易断章取义。

一个简单数据结构可以这样设计:

python 复制代码
small_index_items = [
    {
        "small_id": "paper_001_summary",
        "big_id": "paper_001_full",
        "content": "本文介绍了 Transformer 在机器翻译任务中的应用,并提出改进注意力机制。",
        "type": "summary",
    }
]

big_store = {
    "paper_001_full": {
        "title": "Transformer 论文全文",
        "content": "完整论文内容...",
        "source": "paper_001.pdf",
    }
}

查询时先搜 small:

python 复制代码
def small_to_big_search(query: str, small_vectorstore, big_store, k: int = 3):
    small_hits = small_vectorstore.similarity_search(query, k=k)

    big_contexts = []
    for hit in small_hits:
        big_id = hit.metadata["big_id"]
        big_contexts.append(big_store[big_id])

    return big_contexts

这个问题和笔记里的"small 存哪里、big 存哪里"可以对应起来:

text 复制代码
small 存向量索引,用于检索
big 存原文库或文档库,用于回填上下文
small 和 big 通过 id 关联

如果用 FAISS,也可以理解成两个存储:

text 复制代码
faiss_small:存 summary / question / key sentence 的 embedding
原文库:存完整 chunk / 章节 / 文档
metadata:记录 small_id -> big_id

四、GraphRAG:处理跨文档、跨实体和全局理解问题

1. GraphRAG:从文本相似度走向图谱推理

传统 RAG 主要依赖向量相似度。它适合回答局部事实问题,但对两类问题不太擅长。

第一类是"连接点"问题。答案分散在多个文档里,单个 chunk 看起来都不完整,需要通过实体和关系串起来。

第二类是"宏观理解"问题。比如用户问"这个数据集的主要主题是什么""一批投诉记录反映出哪些共性问题",仅靠 Top-K chunk 很难回答完整。

GraphRAG 的思路是:先从文本中抽取实体、关系、主张,构建知识图谱,再对图谱做社区聚类和摘要。查询时,系统不只找文本 chunk,还可以利用实体、关系和社区摘要。

GraphRAG 索引阶段

GraphRAG 最重的是索引阶段。它把非结构化文本变成结构化图谱。

text 复制代码
源文档
-> 切成 TextUnits
-> LLM 抽取 Entity / Relationship / Claim
-> 合并相同实体和关系
-> Leiden 社区发现
-> 自下而上生成社区摘要
-> 生成图谱、社区报告、向量索引和可视化数据

其中几个对象很关键:

  • Document:原始文档。
  • TextUnit:切分后的文本单元。
  • Entity:实体,例如人、地点、组织、概念。
  • Relationship:实体之间的关系。
  • Covariate / Claim:从文本中抽取的主张或事实陈述。
  • Community Report:社区摘要。

GraphRAG 不只是"多存一点 metadata",而是改变了检索组织方式:从纯文本片段,变成文本 + 实体 + 关系 + 社区摘要。

安装和初始化

官方 GraphRAG 的基本流程大概是:

bash 复制代码
git clone https://github.com/microsoft/graphrag.git
cd graphrag
pip install -e .

初始化项目:

bash 复制代码
graphrag init --root .

初始化后一般会生成:

text 复制代码
.env
settings.yaml
prompts/
input/
output/
cache/
logs/

.env 里配置 API Key:

bash 复制代码
GRAPHRAG_API_KEY=<API_KEY>

settings.yaml 里配置模型、输入输出目录、向量存储、local search、global search 等参数。

把待检索文档放入 input 目录,然后创建索引:

bash 复制代码
graphrag index --root .

索引构建会比较慢,因为它要做 LLM 抽取、图构建、社区检测和摘要生成。

GraphRAG 常见两种查询模式:Global 和 Local。

Global Search 用于回答全局问题,例如:

text 复制代码
这批文档的主要主题是什么?
《三国演义》主要讲了哪些冲突?
这个数据集反映出哪些高层趋势?

它会基于社区报告做类似 Map-Reduce 的流程:先让多个社区报告分别生成中间答案,再汇总重要观点,形成最终回答。成本较高,但适合宏观问题。

Local Search 用于回答具体实体问题,例如:

text 复制代码
关羽战胜过哪些武将?
某个客户经理制度里投诉扣分规则是什么?
某个实体和哪些实体有关?

它会从 query 识别相关实体,再扩展到邻居实体、关系、TextUnit、社区报告和主张,最后构建上下文回答。

查询命令类似:

bash 复制代码
graphrag query --root . --method global --query "和曹操相关的人物都有哪些?"

或:

bash 复制代码
python -m graphrag.query --root ./cases --method local "关羽战胜过哪些武将?"

GraphRAG 查询模式可以这样对比。

更完整的 Global / Local 差异如下。

如果想让 Local Search 匹配到更多 entities 和关系,可以调这些参数:

text 复制代码
如果想让 GraphRAG Local Search 匹配到更多 entities 和 relationships,
可以适当调大 top_k_entities、top_k_relationships、max_context_tokens 等参数;
但参数不是越大越好,因为上下文变大后,成本、延迟和噪声都会上升。

但这些参数不是越大越好。上下文变大以后,成本、延迟和噪声都会上升。

五、Agentic RAG 与常见工程问题:让流程更灵活,但也更可控

1. Agentic RAG 和 Native RAG 的区别

Native RAG 是固定流程。用户一提问,系统就按既定顺序执行:

text 复制代码
query -> embedding -> retrieve -> prompt -> generate

Agentic RAG 更灵活。它会让模型判断:

  • 是否需要检索?
  • 该用哪个知识库?
  • 要不要先改写问题?
  • 要不要调用 BM25、向量检索、GraphRAG 或数据库?
  • 一次检索够不够,要不要多跳?

可以简单理解为:

text 复制代码
Native RAG = 固定流水线
Agentic RAG = LLM 决策 + 工具调用 + 检索

但不要把 Agentic RAG 理解成"更高级所以一定更好"。固定流程更稳定、更容易评估;Agentic RAG 更灵活,但也更难控成本和行为。

真实项目里经常是混合形态:核心链路固定,复杂问题再交给 Agent 决策。

2. 几个容易踩坑的问题

元数据到底有什么用

metadata 是检索命中以后回到业务数据的桥。

向量只负责算相似度,不能还原原始知识。如果没有 metadata,系统只能知道"某个向量相似",但不知道它来自哪个文件、哪一页、哪个部门、哪个版本、有没有权限。

常见 metadata 包括:

text 复制代码
file_name
page_number
chunk_id
author
department
version
created_at
permission_tags
source_url

权限控制也可以放在 metadata 里。比如财务、销售、行政看到的知识不同,可以给 chunk 打 permission_tags,检索时先按用户权限过滤。

芯片寄存器解析适合 RAG 吗

适合,但不要让 RAG 直接"计算 bit"。

更合理的做法是:

text 复制代码
RAG 负责查寄存器定义
代码负责按 bit / byte 解析
LLM 负责解释结果

例如用户给寄存器地址和 32bit 数据,系统先用 RAG 查到寄存器字段定义,再调用一个确定性的解析函数。

python 复制代码
def parse_register(value: int, fields: list[dict]):
    parsed = {}

    for field in fields:
        name = field["name"]
        start = field["start_bit"]
        end = field["end_bit"]
        mask = (1 << (end - start + 1)) - 1
        parsed[name] = (value >> start) & mask

    return parsed

这里 RAG 不负责算,RAG 负责找"规则"。确定性计算交给代码更可靠。

为什么要求"根据 RAG 回答"还是会幻觉

常见原因有几类:

  • 正确 chunk 没召回。
  • 召回了太多无关 chunk,干扰模型。
  • chunk 切分把关键上下文切断了。
  • metadata 没有过滤版本、权限或来源。
  • prompt 没有要求"无法从资料确认时说明不知道"。
  • LLM 把常识和检索资料混在一起回答。

解决时不要只改 prompt,要从检索链路排查:测试集、召回率、Top-K、Rerank、chunk、metadata、版本、来源权威性都要看。

BM25 和向量检索对 chunk 要求一样吗

不完全一样。

向量检索更看语义完整性,chunk 太碎可能语义不足,太长又可能稀释重点。

BM25 更看关键词分布。固定字数切分如果把关键词和上下文切开,也会影响 BM25。对制度、法规、接口文档,可以按标题、条款、章节切分,而不是完全按固定字数切。

Rerank 是怎么知道相关性的

Rerank 模型是训练出来的。训练数据通常是 (query, document, label) 或成对偏好数据,让模型学习什么样的文档更相关。

例如:

python 复制代码
pairs = [
    ["what is panda?", "The giant panda is a bear species endemic to China."],
    ["what is panda?", "Pandas are cute."],
    ["what is panda?", "The Eiffel Tower is in Paris."],
]

模型会学到第一条最相关,第二条一般,第三条不相关。

六、落地排查路径与总结:从 Demo 走向可维护系统

1. 调优时可以按这个顺序排查

遇到 RAG 效果不好,不建议一上来就换大模型。可以按下面顺序查:

text 复制代码
测试集是否明确
正确答案是否在知识库里
chunk 是否切得合理
metadata 是否能过滤权限、版本、来源
query 是否需要改写
是否需要 BM25 补关键词召回
是否需要 MultiQuery 提升覆盖率
是否需要 Rerank 精排
是否需要 Small-to-Big 补完整上下文
是否需要 GraphRAG 处理全局和关系问题
是否需要 Agentic RAG 做动态工具选择

一套比较稳的工程链路可以是:

text 复制代码
入库前:清洗、切分、问题生成、metadata 标注
入库时:原文索引 + 问题索引 + 向量索引 + BM25 索引
检索时:MultiQuery + Hybrid Search
排序时:Rerank
生成时:严格引用检索上下文,不知道就说不知道
上线后:健康度检查、版本管理、回归测试
复杂场景:Small-to-Big / GraphRAG / Agentic RAG

2. 总结

RAG 高级调优不是单个技巧,而是一整套知识工程。

知识库处理解决"知识本身是否可靠":问题生成、对话沉淀、健康度检查、版本管理。

高效召回解决"能不能找得到":MultiQuery、BM25、向量检索、混合检索、Rerank。

上下文组织解决"找到了怎么用":Small-to-Big、metadata 回溯、Top-K 控制、长文档分段。

GraphRAG 解决"跨文档、跨实体、全局理解":实体、关系、社区摘要、Global / Local Search。

Agentic RAG 解决"流程是否固定":让 LLM 根据问题决定是否检索、怎么检索、是否多跳。

最终目标不是堆更多工具,而是让系统在真实业务里稳定回答:

text 复制代码
答案来自哪里?
依据是否正确?
权限是否合规?
版本是否最新?
召回是否可评估?
更新是否可回归?

能回答这些问题,RAG 才从一个 demo 变成真正可维护的应用。

相关推荐
海兰1 小时前
【第54篇】Graph + Langfuse 可观测性实战
java·人工智能·spring boot·spring ai
知彼解己1 小时前
Go 开发环境 安装
后端·golang
KaMeidebaby1 小时前
卡梅德生物技术快报|单 B 细胞抗体技术:全犬源单抗制备流程、关键参数与性能验证
前端·数据库·其他·百度·新浪微博
KG_LLM图谱增强大模型2 小时前
scHilda:大模型与知识图谱分层融合,突破单细胞分型瓶颈
数据库·人工智能·知识图谱
元智启2 小时前
企业AI如何开发:智能体时代的安全治理架构与合规管控实践
人工智能·安全·架构
Appoint_x2 小时前
别让 LLM 当复读机:我给文件管理系统做 AI 助手时的三个关键设计
人工智能
hazel2 小时前
网络与工程化
前端
摄影图2 小时前
AI设计实用图片素材 适配多元创作推广需求
人工智能·科技·智能手机·aigc·贴图
snakeshe10102 小时前
SpringBoot 多人协作平台实战(7):完善登录模块 —— Spring 注解体系与密码加密实践
后端