在完成文本嵌入模型的部署之后,我在FastAPI端实现了用于为角色大模型提供小说原作信息的RAG流程。

一、RAG流程设计

本系统的RAG的作用是在角色大模型推理前提供与当前情境相关的原作信息,从而保证角色行为与小说设定的一致性。在具体实现上,通过RAG获取的原作信息将作为提示词的一部分输入到角色大模型,因此RAG流程函数的用法是被提示词构造函数调用。在逻辑上,RAG流程函数的参数包括当前调度的角色、本局游戏使用的小说以及当前世界信息,这些数据均由Spring Boot后端调用FastAPI端角色调度接口时传参,从而逐步传到RAG流程函数。

RAG的一般流程是:给定用户输入等内容作为查询文本,通过文本嵌入模型得到查询文本的嵌入向量,然后使用该向量基于向量相似度在向量知识库进行检索,得到相似度最高的k个块,然后将检索结果整合为提示词的一部分,用于LLM输入,以增强LLM的上下文。

不过,考虑到本系统中角色大模型承担的AI角色扮演职能,我在这次的RAG设计中没有采用完全依赖向量相似度的方式,而是将这局游戏使用的小说的世界观和此次被调度的角色的设定作为必须要检索得到的数据。

结合小说库构建时Qdrant数据库的设计,小说知识集合中有世界观world、角色设定character、原作文本片段plot这3种point,我将预期获取的原作信息分为必要信息和辅助信息两类:

必要信息包括小说的世界观以及当前被调度角色的人物设定,这两部分信息在每次角色行为都必须被取出,不管该块与查询文本的向量相似度是否足够高。

辅助信息包括其他角色设定原作文本片段,这部分通过向量相似度检索获取,向量相似度计算基于的查询文本就是传入的世界信息文本,并根据小说字段过滤得到本次小说的数据,确保检索范围限定在当前小说内。

所以,整个RAG流程可以概括为:先固定获取世界观和当前角色设定,然后基于世界信息文本进行两次向量检索,分别获取原作文本和其他角色设定,最后将这些结果合并为一段原作信息文本。

这样的设计可以在保证关键背景信息不丢失的前提下,引入与当前情境最相关的补充信息,提升角色推理的稳定性。

二、代码实现

RAG流程要使用文本嵌入模型,而且要直接操作Qdrant数据库,所以它实现在FastAPI模型能力层中,并按照功能拆分为多个服务函数。其中一部分是对Qdrant向量数据库的基础操作封装,包括根据小说名称获取世界观数据、获取角色设定、以及基于向量相似度检索原作文本和其他角色信息。

这部分代码我使用AI辅助生成,主要完成标准的数据库查询和封装逻辑。具体提问如下:

请分别编写以下4个Python函数,封装对Qdrant数据库的操作,分别实现直接根据小说名查询世界观数据、直接根据小说名和角色名查询角色设定数据、根据向量相似度检索小说原作文本片段、根据向量相似度检索其他角色设定数据:
def fetch_required_world_knowledge(novel: str)
def fetch_required_character_knowledge(novel: str, character_name: str)
def search_auxiliary_plot_knowledge(novel: str, query_text: str, top_k)
def search_auxiliary_character_knowledge(novel: str, query_text: str, exclude_character: str, top_k)
小说知识集合的名字是novel_knowledge;每个函数返回数据的payload列表。

AI回答如下:

下面给出这4个函数的代码实现:

