给 Agent 接上可回查的本地资料检索

从六份合成资料开始,把每条检索结果带回具体文件和原文行,再检查更新、删除与空结果。

你能带走什么
  • 每条命中都保留原文行、文件哈希与版本化来源
  • 参数绑定与 MATCH 语法需要分别处理
  • 词法分数只用于排序,冲突证据需要进一步核对
  • 空结果、索引陈旧与删除失败都能用测试复现

1. 先做出一条可以回查的结果

一个助手被问到“导出的文件会保留多久”,本地目录里恰好有四种说法:旧手册写七天,新手册写三十天,会议纪要写十四天,临时演练通知写六十天。把第一条匹配内容直接塞进回答,会掩盖文件版本和适用时间。我们先让检索工具把相关原文和位置带回来,再由人检查这几份资料。

这篇教程从空目录建立一个小型 SQLite FTS5 索引。运行后可以看到命中的原文、片段、文件路径、行号与稳定来源标识;修改或删除资料后,也有测试检查旧结果是否退出索引。全部内容都是为本文原创的合成资料,程序不读取你的其他文件,不连接模型、网络或 embedding 服务。

读者需要会运行 Python 文件。准备 Python 3.10 或更新版本,并确认它自带的 SQLite 支持 FTS5。本次实测环境为 Python 3.12.14、SQLite 3.53.1。下面的完整文件可以直接复制,压缩包中的文件与正文相同。

已有的Agent 记忆教程处理用户事实的保存和纠正;本篇建立的是文档证据入口。需要理解工具怎样进入决策循环,可接着看最小 Agent 教程。本文不会重复实现那套循环。

2. 语料里故意留下冲突和检索盲区

make_corpus.py 会新建 corpus 目录,生成六个 UTF-8 文本和一份 manifest.json。本例用 splitlines 分行,语料使用普通换行。每个非空行作为一个检索单元,空行保留在行号计算中。这种切分足够支持短说明,也让结果中的第几行能直接在编辑器中找到。遇到长段落、表格或跨行条件,实际产品要另做切分与上下文拼接。

清单由资料维护者提供:doc_id 标识文档,revision 是人工修订标签,status 标记 current 或 archived,expires 是不再使用这份资料的日期。当前手册和会议纪要同时保留,程序不会根据文件名或者排序猜测哪份是最终规定。旧版另用 handbook-v1 保存,以便演示归档过滤。

为让中文词项边界可复现,五份教学文本已经人工加了空格,例如“导出 文件 保留 30 天”。这不是程序自动完成的分词。compact.txt 刻意保存“导出文件保留三十天”,用于观察默认 unicode61 的限制:查询整串能命中,查询“导出”不会命中这一行。实际中文资料需要选择并验证分词方案,不能把这里的合成成绩迁移成中文搜索质量结论。

六份合成资料包含旧版、现行手册、冲突会议记录、到期通知、无关资料和未分词中文。查询导出与保留,返回现行手册及会议记录。
六份合成资料包含旧版、现行手册、冲突会议记录、到期通知、无关资料和未分词中文。查询导出与保留,返回现行手册及会议记录。

3. 先检查 FTS5,再创建示例目录

将下文三个完整 Python 文件保存到一个新的练习目录。先运行这条能力检查;成功会打印 SQLite 版本。如果出现 no such module: fts5,应换用带 FTS5 的 Python/SQLite 构建,停在这里排查,不要悄悄把全文检索改成另一种实现。

能力检查

python3 -c 'import sqlite3; c=sqlite3.connect(":memory:"); c.execute("CREATE VIRTUAL TABLE t USING fts5(x)"); print(sqlite3.sqlite_version)'

创建、同步、查询、测试

python3 make_corpus.py
python3 retrieval.py sync
python3 retrieval.py query --query '导出 保留'
python3 -m unittest -v test_retrieval

make_corpus.py 在 corpus 已存在时会报 FileExistsError,避免覆盖你已经修改的练习文件。同步命令会创建 sample-index.sqlite,并重建该库里的 chunks 与 search 两张教学表;只对本例专用数据库执行,别把 --db 指向已有业务数据库。重复查询不会重新读取资料目录。

4. 把参数绑定和 MATCH 语法分开处理

查询“导出 保留”在本例里表示两项都要出现。expression 先按空白切开查询,再把每一段转成带双引号的 FTS 短语,并将输入中的双引号成对转义。只有应用自己插入 AND。用户输入的 OR、括号、冒号不会取得布尔操作或列选择的权限。

SQL 使用占位符绑定 MATCH 表达式、日期和数量。绑定参数能隔离 SQL 结构,但 MATCH 仍有自己的语法。例如把单个双引号作为绑定值直接交给 MATCH,会抛出语法错误。本文保留了这个失败测试。封装后的入口会把纯标点查询当作空结果,并限制输入长度、词项数量和返回数量。

“导出 OR 薄荷”会被当作三个必须同时满足的普通词项,因此返回空列表。引号与星号被安全装入短语后,仍会经过 tokenizer;它们不保证按字符原样检索。这是一个简化的全部词项匹配入口,不提供高级搜索语法,也没有自然语言问题理解能力。

对应规则可在 SQLite 的 FTS5 字符串说明中核对;本文的输入长度、AND 组合和错误处理是自行设计的教学约定。

5. 来源标识比一次查询的行 ID 活得更久

chunks 保存元数据和原文,search 保存每行文本的全文索引,二者通过本次索引里的整数 rowid 对应。重建索引会重新分配整数,所以结果里虽然保留了 id 便于调试,引用时要使用 source_id。

source_id 由 doc_id、revision、完整文件 SHA-256 与行号组合。相同内容重建,标识保持不变;只重命名路径并同步清单,也不改变标识。修改任何文件字节会改变哈希,连带改变该文档所有行的标识。这样旧引用不会无声地指向新版本,但也意味着它不适合用作跨版本段落追踪 ID。

text 是索引保存的完整原文行。snippet 是同一行中带方括号高亮的短片段,使用十八个词项的预算;它可能截断内容,人工核对时仍要看 text 与原文件。回查函数 verify_source 同时比对整个文件哈希和对应行内容。这个哈希用于发现变更,不能证明文档作者或内容真实性。verify_source 不认证 doc_id、revision、status 或 expires 的含义,也不会由 query 自动调用;需要核对当前文件的宿主应显式执行它。

本例没有保存全部历史文件,资料删除或被覆盖后,旧引用可能无法回查。需要长期审计时,应另建受控版本归档及保留规则。也不要把 snippet 直接作为 HTML 插入网页;其中包含原文,展示时应按普通文本转义。

来源标识组合文档ID、修订标签、文件SHA-256和行号;重建索引与仅改路径不改变标识,修改字节则改变标识。
来源标识组合文档ID、修订标签、文件SHA-256和行号;重建索引与仅改路径不改变标识,修改字节则改变标识。

6. 读第一次查询结果,暂时不要替它下结论

初次同步实际写入十二个非空行。查询“导出 保留”返回两条:handbook.txt 第 2 行写三十天,meeting.txt 第 2 行写十四天且待确认。两份资料的词法分数相同,由 source_id 提供固定的并列次序。当前返回的是冲突证据,仍需查资料负责人或正式规则才能形成确定答案。

本次实测命中行摘录;完整元数据见 query 输出

handbook.txt:2  [导出] 文件 [保留] 30 天 , 到期 后 清理 。
meeting.txt:2   [导出] 文件 [保留] 14 天 , 待 负责人 确认 。

lexical_score 来自 bm25(search),这里只用它排序,数值更小排在前面。它不表示可信程度或回答正确的概率。语料增加、行切分改变,分数也可能变化;归档和过期行虽不返回,仍存在 FTS 表中并影响词频统计。本例没有跨查询阈值,更没有把这个分数转换为置信度。

as_of 固定为 2026-10-07 以复现实验。到期日当天即排除,所以 notice 的六十天不会返回;改为 2026-10-06 则可以查到。生产宿主应决定检查时间,不要让模型通过随意回拨日期取回过期证据。归档与日期字段本身也需要维护,本程序不会自动识别文档是否已经失效。

回查命中行

from retrieval import connect, retrieve, verify_source
db = connect('sample-index.sqlite')
try:
    for hit in retrieve(db, '导出 保留', as_of='2026-10-07'):
        print(hit['path'], hit['line'], verify_source('corpus', hit))
finally:
    db.close()

这段检查应打印 handbook.txt 2 True 和 meeting.txt 2 True。它检查当前磁盘内容与索引快照的一致性;文件在检查后仍可能被修改,本例未锁定资料目录。

7. 让更新和删除有可观察的结果