```python
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue

from app.config import settings
from app.services.embedding_service import embed_text

COLLECTION_NAME = "novel_knowledge"


def get_qdrant_client() -> QdrantClient:
    return QdrantClient(url=settings.qdrant_full_url)


def fetch_required_world_knowledge(novel: str) -> list[dict]:
    """固定取该小说的 world point。"""

    client = get_qdrant_client()

    points, _ = client.scroll(
        collection_name=COLLECTION_NAME,
        scroll_filter=Filter(
            must=[
                FieldCondition(key="novel", match=MatchValue(value=novel)),
                FieldCondition(key="type", match=MatchValue(value="world")),
            ]
        ),
        limit=1,
        with_payload=True,
        with_vectors=False,
    )

    return [p.payload for p in points if p.payload]


def fetch_required_character_knowledge(novel: str, character_name: str) -> list[dict]:
    """固定取当前角色的人物设定 point。"""

    client = get_qdrant_client()

    points, _ = client.scroll(
        collection_name=COLLECTION_NAME,
        scroll_filter=Filter(
            must=[
                FieldCondition(key="novel", match=MatchValue(value=novel)),
                FieldCondition(key="type", match=MatchValue(value="character")),
                FieldCondition(key="character", match=MatchValue(value=character_name)),
            ]
        ),
        limit=1,
        with_payload=True,
        with_vectors=False,
    )

    return [p.payload for p in points if p.payload]


def search_auxiliary_plot_knowledge(
    novel: str,
    query_text: str,
    top_k: int = 3
) -> list[dict]:
    """按语义相似度检索原作文本片段。"""

    client = get_qdrant_client()
    query_vector = embed_text(query_text)

    results = client.query_points(
        collection_name=COLLECTION_NAME,
        query=query_vector,
        query_filter=Filter(
            must=[
                FieldCondition(key="novel", match=MatchValue(value=novel)),
                FieldCondition(key="type", match=MatchValue(value="plot")),
            ]
        ),
        limit=top_k,
        with_payload=True,
        with_vectors=False,
    )

    return [
        {
            **point.payload,
            "score": point.score,
        }
        for point in results.points
        if point.payload
    ]


def search_auxiliary_character_knowledge(
    novel: str,
    query_text: str,
    exclude_character: str | None = None,
    top_k: int = 3
) -> list[dict]:
    """检索其他角色设定。"""

    client = get_qdrant_client()
    query_vector = embed_text(query_text)

    must_conditions = [
        FieldCondition(key="novel", match=MatchValue(value=novel)),
        FieldCondition(key="type", match=MatchValue(value="character")),
    ]

    must_not_conditions = []
    if exclude_character:
        must_not_conditions.append(
            FieldCondition(
                key="character",
                match=MatchValue(value=exclude_character)
            )
        )

    results = client.query_points(
        collection_name=COLLECTION_NAME,
        query=query_vector,
        query_filter=Filter(
            must=must_conditions,
            must_not=must_not_conditions,
        ),
        limit=top_k,
        with_payload=True,
        with_vectors=False,
    )

    return [
        {
            **point.payload,
            "score": point.score,
        }
        for point in results.points
        if point.payload
    ]
```

调用示例:

```python
world = fetch_required_world_knowledge("三体")
character = fetch_required_character_knowledge("三体", "汪淼")
plots = search_auxiliary_plot_knowledge("三体", "汪淼在科学边界中遇到了什么")
others = search_auxiliary_character_knowledge("三体", "汪淼和史强的关系", exclude_character="汪淼")
```

在此基础上,我根据RAG流程的设计实现了RAG流程的主函数,将上述几个基础操作组合起来完成完整的检索流程。该函数接收小说名称、角色名称和世界信息文本作为输入,先查询必要信息,再执行辅助检索,最后将结果拼接为统一格式的原作信息字符串返回。

def retrieve_novel_knowledge(
    novel: str,
    character_name: str,
    query_text: str,
) -> str:
    """完整RAG流程函数,根据当前世界信息字符串检索原作知识。"""

    if not novel or not novel.strip():
        raise ValueError("novel 不能为空")

    if not character_name or not character_name.strip():
        raise ValueError("character_name 不能为空")

    if not query_text or not query_text.strip():
        raise ValueError("query_text 不能为空")

    world_items = knowledge_service.fetch_required_world_knowledge(
        novel=novel
    )

    current_character_items = knowledge_service.fetch_required_character_knowledge(
        novel=novel,
        character_name=character_name
    )

    other_character_items = knowledge_service.search_auxiliary_character_knowledge(
        novel=novel,
        query_text=query_text,
        exclude_character=character_name,
        top_k=3
    )

    plot_items = knowledge_service.search_auxiliary_plot_knowledge(
        novel=novel,
        query_text=query_text,
        top_k=3
    )

    sections = [
        _format_section_single("一、小说世界观", world_items),
        _format_section_single(f"二、本次扮演角色设定:{character_name}", current_character_items),
        _format_section("三、相关其他角色设定", other_character_items),
        _format_section("四、相关原作片段", plot_items),
    ]

    return "\n".join(sections)