先把 corpus/handbook.txt 的 30 改成 45,并在 manifest 中把对应 revision 改为 r3。此时再次 query 仍读旧索引,所以旧数字暂时继续出现;verify_source 对旧结果会返回 False。运行 sync 后,查询“30 天”得到空列表,“45 天”得到带新哈希和修订标签的结果。代码不会强制 revision 递增,因此维护者应自行更新标签;即使漏改,内容哈希仍会变化。

sync 先读清单和所有文件,验证完才开启替换事务。元数据与 FTS 行一起删除和重写;任何准备阶段的错误都不会清空旧索引。这个实现针对六份小文件,采用全量重建来让同步关系容易检查,没有增量队列、文件监听或并发写入协调。读文件期间请暂停编辑,否则不同文件可能来自不同时间点。

删除 meeting.txt 时,同时从 manifest 移除它的条目,再执行 sync。之后“14 天”应为空。若只删除文件却保留清单,sync 会报缺失文件,并保留旧索引。若只移除清单,文件还在磁盘,但它会退出下次索引。这几种状态要区分清楚,不能看到文件已删就认定搜索也已更新。

SQLite 表里的逻辑删除不等于磁盘安全擦除,也没有清理旧输出、备份或外部副本。本文只检查后续查询看不到被移除的材料。

先读完并验证清单与全部文件,再在一个事务中替换元数据和FTS表。失败保留旧索引;成功后旧词消失。不命中返回空列表。
先读完并验证清单与全部文件,再在一个事务中替换元数据和FTS表。失败保留旧索引;成功后旧词消失。不命中返回空列表。

8. 工具可以返回空,资料中的命令也只是资料

search_tool 是给 Agent 宿主使用的最小只读接口。宿主绑定数据库和 as_of,只开放 query 参数,将返回对象交给后续决策步骤。查询“发票”返回 hits: [],不会拼一个看起来合理的答案。查询“输出 保存”也为空,因为这份实现没有同义词扩展;一次不命中只能说明当前检索没有找到匹配材料。

返回对象包含 kind、query、as_of、hits 与 action_authority: false,没有 answer 字段。这个标记表达数据用途,不能防住提示注入。将来接入模型时,文档中的“忽略限制”“已获批准”等句子仍然只是来源内容,不能据此取得工具执行权限。涉及发送、写入和删除的动作必须另走宿主授权。

这套例子面向单一所有者的合成目录,不实现多用户鉴权。目录边界检查只是避免清单误指向外部文件,不是抵御能够修改本地目录、符号链接或数据库的攻击者的安全沙箱。真实私人资料还需要访问控制、备份策略和检索范围检查。

9. 完整语料生成器

语料直接写在这个文件里,既可以核对内容,也可以从空目录独立复现。它只创建新目录。

make_corpus.py

"""Create ONLY a new synthetic corpus directory; never overwrite one."""
import json
from pathlib import Path

DOCUMENTS = [
    ('handbook-v1', 'r1', 'archived', None,
     '旧版 导出 说明\n导出 文件 保留 7 天 。\n'),
    ('handbook', 'r2', 'current', None,
     '星屿 项目 导出 手册\n导出 文件 保留 30 天 , 到期 后 清理 。\n下载 入口 位于 设置 页面 。\n'),
    ('meeting', 'r1', 'current', None,
     '星屿 会议 纪要\n导出 文件 保留 14 天 , 待 负责人 确认 。\n'),
    ('notice', 'r1', 'current', '2026-10-07',
     '临时 导出 通知\n导出 文件 保留 60 天 , 仅限 本次 演练 。\n'),
    ('garden', 'r1', 'current', None,
     '窗台 养护 笔记\n薄荷 每周 检查 土壤 湿度 。\n'),
    ('compact', 'r1', 'current', None,
     '导出文件保留三十天\n'),
]


def make(folder):
    root = Path(folder)
    root.mkdir(parents=True, exist_ok=False)
    manifest = []
    for doc, revision, status, expires, text in DOCUMENTS:
        filename = doc + '.txt'
        (root / filename).write_text(text, encoding='utf-8')
        manifest.append(dict(doc_id=doc, revision=revision, status=status,
                             expires=expires, path=filename))
    (root / 'manifest.json').write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')


if __name__ == '__main__':
    make('corpus')
    print('created corpus: 6 synthetic files')

10. 完整索引与查询实现

这是全部运行逻辑,只使用 Python 标准库。操作失败会保留异常,调用方应处理错误,不能把索引故障伪装成没有资料。

retrieval.py

"""Offline, single-owner teaching index. Corpus is trusted local sample data."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import sqlite3
from datetime import date

SCHEMA = '''
CREATE TABLE IF NOT EXISTS chunks (
 id INTEGER PRIMARY KEY, source_id TEXT UNIQUE NOT NULL,
 doc_id TEXT NOT NULL, revision TEXT NOT NULL, path TEXT NOT NULL,
 sha256 TEXT NOT NULL, line INTEGER NOT NULL, text TEXT NOT NULL,
 status TEXT NOT NULL, expires TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(text, tokenize='unicode61');
'''


def iso(value):
    if not isinstance(value, str) or date.fromisoformat(value).isoformat() != value:
        raise ValueError('use YYYY-MM-DD')
    return value


def connect(path):
    db = sqlite3.connect(path)
    db.row_factory = sqlite3.Row
    try:
        db.executescript(SCHEMA)
    except sqlite3.OperationalError:
        db.close()
        raise
    return db


def sync(db, folder):
    """Validate the whole manifest first; replace both tables in one transaction."""
    root = Path(folder).resolve()
    manifest = json.loads((root / 'manifest.json').read_text(encoding='utf-8'))
    if not isinstance(manifest, list):
        raise ValueError('manifest must be a list')
    prepared, seen = [], set()
    for item in manifest:
        doc, rev = item['doc_id'], item['revision']
        if not all(isinstance(x, str) and re.fullmatch(r'[a-z0-9-]{1,40}', x)
                   for x in (doc, rev)) or doc in seen:
            raise ValueError('invalid or duplicate document ID')
        seen.add(doc)
        rel = item['path']
        if not isinstance(rel, str) or Path(rel).is_absolute():
            raise ValueError('relative path required')
        path = (root / rel).resolve()
        if not path.is_relative_to(root) or path.suffix != '.txt':
            raise ValueError('only corpus .txt files allowed')
        raw = path.read_bytes()
        if len(raw) > 100000:
            raise ValueError('sample file too large')
        body = raw.decode('utf-8')
        digest = hashlib.sha256(raw).hexdigest()
        status = item['status']
        if status not in ('current', 'archived'):
            raise ValueError('unknown status')
        expires = item.get('expires')
        if expires is not None:
            iso(expires)
        for line, text in enumerate(body.splitlines(), 1):
            if text.strip():
                source = f'{doc}@{rev}:{digest}#L{line}'
                prepared.append((source, doc, rev, rel, digest, line,
                                 text, status, expires))
    with db:
        db.execute('DELETE FROM search')
        db.execute('DELETE FROM chunks')
        for values in prepared:
            cur = db.execute('INSERT INTO chunks(source_id,doc_id,revision,path,'
                             'sha256,line,text,status,expires) VALUES(?,?,?,?,?,?,?,?,?)', values)
            db.execute('INSERT INTO search(rowid,text) VALUES(?,?)',
                       (cur.lastrowid, values[6]))
    return len(prepared)


def expression(query):
    # Only the application adds AND. Quotation/operator input stays phrase data.
    if not isinstance(query, str) or len(query) > 200 or '\x00' in query:
        raise ValueError('query must be at most 200 characters, without NUL')
    parts = query.split()
    if len(parts) > 12:
        raise ValueError('at most 12 terms')
    if not parts or not any(c.isalnum() for c in query):
        return None
    return ' AND '.join('"' + p.replace('"', '""') + '"' for p in parts)


def retrieve(db, query, *, as_of, limit=10):
    iso(as_of)
    if type(limit) is not int or not 1 <= limit <= 20:
        raise ValueError('limit must be 1..20')
    expr = expression(query)
    if expr is None:
        return []
    sql = '''SELECT c.*, snippet(search,0,'[',']',' … ',18) AS snippet,
                    bm25(search) AS lexical_score
             FROM search JOIN chunks c ON c.id=search.rowid
             WHERE search MATCH ? AND c.status='current'
               AND (c.expires IS NULL OR c.expires > ?)
             ORDER BY lexical_score,c.source_id LIMIT ?'''
    return [dict(r) for r in db.execute(sql, (expr, as_of, limit))]


def verify_source(folder, hit):
    root = Path(folder).resolve()
    path = (root / hit['path']).resolve()
    if not path.is_relative_to(root):
        return False
    try:
        raw = path.read_bytes()
        lines = raw.decode('utf-8').splitlines()
        return (hashlib.sha256(raw).hexdigest() == hit['sha256']
                and 1 <= hit['line'] <= len(lines)
                and lines[hit['line'] - 1] == hit['text'])
    except (OSError, UnicodeError):
        return False


def search_tool(db, query, *, as_of):
    # as_of is a host policy value, not a date chosen by the model.
    return {'kind': 'document_evidence', 'query': query,
            'as_of': as_of, 'hits': retrieve(db, query, as_of=as_of),
            'action_authority': False}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('action', choices=['sync', 'query'])
    parser.add_argument('--db', default='sample-index.sqlite')
    parser.add_argument('--corpus', default='corpus')
    parser.add_argument('--as-of', default='2026-10-07')
    parser.add_argument('--query', default='导出 保留')
    args = parser.parse_args()
    db = connect(args.db)
    try:
        if args.action == 'sync':
            print(json.dumps({'indexed_lines': sync(db, args.corpus)}))
        else:
            print(json.dumps(search_tool(db, args.query, as_of=args.as_of),
                             ensure_ascii=False, indent=2))
    finally:
        db.close()


if __name__ == '__main__':
    main()

11. 完整测试:保留成功与失败样例

测试每次创建独立临时目录与数据库。下面十三项检查在本次环境真实运行通过,其中包含预期抛出异常的反例;通过表示错误按预期发生,不表示这些操作成功。

test_retrieval.py

import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from make_corpus import make
from retrieval import connect, sync, retrieve, verify_source, expression, search_tool

TODAY = '2026-10-07'


class RetrievalTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name) / 'corpus'
        make(self.root)
        self.db = connect(Path(self.tmp.name) / 'index.sqlite')
        sync(self.db, self.root)

    def tearDown(self):
        self.db.close()
        self.tmp.cleanup()

    def search(self, q, **kwargs):
        return retrieve(self.db, q, as_of=kwargs.pop('as_of', TODAY), **kwargs)

    def test_conflicting_documents_are_both_returned(self):
        hits = self.search('导出 保留')
        self.assertEqual({r['doc_id'] for r in hits}, {'handbook', 'meeting'})
        self.assertEqual({r['line'] for r in hits}, {2})
        self.assertTrue(all(verify_source(self.root, r) for r in hits))
        self.assertTrue(all('[导出]' in r['snippet'] for r in hits))

    def test_archived_and_expired_excluded(self):
        self.assertEqual(self.search('7 天'), [])
        self.assertEqual(self.search('60 天'), [])
        self.assertEqual(len(self.search('60 天', as_of='2026-10-06')), 1)

    def test_empty_unrelated_and_synonym(self):
        for q in ('', ' ', '"', '***', '发票', '输出 保存'):
            with self.subTest(q=q):
                self.assertEqual(self.search(q), [])

    def test_unicode61_is_not_chinese_word_segmentation(self):
        hits = self.search('导出文件保留三十天')
        self.assertEqual([r['doc_id'] for r in hits], ['compact'])
        self.assertNotIn('compact', {r['doc_id'] for r in self.search('导出')})

    def test_quotes_and_operators_are_not_fts_programs(self):
        self.assertEqual(len(self.search('"导出" 保留')), 2)
        for q in ('导出 OR 薄荷', "x'; DROP TABLE chunks; --", 'NEAR(',
                  '导出 : (', '" OR "', '\"导出'):
            with self.subTest(q=q):
                self.search(q)  # must not raise SQL or MATCH syntax error
        self.assertEqual(self.db.execute('SELECT count(*) FROM chunks').fetchone()[0], 12)
        self.assertEqual(self.search('导出 OR 薄荷'), [])

    def test_direct_bound_match_still_has_syntax_errors(self):
        with self.assertRaises(sqlite3.OperationalError):
            self.db.execute('SELECT rowid FROM search WHERE search MATCH ?', ('"',)).fetchall()

    def test_reindex_is_stable_and_rename_keeps_id(self):
        before = {r['source_id'] for r in self.search('导出 保留')}
        sync(self.db, self.root)
        self.assertEqual(before, {r['source_id'] for r in self.search('导出 保留')})
        mf = self.root / 'manifest.json'
        items = json.loads(mf.read_text())
        (self.root / 'handbook.txt').rename(self.root / 'renamed.txt')
        items[1]['path'] = 'renamed.txt'
        mf.write_text(json.dumps(items), encoding='utf-8')
        sync(self.db, self.root)
        self.assertEqual(before, {r['source_id'] for r in self.search('导出 保留')})

    def test_update_replaces_old_terms_and_detects_stale_hit(self):
        old = self.search('30 天')[0]
        path = self.root / 'handbook.txt'
        path.write_text(path.read_text().replace('30', '45'), encoding='utf-8')
        self.assertFalse(verify_source(self.root, old))
        # An unsynchronized index still returns its old snapshot.
        self.assertEqual(len(self.search('30 天')), 1)
        sync(self.db, self.root)
        self.assertEqual(self.search('30 天'), [])
        new = self.search('45 天')[0]
        self.assertNotEqual(old['source_id'], new['source_id'])
        self.assertTrue(verify_source(self.root, new))

    def test_delete_requires_manifest_and_index_sync(self):
        mf = self.root / 'manifest.json'
        items = json.loads(mf.read_text())
        (self.root / 'meeting.txt').unlink()
        with self.assertRaises(FileNotFoundError):
            sync(self.db, self.root)
        self.assertEqual(len(self.search('14 天')), 1)  # old index intact
        mf.write_text(json.dumps([x for x in items if x['doc_id'] != 'meeting']))
        sync(self.db, self.root)
        self.assertEqual(self.search('14 天'), [])

    def test_invalid_manifest_cannot_wipe_index(self):
        mf = self.root / 'manifest.json'
        items = json.loads(mf.read_text())
        items[0]['path'] = '../outside.txt'
        mf.write_text(json.dumps(items))
        with self.assertRaises(ValueError):
            sync(self.db, self.root)
        self.assertEqual(len(self.search('导出 保留')), 2)

    def test_invalid_query_contract(self):
        for q in ('x' * 201, 'a\x00b', ' '.join(['a'] * 13), None):
            with self.subTest(q=q), self.assertRaises(ValueError):
                expression(q)
        with self.assertRaises(ValueError):
            self.search('导出', limit=True)
        with self.assertRaises(ValueError):
            self.search('导出', as_of='2026-1-1')

    def test_tool_returns_no_answer_on_no_hit(self):
        result = search_tool(self.db, '发票', as_of=TODAY)
        self.assertEqual(result['hits'], [])
        self.assertFalse(result['action_authority'])
        self.assertNotIn('answer', result)

    def test_snapshot_persists_on_reopen(self):
        path = Path(self.tmp.name) / 'index.sqlite'
        another = connect(path)
        try:
            self.assertEqual(len(retrieve(another, '导出 保留', as_of=TODAY)), 2)
        finally:
            another.close()


if __name__ == '__main__':
    unittest.main()

12. 运行之后能确认什么

本次十三项测试验证了:冲突资料同时返回、归档与到期边界、无关词及同义词不命中、中文整串的分词限制、引号与操作符输入、直接 MATCH 的语法失败、重建及重命名后的来源稳定性、更新后的旧词退出、删除时的清单一致性、越界清单失败时保留索引、输入契约、工具空结果和重新打开数据库后的持久化。测试没有测量召回率、语义理解、模型回答质量或大型资料库性能。

接下来可以自己加一份包含相同关键词却用途不同的材料,检查它是否也被检出,再决定需要哪些业务过滤。先把“为什么返回这一行”解释清楚,才有条件评估切分、排序和检索方案的改变。

如果下一步要把这一路词法结果与另一份排名合并,可以接着做RRF 排名融合实验。先注意输入粒度:本篇返回原文行,那个实验接收文档级 ID 排名。应先定义行到文档的映射与折叠规则,让两路使用相同的身份和检索范围;不要直接混加原始分数。

技术核对日期为 2026-10-07。主要依据为 SQLite FTS5 官方文档中的 unicode61、查询字符串、snippet 与 bm25 部分。本文语料、代码、切分方式、来源标识和测试均为原创教学设计。

这就是语义 RAG 吗?

本例只实现词法检索和来源回查,没有 embedding、语义排序或模型回答。

为什么中文文本里有空格?

教学语料显式预分词,让 unicode61 的词项边界可复现。未分词中文另有失败例,不能据此推断真实中文检索效果。

删除文件后为什么还查得到?

查询读取索引快照。需要同步更新清单并重建索引;清单仍引用缺失文件时同步会失败,旧索引保留。