在实现过程中,我重点规定了检索结果的组织方式。由于世界观、角色设定和原作文本的语义性质不同,我在拼接结果时对它们做了简单区分,而不是简单拼接成一段文本,这样在后续构造提示词时可以更清晰地控制各部分内容。函数的示例返回文本如下,示例小说为《亮剑》:

一、小说世界观
故事发生在抗日战争时期的华北地区,八路军独立团团长李云龙与政委赵刚共同指挥部队。李云龙性格粗犷、战术灵活,在李家坡战斗中,他通过土工作业缩短冲击距离,集中投掷手榴弹的战术,成功歼灭日军山崎大队,但未能与山崎本人决斗。战后,独立团分兵游击,李云龙与赵刚在摩擦中加深情谊。与此同时,晋绥军358团团长楚云飞欣赏李云龙的胆识,两人在日军控制的河源县城茶馆会面,约定共同赴日军宪兵队长平田一郎的生日宴,伺机行动。李云龙的警卫员魏和尚武功高强,桀骜不驯。整个华北战场呈现胶着状态,八路军、国民党军与日军之间斗争复杂,游击战成为主要作战形式。
二、本次扮演角色设定:李云龙
八路军129师386旅独立团团长。中等个子,脑袋偏大,性格粗犷豪放、狡黠务实、作战勇猛且富有创造性。出身贫苦,有练武经历,说话带脏字,善于和战友、老乡插科打诨。战术上不拘一格,在李家坡战斗中提出用土工作业接近、集中投掷手榴弹的战术。看不起知识分子出身的政委,但内心重承诺(如为兑现对张部长的承诺而想缴获指挥刀)。
三、相关其他角色设定
1. 独立团一营营长。作战经验丰富,反应快,能迅速理解李云龙的战术意图(如提出土工作业)。在李家坡战斗中负责带领一营进行土工作业和首轮手榴弹投掷。
2. 八路军独立团政委,不到25岁,身材单薄,脸色白皙,带书卷气。资历深,曾是“一二九运动”负责人之一,北平燕京大学学生,八路军中少见的高学历人才。性格沉稳、理智,善于克制情绪,能容忍李云龙的粗鲁。有原则,在军事行动中坚持指挥员应在指挥位置。对李云龙有时孩子气的行为(如向山崎挑战决斗)感到好笑又无奈。
3. 八路军386旅旅长,李云龙的上级。了解李云龙的脾性,说话直接风趣(如猜他在骂街)。指挥果断,在攻击受阻时下令全力总攻。爱惜部下,既给李云龙下达死命令,又要求他活着回来。认可李云龙的打仗才能,也清楚他爱惹事的毛病。
四、相关原作片段
1. 李云龙在望远镜里看得清清楚楚。他拉住一个刚撤下阵地兄弟部队的一个营长问:山坡边缘离那个环形工事有多远?那个挂了彩的营长马上明白什么意思:有80多米,手榴弹够不着。李云龙皱着眉头说了句:硬冲不是办法,这是赔本生意。李家坡战端一开,整个华北地区都热闹起来。日军参谋长板垣征四郎,日本驻中国派遣军司令官西尾寿造大将、日军驻山西第一军司令官莜冢义男、日军华北派遣军司令官多田骏都在各处的司令部注视着地图。各级司令部的作战参谋们在紧张地进行着图上作业,地图上李家坡周围已被不同颜色的巨大箭头所包围。日军驻潞安的36师团、驻汾阳的16旅团、驻太原的第9旅团、驻阳泉的第4旅团各部,都在日夜兼程向李家坡地区分进合击。
与此同时,整个华北地区的八路军各部的打援部队也已经和日军增援部队纷纷接上火。八路军总部的命令是死的:不惜一切代价,阻敌增援。于是,围绕着李家坡这个名不见经传的小山包,整个华北地区的八路军部队和日军各部已摆出决战的架势,而交战双方的最高指挥官的目光还是都注视着山西境内的这个往日默默无闻的小山包。李家坡之战注定要载入史册了。
386旅旅长陈赓在望远镜里看到自己的攻击部队像潮水般一浪高过一浪地向主阵地冲击,而顷刻又像退潮般地退下,山坡上躺满了穿着灰色军装的尸体。陈赓一咬牙,抓起电话下了命令:“集中全部炮火轰击山顶,炮弹要全部打光,不过啦,预备队全部出动,踩也要把李家坡给我踩平。”李云龙在电话里说:“旅长,我有个要求。”
陈赓没好气地说:“你哪儿这么多事?快说。”“师属炮兵营暂时由我指挥,就这点儿要求。拿不下李家坡我也用不着提着头来见你,因为那时我肯定已经躺在山坡上啦。我只能向你保证,我们独立团全团一千多号人决不会有人活着退出战斗。”
陈赓的眼睛湿润了,握住话筒的手有些颤抖:“同意你的要求,炮兵营由你指挥。同志哥,我要你拿下李家坡,还要你活着回来,这是命令!你必须执行。”
2. 平安县城这一战使李云龙名声大噪。先是在太原的日军莜冢义男中将又发了脾气,他盯着军用地图上晋西北平安县城的周边地区,上面表示敌我态势的红蓝箭头犬牙交错,纠缠在一起,一个参谋军官用比例尺算出双方交战地域的面积竟有上千平方公里,粗粗估算一下,双方投入的总兵力也达到30多万。平安县一千多守军全部阵亡,山本特工队全部玉碎,各路增援部队也都伤亡过半,北同蒲铁路被破坏成数截,整个晋西北地区日军的有线通讯网被全部破坏,在多数地区,都是连绵二十多公里的电线杆没剩下一根。有些日军据点由于增援造成兵力空虚,被支那军地方武装乘虚而入。这一切都是那个叫李云龙的八路军团长造成的,他究竟是个什么样的人?怎么会具有如此巨大的破坏力?连装备精良、训练有素的山本特工队都全军覆没,无一生还,他只是个小小的团长,怎么能调动如此之众的部队参战?连支那政府军都卷入了。莜冢义男怎么也想不明白,他的情报部门早已专设了李云龙的档案,可是只有薄薄的一页纸:姓名:李云龙国籍:中国民族:汉族年龄:不详籍贯:中国湖北省与河南省交界地域——大别山。(湖北人?河南人?不详)简历:1927年参加中共组织的黄麻暴动,后转入中共工农红军。(历任职务不详)据悉,李曾参加过中共谓之长征的战略性撤退,属中共军内较为资深之军官,并多次立有战功。
(立功原因不详)现任中共军队国民革命第十八集团军一二九师独立团团长,无军衔。性格:桀骜不驯,胆识过人,意志坚毅,思维方式灵活多变,多采用逆向思维,处事从不拘泥于形式,是个典型的现实主义者。纪律性差,善做离经叛道之事。政治观点:偏左,但不激进,缺乏理论,对政治缺乏兴趣。文化程度:偏低。没受过军校教育。也无资料表明受过何种教育。特长:实战经验丰富,战斗中心理素质极稳定,由此推测,有当过士兵的经历。精通射击术,能熟练使用各种轻武器,能双手同时使用手枪达到首发命中,受过格斗训练及刀术训练,科目为中国武术,程度不详。下面就没有了,档案中连张李云龙的照片都没有,这个混蛋长得什么样子?
莜冢义男想,有朝一日我要亲手砍掉他的脑袋,把他的头做成标本放在办公桌上。
唉,徒有一流的情报系统,怎么只有关于这个李云龙支离破碎的资料呢?莜冢义男感到胸口隐隐做痛,有口闷气堵在那里吐不出来。情报部门的解释是,该人从未在支那政府军中任过职,与支那政府各机构无任何接触,帝国圣战之前,该人本是中共军内名不见经传之小人物,按规定,帝国情报部门只收集敌国或假想敌国军队少将级或师级以上军官之资料。莜冢义男愤愤合上档案,闭上眼睛……
八路军总部也是战斗结束后十几天才搞清楚是怎么回事,据查,有七八个主力团和大批地方部队都投入战斗,这些部队在没有命令的情况下,稀里糊涂地成了李云龙战役格局上的棋子,打援的打援,伏击的伏击,忙个不亦乐乎。
3. 在李家坡环形工事里的山崎觉得不大对劲,怎么四周一片寂静?静得日军士兵们心里一阵阵发冷,军人们是最不喜欢寂静的,因为战场上的寂静往往包含着更大的危险,预示着更激烈的战斗。山崎凭直觉意识到,八路军正酝酿着一次更猛烈的攻击。激战一天下来,山崎大队已经伤亡过半,他不大在乎伤亡,他知道各路援军正在向他合拢,凭借有利的地形、充足的弹药、近距离的空中支援,再坚持两天没问题,他希望凭借自己一个大队的兵力把八路军的主力牢牢地吸引住,待援军的反包围圈合拢,再来个中间开花。他渴望着在李家坡之战中建功立业,一战成名。
山崎发现山坡下伸出一个白铁皮做的拐脖喇叭,那边传来日语的喊话声:“日军山崎大队长听着,八路军独立团团长李云龙得知阁下武士世家出身,精通剑道。李团长认为,贵国之剑道,不过是得中国剑术之皮毛而已,师徒之名分,早在唐朝便已有定论。如阁下很珍惜武士的荣誉,就停止射击,走出工事,李团长愿意和阁下用刀剑进行正式决斗,李团长用军人的荣誉担保,如败在阁下的剑下,八路军独立团立即停止攻击,给贵军让开道路。”“叭!叭!”日军狙击手开火了,铁皮喇叭顿时被打了几个窟窿;129师敌工部的日语翻译被震得虎口发麻。
山崎那边回话了:“八路军李团长阁下,鄙人对贵军作战之英勇深感钦佩,对阁下的挑战深感荣幸,鄙人十分珍惜武士之称号,愿与阁下切磋剑术,无奈军务在身,不能只身与阁下决斗,非常抱歉。如阁下能率部队攻入鄙人的环形工事,鄙人则愿意在肉搏战中与阁下一决雌雄。”在独立团指挥所里,赵刚感到好笑:“什么乱七八糟的,还都以为自己是中世纪的骑士呢,动不动就要决斗。”李云龙不屑地说:“山崎这小子真没劲,算不上条汉子。”一营的土工作业进展很快,日军也很快发现了八路军的企图,追击炮、掷弹筒纷纷打来。一营的几十个战士在爆炸声中血肉横飞……129师的迫击炮营在李云龙的指挥下开火了,几十发炮弹像黑乌鸦似的从天而降,落进日军工事,火光闪闪、硝烟弥漫,日军炮兵一时顾不上土工作业的一营,急忙对八路军炮兵做压制性轰击,一营的掘进速度更快了。
山崎用无线电台呼唤空中支援,几架零式战斗机呼啸而来。李云龙用重机枪火力组成密实的火网,迫使日军飞机不敢进入俯冲位置……山崎发现自己的冷汗正顺着脑门往下流,他眼睁睁地看着眼前这片开阔地被一点点地蚕食,离他的工事只有30米了,日军的迫击炮和掷弹筒几乎是垂直发射了,距离太近了,出膛的炮弹极有可能会落到自己头上。
叭!随着一颗红色信号弹腾空而起,山崎明白八路军最后的攻击即将开始,日军士兵们各种武器的准星都无声地对准前方。
没有人露头。

三、性能评估

在完成RAG流程实现之后,我对其效果进行了简单评估。

评估方法是人工构建30条测试数据,每条测试数据包括小说名称、角色名称、查询文本以及预期命中的原作关键词。对于每一条查询,执行RAG流程后检查返回的原作信息文本中是否包含预期关键词。如果包含则视为命中,否则视为未命中。按照这一标准统计整体命中率。

最终测试结果显示,RAG流程的命中率为93.33%,明显高于任务书中要求的60%指标。这一结果说明当前的检索策略在大多数情况下能够正确获取与情境相关的原作信息,为后续角色推理提供有效支持。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